ADR-0008: One-step verification
A one-step verifier receives the disputed instruction and the accounts it touches as Merkle witnesses. Those accounts are L2 accounts.
Status: accepted. Dated 2026-08-26.
Status: accepted (2026-08-26) · Prompt: §6 C7, §5 D3
Context: the witnessed one-step verifier is real and measured — 19 216 CU for system transfers (svm-spike scenario M), and a second class for SPL token transfers alongside it (scenario S). Building the second class made the shape of the remaining work unambiguous, and that is worth recording before anyone plans around a wrong assumption.
The constraint nobody can design around
A one-step verifier receives the disputed instruction and the accounts it touches as Merkle witnesses. Those accounts are L2 accounts. They do not exist on Solana, they have no addresses the runtime knows, and no program owns them there.
Therefore the verifier cannot CPI into the program being disputed. There is nothing to invoke: invoke needs real accounts owned by real programs, and a witness is bytes with a proof. Every instruction class the verifier supports must have its semantics re-implemented inside the verifier program.
This is not a limitation of the current implementation. It follows from what a fraud proof is.
Decision
Support instruction classes explicitly, one at a time, and refuse everything else.
- Each class gets its own instruction on the dispute program, with the class boundary enforced in code (parse_token_account rejects delegated, frozen, uninitialized, and wrapped-SOL accounts rather than guessing at semantics it does not implement).
- Anything outside a supported class is StepNotExecutable — a rejection, never a default. Traces containing such steps fall back to the placeholder verifier and are, honestly, not yet trustlessly disputable.
- Every class carries a differential test against the real program. For SPL tokens the harness executes the batch with the actual SPL Token program and then plays an HONEST dispute game: it can only end in ProposerWins if the re-implementation reproduces the real program byte for byte. A one-unit divergence flips the verdict and fails the gate. Re-implementation risk is managed by test, not by assertion.
Classes supported today: system transfer, SPL token transfer (both in-place mutation of existing accounts — see ADR-0007 for why creation needs a different tree first).
The end state, named so it is not mistaken for the current one
General one-step verification — any instruction of any deployed program — requires executing SBF bytecode on-chain against witnessed accounts: an interpreter, with the program's own ELF supplied as part of the witness and proven against a commitment to the deployed binary. This is the well-trodden path in other ecosystems (MIPS and RISC-V interpreters serving exactly this role), and it is a large, self-contained piece of work rather than an extension of what exists.
Started, 2026-09-10: the interpreter and its one-step verifier exist as a rule
crates/solieum-sbf (adf88ba, 9b03aaf), pure and wired to nothing. Vm::root commits registers, program counter, memory and the halt flag — the state a bisection descends into. step takes exactly one instruction and leaves the machine untouched on any fault. verify_one_step takes the two roots bisection narrowed to, a machine hashing to the first, and one instruction proven out of a Merkle commitment over the instruction stream, and says whether the claimed post-state is true.
That Merkle commitment is how the ELF requirement above is met without putting an ELF in a transaction: a witness carries one instruction and its path, sixteen hashes for a 65,536-instruction program, so a whole verification is about nineteen hashes against the 700,000 CU a witnessed step is allowed. The commitment binds the instruction's index, the program's instruction COUNT (jump bounds are checked against it, so a prover free to name a different length could re-aim a jump) and its padding leaves.
It is a class, not an emulator, and that is deliberate. Division and modulo, 32-bit ALU, memory and call/syscall each fault rather than being guessed, because this interpreter and the node's execution must agree exactly and a step "verified" against semantics that differ from the runtime's would prove the wrong thing. A witness that does not hold up — including one for an unsupported instruction — decides NOTHING rather than deciding against the proposer, so an unsupported step stays opaque exactly as it is today.
The memory commitment followed (c56b6bb): a page tree of 32-byte pages at depth 59, covering the whole 64-bit address space so real SBF addresses sit where they actually are with no region map to get wrong. A proof is 59 hashes, about 9,000 CU, so a load and a store together cost roughly 18,000 of the 700,000 — which is why it is not the depth-256 account tree, whose proofs already account for most of an existing one-step's budget. An all-zero page hashes to the empty tag, so writing zeros returns the tree to its untouched state and two machines with identical memory cannot hold different roots.
That puts the 8-byte aligned load and store inside the class. Alignment is the safety property: an aligned 8-byte access always lies inside one page, so a verifier holding ONE witnessed page has provably seen the whole access. Unaligned faults rather than being split, since the second half would be in a page nobody proved; narrower forms stay out, because sub-word semantics are the kind of thing that is a consensus bug when guessed. The witnessed page must prove against the machine's own memory root before the step may read it, and a store folds back through the same proof, so a write lands where it was proven and nowhere else.
So what exists covers registers, control flow — since 2026-09-14 including calls and returns, below — and aligned 8-byte memory access inside the supported class. A program that logs, does a CPI or otherwise reaches a syscall is still opaque.
Read before building the serialization: this node uses DIRECT MAPPING
Surveyed 2026-09-10 against solana-bpf-loader-program 1.18.26, which is what the node actually runs. The finding changes the shape of the account-witnessing step, so it is recorded before anything is built on the wrong one.
serialize_parameters is called with copy_account_data = !direct_mapping, and direct_mapping is bpf_account_data_direct_mapping in the feature set. exec.rs builds its runtime with FeatureSet::all_enabled(), so the feature is ON and account data is never copied into the input buffer. Instead:
- The input buffer carries only headers per account — duplicate marker or 0xff, the signer/writable/executable flags, four zero bytes, key, owner, lamports, data length — then MAX_PERMITTED_DATA_INCREASE (10,240) plus 16 bytes of zero realloc padding, then the rent epoch. Where the data would sit under the copying path, nothing is written.
- Each account's data becomes its own MemoryRegion, mapped at a computed virtual address and aliasing the account's real bytes — readonly, writable or copy-on-write depending on the account.
- An account with empty data gets no region at all.
Two consequences, and the first is good news:
The data the VM reads IS the account's data, not a copy. So the page tree that now commits account data (ca26aa6) is already the right commitment for those memory regions: one tree serves both the state leaf and the VM's view of it, and account data is not committed twice. That was not obvious before looking.
But VM memory is not one flat space, so committing it as one is wrong. solieum-sbf's MEMORY page tree suits the stack and heap, which are genuinely flat; the input region is a small header buffer plus a set of regions that alias account data. The memory commitment for a program call should therefore be COMPOSED — the header buffer, plus each account's existing data root — rather than a single flat tree that would copy every account into a second commitment and then have to keep the two agreeing.
What that leaves for the serialization step is the layout arithmetic: where each header sits and at which virtual address each account's data region begins, which is a deterministic function of the account set. It must be verified against the runtime rather than transcribed from it, because a verifier that disagrees with the runtime about an address disputes the wrong bytes. solana_bpf_loader_program::serialization is public and the node already depends on it, so a differential test against the real serializer is available and is the gate that step should pass before it is trusted.
The remaining pieces, in order, were that layout with its differential test, the composed memory commitment above, the dispute game instruction that calls this verifier — all three built by 2026-09-13, the last measured below — and only then the multi-transaction stepping the section below describes, which now stands behind one more piece: the seam that lets a game descend from an L2 step into the VM steps inside it.
Measured, 2026-09-13: one interpreted step verified on the real program costs 58,495 CU
one_step_verify_interpreted on solieum-dispute-game calls this crate's verify_one_step with nothing but the witness: one eight-byte SBF store into a mapped account, one account page, a 59-level memory proof, the instruction proven out of the code commitment. On the real rebuilt program in the harness it confirms the honest post-state, refutes a wrong one, and decides nothing for a witness of another pre-state, at 58,495 CU against the 700,000 the node declares for a witnessed step (the transfer class costs 19,216). That is the whole verifier — the decode, the bounds-checked memory, the Merkle proofs — not the spike's floor below, and it is the number that says one interpreted step sits comfortably inside one transaction. The program grew from 607,624 to 672,072 bytes to carry it. Wired to no game yet: the instruction's own doc names the seam it waits on.
Built, 2026-09-14: calls and returns are in the class, held to rbpf by a differential test
call in its immediate form and exit at depth were the last control-flow instructions outside the class, and without them no compiled function boundary can be stepped through. Both are in now, from a reading of rbpf 0.8.3 — the interpreter the node's runtime executes programs with — and the reading is pinned rather than trusted: onchain/node/src/interp_fidelity.rs runs hand-assembled bytecode through rbpf itself and through this crate and compares the result register, every byte the program stored (the registers a return must restore are stored by the program so both sides show them), and the instruction count — the same reason and the same place as the input-layout differential. A function that calls itself is refused by both at the same call: sixty-three run, the sixty-fourth is refused. rbpf meters the refused call and the class leaves the machine untouched, which is the one difference and is not a semantic one.
What a call does is what rbpf's push_frame does under the runtime's direct-mapping configuration (fixed 4,096-byte frames, gaps off, depth 64): save r6–r9 and r10, remember pc + 1, move r10 up one frame, jump to the target. A return restores all of it. Two commitments make that verifiable from one witness:
- The call stack is inside the machine's root. Vm.frames is a hash chain over the frames a call pushed and no exit popped (frame_chain, zero when empty) with Vm.depth beside it, and both are in Vm::root, because two machines that agree on every register and differ in where exit returns to are different machines. A return's witness carries the top frame and the chain beneath it, and the step refuses unless they hash to the committed root, so a prover cannot invent a return address.
- The function registry is committed. A call's immediate is a key rbpf resolves through the program's function registry, which is not in the bytecode. registry_root commits the sorted (key, pc) entries with their count under their own domain, and a call's witness carries a RegistryProof for its key; an unknown key faults and stays opaque. rbpf consults the loader's syscall registry before the function registry, and this crate has no syscall registry; that order cannot be observed on any program the runtime loads, because rbpf refuses to load a v1 program whose function key collides with a syscall key (SymbolHashCollision, under the node's all-enabled feature set), so resolving through the function registry alone is the same rule.
The cost is bytes, and it moves a line drawn above. Every witness now carries the depth and the frames root — 42 bytes with the two presence flags — a return adds its 80-byte frame, a call its registry proof. The realistic witness sized at 990 bytes on 2026-09-11 is now 1,032 — thirty-seven over the 995 of instruction-data room — so a realistic interpreted step is two chunks rather than one transaction cleared by five bytes, which is what the chunking was built ahead of. On the real rebuilt program the harness confirms, refutes and declines exactly as before at 58,939 CU (was 58,495); the program is 681,864 bytes (was 672,072), still wired to no game and not deployed. Syscalls remain outside the class: a program that logs or does a CPI is opaque at that instruction, as before. What the crate can now hold is a compiled program's own control flow from entry to exit, up to the first syscall, division, 32-bit or narrow-memory instruction it reaches.
Built, 2026-09-14: the machine a program starts in, held to the runtime — and the map it must still enforce
The seam has two halves: the VM's entry state derived from the L2 pre-state, and its exit state folded back into the L2 post-state. The L2 side moved the same day, when an opaque instruction step began committing its program, account metas and data (state commitment 3, 1c31c54). The first piece of the VM side is now built, solieum_sbf::entry:
- A program is committed from what its loader produced, never from an ELF parsed a second way. commit_program takes the relocated text, the function registry in the loader's key order, the entrypoint and the read-only section at the address it was mapped, and commits them as one ProgramCommitment: code root and count, registry root and count, entrypoint, and the read-only section by its data root under the account-data tree. It refuses what could not run — text that is not whole instructions, an entrypoint or a function outside the text, a registry out of order, a read-only section that is empty, past the tree's 16 MiB, or outside the program region.
- The machine at step zero is a function of what the chain commits. entry_state takes that commitment, the read-only bytes (checked against it) and the instruction as the v3 leaf identifies it, with each account read from its state leaf, and returns the machine: r1 at the input, r10 at the top of the first frame, the program counter at the entrypoint, the loader's buffer laid into the machine's own memory region by region, and the read-only section and each account's data mapped as regions by root. An image with more regions than a witness counts in its one byte is refused.
- Where a byte lands was the trap. The loader does not map its buffer as one region: it closes a region before each account's data, maps the data between, gives each account's headroom a region of its own, and steps the next region's start past up to eight buffer bytes. The first draft laid the buffer in contiguously from the start of the input, which puts every byte after the first account with data at the wrong address — and its own unit test passed, because a test written from the same reading agrees with it. serialize::Layout now records the loader's whole input map from the same accounting that places the data, with each region's permission: headers and the last region writable, an account's data and headroom writable exactly when the runtime's can_data_be_changed holds. The entry state lays the buffer in from that map.
What holds it to the runtime, in the node crate where the loader is a dependency:
- the input map against serialize_parameters, region for region — address, length, permission, and a buffer slice's offset — across shapes that exercise each permission rule;
- a program that reads a header, mapped data, the bytes shifted past that data, the next header, the final region and its own code, stores into its own account, the stack and the heap, and calls a function, traced through rbpf under the node's own configuration (from exec::program_environment, the one construction the runtime uses) and through the class from the entry state: all 36 steps agree on every register and the program counter, and the regions account data is read through carry the roots the accounts' state leaves carry;
- a real program, solieum_sbf_spike.so at 199,208 bytes, loaded by the runtime's own loader and committed from what that loader made of it: 20,045 instructions, 182 functions, the entrypoint at instruction 5,110, and a 178,200-byte read-only section at the start of the program region holding the text at its own address.
What building it measured: the class does not enforce the runtime's memory map, and that has to close before any game descends into the machine. rbpf refuses a store into a read-only region and any access outside a mapped one. The class serves its private memory at every address and carries no permission per region. Probed at 26 boundary addresses against the runtime's own mapping, it agrees wherever the runtime serves — one straddle of two regions aside, which the class refuses by design and the runtime stitches together, a lost step rather than a wrong one — but it serves 7 reads and performs 13 stores the runtime refuses: stores into the program's read-only section and into another program's account data and headroom, and reads and stores past the stack, the heap, the program and the input, and in unmapped space. Each is a step the runtime faults on and the class would execute to a post-state, which is the direction that costs an honest party a dispute, not merely a step left opaque. The test lists both sets exactly, so the change that closes the gap has to empty them. That change is the next piece: the image carries what is mapped and what may be written, and a step outside it faults. Because the witness then carries the map, the wire format changes and the game is rebuilt with it. The earlier sentence that the crate can hold a compiled program's control flow from entry to exit stands for control flow; for memory it holds only once the map is enforced.
Enforced, 2026-09-14: the runtime's memory map, and the verifier flaw enforcing it found
The gap the entry-state work measured is closed the same day. Every access, on the node and in the verifier, now goes through one rule over a committed map, MemoryImage::access:
- inside a data-backed region — the program's read-only section or an account's data — that region answers, and a store needs the region to be writable;
- touching such a region without lying inside it is refused;
- anything else is the machine's own memory, mapped only inside the stack (64 frames of 4,096 bytes), the heap (32 KiB, the default budget this node runs every instruction under, with no heap request processed) and the input up to where it ends, and a store may not touch a read-only span of the input: the realloc headroom of an account the program may not change.
The image commits the map: each region's permission, where the input ends, and the read-only spans, all derived from the loader's input map that is already held to serialize_parameters region for region. The stack and heap sizes and addresses are constants, pinned to the node's own runtime environment by a differential test. No permissive memory is left: the machine's flat page tree is storage, not a Memory, so no step can reach it without the map.
At the same 26 boundary probes against the runtime's own mapping, the class now serves exactly what rbpf serves, through the node's memory and through the one page a verifier holds: the 7 reads and 13 stores it used to perform are refused. The one difference left is an access across two adjacent regions, which rbpf stitches together and the class refuses by design — a step left undecided, never a step decided wrong.
The flaw enforcing it found. The verifier checked a witnessed page against the root of the space the witness claimed, and never against the space the address belongs to. The machine's private page tree is empty under every account region, and a proof of an empty page verifies against the real private root. So a party could answer a load from an account's data with a genuine proof of that zero page, and the verifier would confirm "the account read zero" and refute the honest party. A test demonstrated it confirming exactly that lie before the fix, and it now refuses it: a witnessed page answers only for an address the committed map resolves to the page's own space. An older test that appeared to cover the case passed only because it gave its image a zero private root, under which the page failed to prove for an unrelated reason. The instruction carrying this verifier moves no dispute state and is wired to no game, so no dispute could have reached the flaw.
The map costs bytes on every witness: one permission byte per region, eight for the input's end, and a count plus sixteen bytes per read-only span. The realistic witness goes from 1,032 to 1,060 bytes, 65 over the 995 of room, still two chunks; the dense one from 1,560 to 1,589; the largest possible witness from 17,551 to 21,895, so MAX_WITNESS rises to 22 KiB. The image's commitment domain moved to version 2, so no root of the old shape can be read as the new. The game was rebuilt with it at 734,976 bytes and verifies the harness step at 59,645 CU.
Built, 2026-09-15: the machine a program ends in, read back as the runtime reads it
The seam's VM half now has its other end, solieum_sbf::exit. When a program returns zero, the runtime's deserialize_parameters reads every account the instruction names for the first time back out of the machine, and exit_accounts reads them the same way, in the same order:
- the lamports, from the account's header, moved only as set_lamports lets them: never out of another program's account, never on a read-only or an executable one;
- the data length, from the header, refused past the 10,240 bytes of realloc headroom or the 10 MiB largest account;
- the data, resized when the program may change the account, with grown bytes taken from the headroom that starts where the old data ended — and a length it may not change refused with the runtime's own reason, which stands only if the length actually moved;
- the owner, from the header last, changed only on the program's own writable, non-executable account whose data, as just resized, is all zeros.
Then the instruction's lamports, counted once per account, must sum to what they summed to at entry. Data written in place needs nothing more: under direct mapping the program wrote the account's own bytes, which the class holds in that account's region pages. A non-zero r0, or a machine that has not halted, reads back nothing.
Reading an account back needs to know where the loader put each field, which the runtime records as SerializedAccountMetadata. serialize::Layout::slots records the same addresses from the same accounting that places the regions, and the input-map test holds them to the runtime's own record for every instruction account in every shape. Which reason refuses a data change is one function, data_change_refusal, which also decides the permission a region is mapped with at entry, so the entry and the exit cannot disagree about who may write an account.
What holds it to the runtime, in the node crate: 19 scenarios over one instruction — the program's own account with data, another program's account, an empty account, a read-only one, an executable one, and a duplicate — each storing the same words through the runtime's own memory mapping and through the class's memory, then running deserialize_parameters and the transaction context's balance check on one side and exit_accounts on the other. Seven exits are accepted: nothing written, lamports moved, data written in place, grown from the headroom and shrunk, an empty account assigned away, and one grown with zeros and then assigned. Twelve are refused: ExternalAccountLamportSpend, ReadonlyLamportChange, ExecutableLamportChange, UnbalancedInstruction, InvalidRealloc, AccountDataSizeChanged, ReadonlyDataModified, ExecutableDataModified, and ModifiedProgramId four ways. On all 19 the class leaves every account's lamports, owner and data exactly as the runtime does, or refuses with the same error. Each scenario names the outcome it is there for and the runtime must reach it, and an accepted exit must show its writes, so a store that landed somewhere else cannot pass as agreement. ExternalAccountDataModified is the one refusal the runtime cannot return here: another program's account whose length moved is refused as AccountDataSizeChanged first, and a store into its data never gets past the memory map.
What it does not do yet. The fold runs where the whole machine is held, on the node. A verifier holds one page and cannot run it: showing that a claimed post-state is the fold of a halted machine needs a witness of its own — each account's header words and any grown bytes, against the image — and a game that can descend from an L2 step into VM steps at all. Nor are the accounts it returns a state root: their data roots, and the node's own commit rule (every account inserted, zero-lamport accounts removed), belong to whoever folds them into one. The witness and the game are unchanged, so nothing was rebuilt.
Measured, 2026-08-27: it costs 95 CU per interpreted instruction
The sentence above originally ended at "large piece of work", which is an estimate, so it was measured instead (programs/solieum-sbf-spike, harness scenario U). A minimal interpreter runs the same bytecode at two instruction counts and the difference cancels fixed overhead:
| Interpreted instructions | Compute units |
| 1,002 | 96,588 |
| 10,002 | 953,388 |
Marginal cost: 95.2 CU per interpreted SBF instruction ⇒ ~14,700 instructions per 1,400,000 CU transaction.
This is a floor. The spike interprets a subset with no memory, no bounds checking and no bytecode verification; every one of those omissions makes a real implementation cost more.
What the number decides. Solana meters SBF execution at roughly one CU per instruction, so a program instruction that natively costs ~N CU needs ~N interpreted instructions to re-execute. Therefore:
- A small instruction — a transfer, a simple state update, a few thousand CU natively — fits inside one transaction with margin. The interpreter path is viable for that band.
- Anything doing substantial work (an AMM swap, a large CPI chain, tens of thousands of CU natively) does not fit, and no amount of optimisation closes a 10× gap.
So an interpreter alone does not deliver "dispute any transaction". Reaching arbitrary programs additionally requires splitting one step across multiple transactions — checkpointing interpreter state (registers, pc, memory commitment) into an account between transactions so a single disputed instruction can be verified over several L1 transactions. That is a second mechanism with its own state machine and griefing surface, not a detail of the first.
Recording this now because it is the kind of finding that quietly invalidates a roadmap: the honest sequence is classes → interpreter for small instructions → multi-transaction stepping, and each stage should be justified by observed dispute traffic rather than built speculatively.
Until it is built, "Solieum can trustlessly dispute any transaction" is false, and no Solieum material may say it. What is true is stated per class, with its measured cost.
Reversal trigger
If the class-by-class path covers observed dispute traffic well enough that an interpreter never pays for itself, that is a legitimate end state — but it must be stated as such publicly (a rollup whose fraud proofs cover a subset of instructions has a subset security argument), not presented as generality. Revisit when either the second condition holds or a class outside in-place mutation becomes necessary.