BIP300 · Hashrate escrow

BIP300 Hashrate Escrow

BIP300 is not a messaging format — it is two state machines with deadlines. A sidechain slot and a withdrawal bundle each begin as a proposal, accumulate miner votes block by block, and either cross a threshold or die. Understanding the death conditions matters more than understanding the messages.

Companion to Drivechain Messages M1–M8, which covers the wire format of all eight messages and the coinbase / peer-to-peer split. This page assumes that split and goes into the escrow mechanics underneath it.

Two clocks, one shape

Both BIP300 objects follow the same life: proposed in a coinbase message, voted on in later coinbase messages, then resolved — activated or paid out if the votes arrive in time, discarded if they do not. Every rule below is a variation on that shape.

The two escrow objects and the messages that drive them.
ObjectProposed byVoted byResolved byDies when
Sidechain slot M1 ProposeSidechain M2 AckSidechain activation — no message aged out, or arithmetically unreachable
Withdrawal bundle M3 ProposeBundle M4 AckBundles M6 Withdrawal tx aged out past withdrawal_bundle_max_age

A sidechain activating produces no message at all. It is a conclusion every node reaches independently by counting M2s — which is why nothing needs to be broadcast and why nobody can forge it.

The sidechain slot lifecycle

%%{init: {"theme":"base","themeVariables":{"fontFamily":"IBM Plex Mono, monospace","fontSize":"13px","primaryColor":"#F6EEDC","primaryTextColor":"#15181D","primaryBorderColor":"#A9761C","lineColor":"#5E6672","secondaryColor":"#EDEFF3","tertiaryColor":"#F8E9E2"}}}%%
stateDiagram-v2
    direction LR
    [*] --> Proposed : M1 in a coinbase
    Proposed --> Proposed : M2 vote, count += 1
    Proposed --> Active : votes > threshold
and age within window Proposed --> AgedOut : age > max_age Proposed --> Unreachable : too many non-ack blocks Active --> [*] AgedOut --> [*] Unreachable --> [*]
Two ways to fail, not one. A proposal can run out of time, or run out of arithmetic — the enforcer discards it as soon as the remaining window can no longer contain enough votes, without waiting for the deadline.
Activation
activates = vote_count > threshold   // strictly greater
         && proposal_age <= max_age  // inclusive

The asymmetry is deliberate and easy to misread: the vote threshold must be strictly exceeded, while the age window is inclusive. A proposal sitting exactly on the threshold has not activated.

Early death — the rule most people miss
max_fails = max_age - threshold
fails     = age - vote_count

failed = age > max_age
      || (age > max_fails && fails >= max_fails)

Every block that goes by without an M2 for this proposal is a "fail". Once enough of them accumulate, no sequence of future votes can reach the threshold before the deadline — so the enforcer drops the proposal immediately rather than letting it linger. On a network where miners are indifferent, proposals die of this long before they age out.

Which window applies depends on whether the slot has ever been used. Reading the wrong row is a common source of "that activation was impossible" conclusions.
PresetUsed slot — age / votesUnused slot — age / votes
MAINNET26,300 / 13,1502,016 / 1,815
FORKNET144 / 7236 / 30
SHORT10 / 510 / 5

A slot previously occupied by a sidechain is a used slot and gets the longer, more forgiving window — taking over an existing slot is meant to be harder to do quietly than claiming an empty one. On FORKNET, a proposal 119 blocks old with 73 votes fails the unused rule (36 / 30) and passes the used one (144 / 72), by a single vote.

The withdrawal bundle lifecycle

%%{init: {"theme":"base","themeVariables":{"fontFamily":"IBM Plex Mono, monospace","fontSize":"13px","primaryColor":"#F6EEDC","primaryTextColor":"#15181D","primaryBorderColor":"#A9761C","lineColor":"#5E6672","secondaryColor":"#EDEFF3","tertiaryColor":"#F8E9E2"}}}%%
stateDiagram-v2
    direction LR
    [*] --> Pending : M3 names a bundle txid
    Pending --> Pending : M4 upvote, count += 1
    Pending --> Pending : M4 downvote or alarm
    Pending --> Payable : count > inclusion_threshold
    Pending --> Expired : age > bundle_max_age
    Payable --> PaidOut : M6 tx spends the treasury
    PaidOut --> [*]
    Expired --> [*]
Only the last step is a transaction. M3 and M4 are coinbase messages, so a bundle becomes payable purely by miner vote. The M6 that finally moves the coins is an ordinary transaction — and it is rejected unless the bundle it matches already crossed the threshold.
What M6 is checked against
m6id must exist in pending_m6ids[sidechain]
info.vote_count > withdrawal_bundle_inclusion_threshold

Strictly greater again. A bundle nobody proposed, or one still short a vote, produces MissingPendingWithdrawal or InsufficientVoteCount — the transaction is refused, not merely ignored.

How M4 actually votes

M4 is the subtlest message in BIP300. A single M4 votes across every active sidechain at once, positionally: the upvote list must have exactly one entry per active sidechain, in the enforcer's canonical ordering. A wrong-length list is rejected outright.

0x00 RepeatPrevious Re-apply whatever the previous block's M4 did. If that block had no effective M4 — absent, or abstain-only — this does nothing. The first block after genesis repeats nothing.
0x01 OneByte One byte per active sidechain. Each value is an index into that sidechain's pending bundle list — not an identifier.
0x02 TwoBytes The same, two bytes per sidechain, for slots with more than 254 pending bundles.
0x03 LeadingBy50 Carries no per-sidechain data. For each active sidechain, upvote the bundle whose vote count leads the next-highest by at least 50. A sole bundle counts as leading an implicit zero-vote rival, so this is the compact way to keep an uncontested bundle moving.

Two sentinel values

0xFF / 0xFFFF Abstain. Skip this sidechain entirely — no upvote, no downvote, no change to any count.
0xFE / 0xFFFE Alarm. Downvote every bundle on that sidechain that currently has positive votes. This is the miners' brake.

An upvote is never only an upvote: choosing one bundle downvotes the sidechain's other pending bundles in the same action. Abstain is the only value that leaves every count untouched, which makes "abstain" and "vote for nothing" meaningfully different from each other.

The Ctip chain

Each sidechain's balance lives in exactly one UTXO — the treasury — and every deposit and withdrawal spends it and recreates it. The pointer to the current one is the Ctip:

struct Ctip { outpoint: OutPoint, value: Amount }

So a sidechain's whole custody history is a single chain of spends. There is no set of coins to reconcile, and no balance to compute — there is one output, and its value is the sidechain's holdings.

%%{init: {"theme":"base","themeVariables":{"fontFamily":"IBM Plex Mono, monospace","fontSize":"13px","primaryColor":"#E2F0F2","primaryTextColor":"#15181D","primaryBorderColor":"#2B7A87","lineColor":"#5E6672","secondaryColor":"#EDEFF3","tertiaryColor":"#F6EEDC"}}}%%
flowchart LR
    C0["Ctip n-1
value T(n-1)"] -->|"M5 deposit
value increases"| C1["Ctip n
T(n-1) + deposit"] C0 -->|"M6 withdrawal
value decreases"| C2["Ctip n
T(n-1) - payouts - fee"] C1 --> C3["…"] C2 --> C3
Direction of travel is the classifier. The enforcer does not look for a tag to tell M5 from M6 — it compares the new treasury value against the old one. Larger is a deposit, smaller is a withdrawal, equal is an error.
Invariants the enforcer enforces on any treasury-touching tx
value > old   ->  M5 deposit
value < old   ->  M6 withdrawal
value == old  ->  ZeroDiff  // refused

Alongside the comparison, a handful of structural rules hold the chain together: spending the treasury without recreating it is TreasurySpentWithoutNewCtip; creating a new one without spending the old is OldCtipUnspent; two treasury outputs for the same sidechain in one transaction is MultipleOpDrivechainOutputs. A deposit into a sidechain that was never activated is refused outright, so no one can fabricate a treasury.

The shape of an M6id

A withdrawal bundle is voted on before the transaction that pays it exists in a block — so the identifier miners vote on cannot be an ordinary txid, which would change as inputs are attached. The M6id is computed from a normalised form of the transaction with its inputs stripped.

%%{init: {"theme":"base","themeVariables":{"fontFamily":"IBM Plex Mono, monospace","fontSize":"13px","primaryColor":"#F6EEDC","primaryTextColor":"#15181D","primaryBorderColor":"#A9761C","lineColor":"#5E6672","secondaryColor":"#EDEFF3","tertiaryColor":"#E2F0F2"}}}%%
flowchart TB
    A["M6 transaction"] --> B{"output 0 is an
OP_DRIVECHAIN treasury UTXO?"} B -->|no| X["MissingTreasuryOutput"] B -->|yes| C{"exactly one input?"} C -->|no| Y["MissingTreasuryInput
or ManyInputs"] C -->|yes| D["Tn = value of output 0"] D --> E["clear the input list"] E --> F["P_total = sum of outputs 1..n"] F --> G["F_total = T(n-1) - Tn - P_total"] G --> H["hash the normalised tx = M6id"]
Inputs are cleared before hashing. That is what lets a bundle be named and voted on while the transaction paying it has not yet been assembled — the identifier commits to the payouts and the resulting treasury value, and to nothing that can still change.

The fee falls out of the arithmetic rather than being stated: since Tn = T(n-1) − P_total − F_total, knowing the previous treasury value, the new one, and the payouts fixes the fee exactly. A bundle cannot quietly overpay itself in fees without changing its own M6id — and therefore invalidating every vote already cast for it.

Failure modes, collected

What each refusal actually means when it appears in an enforcer log.
ConditionObjectMeaning
age > max_ageslotProposal ran out its window without enough M2s
fails >= max_failsslotToo many silent blocks; threshold now unreachable
InvalidVotesbundleM4 upvote list length ≠ number of active sidechains
UpvoteFailedbundleM4 index points past the end of the pending list
age > bundle_max_agebundleBundle expired before collecting enough upvotes
InsufficientVoteCountM6Bundle real, but still at or below the threshold
MissingPendingWithdrawalM6No such bundle was ever proposed for this sidechain
ZeroDiffM5/M6Treasury value unchanged — neither deposit nor withdrawal
AmbiguousM5/M6Transaction reads as both at once
TreasurySpentWithoutNewCtipchainTreasury spent and not recreated
OldCtipUnspentchainNew treasury created without consuming the old