ADR-0012: Sequencer bond and slashing

The sequencer's ordering receipt (Appendix E2, solieum-receipt) turns a soft confirmation into an accountable promise: two validly signed receipts for the same (batch, position) with different order hashes are…

Status: accepted (accepted, implementing). Dated 2026-09-03.

2026-09-03. Status: accepted, implementing.

Context

The sequencer's ordering receipt (Appendix E2, solieum-receipt) turns a soft confirmation into an accountable promise: two validly signed receipts for the same (batch, position) with different order hashes are a contradiction anyone holding both can prove, and prove_double_assign has done that judgement, tested, since the receipt crate landed. What was missing was the consequence. The website's own honesty line said it: "turning a proof into a penalty needs a slashing contract that does not exist yet." Without it, equivocation is an embarrassment, not a cost.

The user's decision that frames this ADR: the 7-day challenge window stays on mainnet; the trust gap to close is single-operator accountability, not settlement latency.

Decision

A new L1 program, solieum-sequencer-bond, holds a bond posted by the receipt-signing key and slashes it on a proven double-assignment.

  • Identity is the receipt-signing key. The bond account is a PDA of the sequencer's Ed25519 pubkey — the key clients verify receipts against — not the settlement payer. Slashing must hit the identity that made the promise.
  • The judgement on-chain is the crate's judgement. The program calls solieum_receipt::prove_double_assign with a verifier backed by the Ed25519 precompile: the two signatures are verified by the Ed25519SigVerify program in the same transaction, and the bond program reads that program's instruction data through the instructions sysvar to learn which (pubkey, message, signature) triples were verified. A transaction whose precompile instruction fails never executes, so a triple present in that data is a verified one. Only triples inline in the precompile instruction are accepted (no cross-instruction offsets), and only instructions before the slash instruction are read.
  • Consequence. The bond is moved: a configurable share to the reporter, the remainder to the incinerator address. The bond account stays with slashed = true and the proof's batch and position, so the fact is permanent and readable; a slashed key cannot re-register under the same account.
  • Unbonding is slower than the window. A sequencer may request to unbond and withdraw after unbond_delay_secs, set at initialization and intended to be at least the challenge window (7 days): a receipt from the sequencer's last batch must remain slashable for as long as that batch's root can still be disputed.
  • Permissionless reporting. Anyone may slash; the reporter pays the transaction and receives the share. There is no reporter allowlist.

Consequences

  • Equivocation now costs the sequencer its bond, immediately and without a dispute game — the proof is the contradiction itself.
  • Batch contradiction (a receipt the published batch does not honour, prove_batch_contradiction) is not slashable on-chain in this step. With ADR-0011 the root's transactions_root is a tree over the block's steps rather than a flat hash, so a one-position inclusion proof becomes possible; adding slash_batch_contradiction against that tree is the next step, recorded here rather than faked.
  • The settlement program does not yet consult the bond: a slashed sequencer can still propose roots there. Wiring submit_root to refuse a slashed proposer is a one-check follow-up in solieum-scc, kept out of this ADR so the two programs can ship independently.
  • The bond is separate from the proposer bond in the settlement program: that one backs a root; this one backs a key's promises.

A receipt names no chain (found 2026-09-11)

The first bullet of the decision — identity is the receipt-signing key — is also how the guarantee is defeated, and this record was silent on it.

Receipt::signing_bytes is 53 bytes: version, order hash, batch, position, l2 slot. There is no chain id, no genesis hash and no domain tag. prove_double_assign accepts on exactly three conditions — same signer, same (batch, position), different order hash — and nothing in either function asks which chain a receipt was signed for.

So two chains sharing one sequencer key sign valid equivocation proofs against each other. The order hashes differ precisely because they are different chains, and because every chain numbers batches from zero the collision starts at the first block rather than eventually.

This is not hypothetical here. Alpha (7788) and beta (7789) both sequenced under ENFjJFWJbFNACmxNW84NaDC3WLiwmKHoceJPsex3hmC4: one devnet/sequencer.json was passed to both. Alpha's counter reached batch 10 and beta's 158, so batches 0 through 9 exist on both chains under one key — ten pairs, each a complete slashing proof against a 0.1 SOL bond that slash_equivocation will accept from anyone. It cannot be repaired after the fact: the receipts are signed, and rotating the key stops new pairs without unsigning old ones. Exposure was contained only by where the receipts live — blocks.jsonl in two untracked datadirs, not the published DA payload.

A second consequence follows from the same bullet and is worth stating separately, because it survives the fix above. The bond PDA is seeded [BOND_SEED, sequencer_pubkey] — the key, not the chain — so one bond backs every chain a key ever signs for, while the parameters below size it against what a sequencer could gain by reordering inside one window on one chain. A key running N chains is under-bonded by a factor of N.

What the fix is, and why it is not an afternoon

Putting the chain into signing_bytes is necessary but not sufficient on its own: prove_double_assign never compares the field, so two receipts naming different chains would still satisfy its three conditions. The minimum is two changes together — carry the chain in the signed bytes, and require both receipts to name the same one. That is a receipt-format break, so it is a new genesis, and the sequencer bond program must be upgraded in step because it recomputes those bytes on-chain.

Binding a bond to a chain is a further step: the chain would have to reach the verifier from somewhere it cannot be spoofed, which means the bond account records it at registration, which changes the account layout and needs a migration for existing bonds. Bond today is sequencer, lamports, bonded_at, unbond_requested_at, slashed, slashed_at, slash_batch, slash_position, slash_first, slash_second, reporter, bump — no chain anywhere in it.

That attaches a cost to a decision being taken now, which is why it is recorded here rather than discovered later: one bond program serving several chains can only adopt the chain-recording layout for all of them at once. A program upgrade is a single event, and every bond account already open under it is on the old layout. Sharing a bond program across chains is still reasonable — it is one deployment, one config, one audited binary — but it converts that migration from a per-chain step into a coordinated one, and the chains that share it are the chains that must move together.

Note also that requesting an unbond buys the sequencer nothing here: slash_equivocation checks only !slashed and lamports > 0, and never reads unbond_requested_at. Exposure ends at withdraw, not at the request. That is the intent stated above — a receipt stays slashable while its root can still be disputed — written out as the operational fact, because "unbonding" reads like a step that reduces risk and does not.

Neither is done. The change was deliberately not made during the beta to gamma migration: a consensus format nobody has reviewed, adopted under a deadline, is worse than a known gap that is written down.

What is in place is operational, not structural: one sequencer key per chain, enforced by a preflight in the start scripts themselves (solieum-testnet/check-sequencer-keys.py), which reads datadir receipts rather than start scripts because a stopped chain leaves no script to inspect. The successor chain has its own key. Full evidence is in onchain/DEVNET.md under "ALPHA AND BETA ALREADY SHARE A SEQUENCER KEY".

Parameters (devnet)

min_bond 0.1 SOL, unbond_delay_secs 604,800, reporter_bps 5,000. Mainnet values are a governance decision sized like ADR-0006's coverage rule: the bond must exceed what a sequencer could gain by reordering inside one window.

Deployed (devnet, 2026-09-03)

Program6KQdRji1ZKg3tGLUczeUyX9ywxnivXKbSSAM3hbzzY4L (301,528 bytes, max-len 400,000; upgrade authority = the devnet payer)
Config PDAHXZYbRbEfC6Mg3VBSypjVhacYdbJuiGRZt4ctpcZwHDX — min bond 0.1 SOL, unbond delay 604,800 s, reporter 5,000 bps; init tx 3EbHo6k6S8L1xZsKWDwgQUrDx7KQmSUDFAyXJqaAp3VhviiPur5UbFxDqWYrtFqNAdx5yeL4GaYeDTgUinAM2JSo
Sequencer bondENFjJFWJbFNACmxNW84NaDC3WLiwmKHoceJPsex3hmC4 bonded 0.1 SOL, tx 5Guo8j6xK6b5RnqVMxLoGHMviWCcCweWzrPKDzdTFpxTcf1HXesy3Ek4WCuzsS7T9VbehbTFjpJ33FkedfeR9WtB
Demonstration slashthrowaway key 3rBnHad9GeLXE2dHTrUGvn6AULLXXFAd2xAh2EbJGVpG bonded 0.1 SOL, signed two receipts for (batch 9, position 0) with different order hashes, slashed by the payer as reporter in tx 3tAAVMwhLVh2r2dbWZS4ATsEfLiJpa1zNUjjn5c9ZjZ2meSU1AY11cdYfBr1rU5nzqty2yjSMo7UTQrS5nvx8tNu; a second attempt fails on-chain with AlreadySlashed (0x1773). Identical receipts are refused before any transaction is sent (IdenticalReceipts).

Every transaction is viewable on solscan with ?cluster=devnet.

Tooling

  • getSequencerBond [pubkey] (node RPC; the explorer's Sequencer bond card on the sequencer's address page and the banner on Receipts) reads the config and bond from the settlement cluster on every call.
  • solieum-node bond --init --payer K | --register LAMPORTS --sequencer-key F | --top-up N | --unbond | --withdraw | --sequencer PK and solieum-node slash --receipt-a A.json --receipt-b B.json --payer K [--bond-id ID]. The receipt files are exactly what getReceipt returns. slash judges locally with the crate first, so a pair that is not equivocation costs nothing.
  • In-process test: cargo test -p solieum-node bond runs the real .so under the node's runtime with a hand-built instructions sysvar.