ADR-0011: The v1 trace is what a dispute is about
A state root commits to pre_state → post_state for one block. The dispute game (ADR-0003, solieum-dispute, solieum-dispute-game) does not reason about blocks. It reasons about a trace: num_steps state…
Status: accepted (amended the same day when the fee step class landed in the program). Dated 2026-09-03.
Status: accepted, 2026-09-03; amended the same day when the fee step class landed in the program. Implemented in onchain/node/src/trace.rs, played by the defender in onchain/node/src/l1.rs, verified by one_step_verify_witnessed and one_step_verify_witnessed_fee in solieum-dispute-game.
Context
A state root commits to pre_state → post_state for one block. The dispute game (ADR-0003, solieum-dispute, solieum-dispute-game) does not reason about blocks. It reasons about a trace: num_steps state commitments, roots[0] = pre_state, roots[T] = post_state, which the two parties bisect until one step is left, and which a one-step verifier then decides from Merkle witnesses alone — the instruction proven into the root's transactions_root, the touched accounts proven into the agreed pre-state, the instruction executed natively, the post-state recomputed by solieum_state_tree::update_root.
Until now the node committed transactions_root = sha256(tx_hashes) — a flat hash — and had no notion of a step. A root the node proposed could therefore be challenged but never defended: no bisection had an honest midpoint to commit, and no witness could bind an instruction to the batch. The watchdog (runbook W3) made that failure visible; this ADR makes the roots defensible, and says exactly where they still are not.
Decision
A step is one state transition the node applied, in published order:
- the flat fee leaving a transaction's payer;
- then, only if the transaction succeeded, one step per instruction;
- a withdrawal (debit plus withdrawal leaf) is one step;
- a deposit credit from the inbox is one step;
- a consumed forced entry is no step — it changed nothing.
roots[k] is the state-tree root (state::state_root_with, accounts and withdrawal leaves in one tree) after k steps.
transactions_root is a state tree over the steps: leaf key H("solieum-ix" ‖ i), leaf value the step's value. For a step in the verifier's v1 class the value is what the verifier recomputes, H("solieum-transfer" ‖ from ‖ to ‖ lamports). Every other step is committed opaque under its own domain, H("solieum-step-opaque" ‖ tag ‖ position ‖ instruction index) — a value no verifier knows, by construction, so an opaque step can never be mistaken for a witnessable one.
A step is witnessable when one of the program's witnessed one-steps can execute it. Two classes exist:
- a transfer: a system transfer between two accounts that are plain system accounts before it and still exist after it; value H("solieum-transfer" ‖ from ‖ to ‖ lamports);
- a fee: the flat fee burned from a plain system payer that keeps a balance; value H("solieum-fee" ‖ payer ‖ fee), verified by one_step_verify_witnessed_fee with a single-leaf update_root.
The tree drops empty accounts, and update_root can update leaves but not remove or create them, so a transfer that empties its source or creates its destination, and a fee that empties its payer, are opaque.
The node defends its own roots from the trace: at its move it bisects with roots[mid]; on a unit range over a witnessable step it sends one_step_verify_witnessed with the witness built from the step's pre-state; on a unit range over an opaque step it reports the root as undefendable and lets the timeout run. It refuses to play from a trace whose endpoints do not reproduce the claim.
Consequences
- Roots committed after this change (devnet: after root 7) bind their instructions the way the game verifies them. Roots 1–7 on devnet carry the flat hash and are not defensible; nothing on-chain reads the field except the game, so nothing else changes.
- The fee step is defended. It was the step every transaction began with and no verifier could execute, so a challenger could win on timeout against any honest root by narrowing to it. The program now has a fee class — the payer proven into the pre-state, the fee subtracted natively, the root recomputed from that one leaf — and the node builds and sends the fee witness. The harness plays both sides of it on-chain: an honest fee step verifies, a fee off by one lamport is refuted.
- Program calls, withdrawals and deposits are opaque and need their own verifier classes; so is any transfer or fee that empties an account. The class list is the honest boundary of what "fraud proof" means on this chain today: a block made only of plain transfers between funded accounts is fully defensible; a block with a deposit credit or a withdrawal in it is not, at those steps.
- The challenger names num_steps when opening the game. If it differs from the block's step count, the node plays anyway — positions past the last step commit the post-state — and says so. A challenger who inflates it can drive the game to a position no witness covers; the fix is to bind the step count into the root record so the program can refuse a claim of the wrong length. Not built.
- Building a trace replays the block from the published log with a state snapshot per step. A node without a datadir has no trace and reports our_move it cannot make; that is the same operator warning the watchdog already gave, now with the reason.