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.
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.
| Object | Proposed by | Voted by | Resolved by | Dies 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.
%%{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 --> [*]
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.
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.
| Preset | Used slot — age / votes | Unused slot — age / votes |
|---|---|---|
| MAINNET | 26,300 / 13,150 | 2,016 / 1,815 |
| FORKNET | 144 / 72 | 36 / 30 |
| SHORT | 10 / 5 | 10 / 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.
%%{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 --> [*]
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.
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.
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.
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
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.
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"]
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.
| Condition | Object | Meaning |
|---|---|---|
| age > max_age | slot | Proposal ran out its window without enough M2s |
| fails >= max_fails | slot | Too many silent blocks; threshold now unreachable |
| InvalidVotes | bundle | M4 upvote list length ≠ number of active sidechains |
| UpvoteFailed | bundle | M4 index points past the end of the pending list |
| age > bundle_max_age | bundle | Bundle expired before collecting enough upvotes |
| InsufficientVoteCount | M6 | Bundle real, but still at or below the threshold |
| MissingPendingWithdrawal | M6 | No such bundle was ever proposed for this sidechain |
| ZeroDiff | M5/M6 | Treasury value unchanged — neither deposit nor withdrawal |
| Ambiguous | M5/M6 | Transaction reads as both at once |
| TreasurySpentWithoutNewCtip | chain | Treasury spent and not recreated |
| OldCtipUnspent | chain | New treasury created without consuming the old |