ADR-0014: Execution happens at admission, not at block seal

A Solana transaction can end three ways: it lands and succeeds, it lands and fails, or it never lands. The middle case is priced in by nobody. On the sample this project took itself — 39 finalized mainnet…

Status: accepted (accepted, implemented in the node). Dated 2026-09-04.

2026-09-04. Status: accepted, implemented in the node. Prompt: §6 C2, C12, C14; App. E2. Companion: docs/notes/zero-failed-transactions.md for the measurements this is argued from, and the node README's "Admission" section for what the code does.

Context

A Solana transaction can end three ways: it lands and succeeds, it lands and fails, or it never lands. The middle case is priced in by nobody. On the sample this project took itself — 39 finalized mainnet blocks across an hour on 2026-09-04, 22,204 non-vote transactions — 7,393 failed and every one paid its fee, 33.3%, costing 0.1207 SOL in an hour to buy nothing. The Dune dashboard the site cross-checks against puts the same figure at 25.7% over its history. Almost all of it is one class: the program ran and said no, because many parties sent transactions only one of which could succeed.

That is not a defect in Solana. It is the necessary consequence of an open fee market: you build a transaction against the state you can see, an unknown leader executes it hundreds of milliseconds later against the state that exists then, after an unknown set of other transactions in an order nobody committed to in advance. simulateTransaction there is advisory by construction, and the gap between the simulation and the execution is where the failures live.

Solieum had inherited the same shape without inheriting the reason for it. Node::submit_wire checked signatures, the blockhash, duplicates and whether the fee payer could cover the flat fee, then ordered the transaction under a signed receipt and queued it. produce_block executed it two seconds later. apply_tx charged the fee and then took the rollback snapshot, so a failing instruction reverted the state and kept the fee — with a comment saying exactly that. Correct for a chain that cannot know the outcome in advance. Wrong for this one.

Three properties of the code, none of them added for this ADR, mean this chain can know:

  1. One executor. The process that answers sendTransaction is the process that runs produce_block. Nothing is handed to an unknown party.
  2. The position is fixed before execution. admit takes a signed ordering receipt for a dense, irrevocable position (Appendix E2, solieum-sequencer) and then pushes onto pending. A transaction's predecessors are known when it arrives and cannot change.
  3. Nothing is ever inserted in front. produce_block drains the forced-inclusion inbox and appends; every forced path (admit_forced, admit_deposit, admit_forced_noop) ends in the same admit, which is a Vec::push. Forced entries execute after everything already queued.

So the state a submitted transaction will execute against is fully determined at submission. Running it then is not a prediction.

Decision

A transaction is executed when it is submitted, against the exact position it will occupy. One that would fail is refused there.

  • The node keeps an OpenBlock (node/src/admission.rs): the projected state of the block being filled. Every submission executes against it.
  • A success advances the projection, the transaction is admitted, and it will succeed at seal. A failure returns the program's own reason and logs over JSON-RPC and does nothing else: no fee, no position, no receipt, no published bytes, no drop record.
  • The real execution still happens in produce_block, from the real store, in the same order, at the same timestamp. Replay, the trace recorder that feeds the dispute game (ADR-0011), the explorer index and the state root keep exactly the code path they had. The gate is a refusal in front of them, not a rewrite of them.
  • The block's timestamp is stamped once, when the block opens, and produce_block uses that value instead of reading the clock again. This is load-bearing, not tidiness: apply_tx hands now to the runtime as Clock.unix_timestamp, and "now" at submission is not "now" at seal, so a program branching on a deadline could otherwise pass the gate and fail the block — the one way the two runs could disagree. With the timestamp fixed, they see identical inputs, and the determinism charter (§7.3: no wall-clock reads, no floats, no map-iteration-order dependence in consensus code) covers the rest.
  • A gated transaction that fails at seal is a determinism defect, not a dropped transaction, and produce_block says so in those words. The two runs are the same pure function of the same inputs; disagreement means the charter is being violated somewhere in that path.
  • Forced-inclusion entries are never gated. An entry that is junk, unsigned or unpayable is still consumed and still marked included, because the derivation rule faults a sequencer that leaves an overdue entry behind — if a failing payload could block the watermark, anyone could wedge the chain for the price of one L1 fee. Forced inclusion promises a slot, not a successful transaction (ADR-0009), and that promise outranks the statistic. Deposit credits refused by the ADR-0013 D2 supply check are the other exception, and they refuse in the safe direction.
  • simulateTransaction is added, running the same execution and committing nothing. It carries an exact: true field, because on this chain the answer is not advisory.
  • A v0 (versioned) transaction is refused by name, saying it is v0 and that this chain takes legacy messages. It used to fail as cannot decode transaction: bincode: …, which is accurate and useless; most wallets and every aggregator route build v0 by default, so this is the refusal a real user is most likely to meet.
  • --no-admission-check restores the old behaviour. A gate that wrongly refuses is worse than no gate, and an operator needs a way back that does not require a new binary.

Consequences

  • The on-chain failure rate is zero by construction, forced entries excepted. That is a stronger and more checkable claim than "low failure rate", and it is the one the site may make — in the exact form below.
  • What may be claimed: a transaction that would fail never reaches a block, never takes a fee, and is answered at submission with the program's own error and logs. What may never be implied: that a trade always works. A slippage limit still refuses, a race still has one winner, a program that says no still says no. What changed is that being told no costs nothing and takes milliseconds instead of a block.
  • This is an execution property, not a finality one, and the two must never be published together. The gate moves the settlement clock by nothing. The ladder is unchanged: the sequencer's signed receipt is a soft confirmation in milliseconds and is revocable; the block's root reaches Solana under bond within seconds; the root is final only once it survives the challenge window — 48 hours since ADR-0016, seven days on chains created before it — and that is the moment a withdrawal against it can complete. "Every transaction in a block succeeded" is a claim about what executed at soft confirmation. Heard as "instantly final" it would be exactly the conflation the site's metrics table exists to prevent, and it would be a far worse dishonesty than the failure rate this ADR removes.
  • The published payload shrinks to successful transactions, so verifiers re-derive less and the revert step class stops appearing in real blocks — one fewer class for the dispute game to defend.
  • Every admitted transaction executes twice, and each gate run clones the projected store. That is real CPU on the RPC path, and it moves the cost of spam from the sender's fee to the operator's core. The fee-payer balance check still runs first as a cheap filter. A per-payer admission rate limit and an explicit per-transaction compute ceiling are the next two guards and are not built; until they are, a public endpoint wants a rate limit in front of it.
  • The fee stops arriving from failures, and it never covered costs anyway. A refused transaction pays nothing, so whatever a failing transaction used to contribute to the operator is gone. That matters less than it sounds, because the flat 5,000-lamport fee is far below what a block costs to settle: rent on the block's root and batch records dominates, and break-even is 820 transactions per block under the rent schedule devnet actually charges, or 901 under Rent::default(). Both are measured and pinned by a test in the fee-economics work (node/src/fees.rs); the gap between them is a correction to a figure the node had been publishing about itself. The point here is only that "the fee covers the cost" is untrue today, that this change does not make it truer, and that neither the site nor any deck may say otherwise until the fee model in §6 blocker 3 of the readiness note exists.
  • The drop machinery stays. It is still reached by forced entries, by --no-admission-check, and by replay, and it is still covered by drops_are_reported_with_reason_and_fee_and_survive_restart — that test now runs with the gate off on purpose.
  • warpClock does not affect a block that is already open, since the timestamp is stamped at open. It takes effect from the next block. This is embedded-L1 test tooling only.
  • Roots and replay are unaffected: the log carries the block's timestamp and replay executes from it, exactly as before.

Alternatives rejected

  • Refuse at admission using a cheap static check (balance, account existence) instead of executing. It would catch the small classes and miss the 94.6% that is "the program ran and said no", which is precisely the class that needs a real execution to see.
  • Move execution to admission and make produce_block a pure seal. Fewer cycles, and it was the first design. Rejected because replay, tracing and the explorer index all execute at seal, so the change would have forked those paths in production versus replay — the exact split the node's "durability by replay" property exists to avoid. Executing twice buys a single code path plus a free determinism check.
  • Charge for a refusal to deter spam. It would make the refusal an outcome again, which is the thing being removed. Rate limiting is the right instrument and it belongs in front of the node.

Reversal trigger

If admission-time execution becomes a denial-of-service surface that rate limiting cannot hold — measured, not assumed — the gate becomes opt-in and the fee returns as the spam price. If a future sequencer set makes the executor and the admitter different processes (§9's rotating set, where a transaction may be handed to the next sequencer), property 1 in the Context above stops holding, and the gate must be re-derived for hand-off or disabled across one.

Open, and deliberately not in this ADR

  • Durable nonces, which would remove blockhash expiry as a refusal class entirely, including for hardware-wallet and offline-signing flows. The window here is 150 blocks and blocks are produced only when there is work, so an idle chain never expires one and a busy chain gives about five minutes against Solana's one — this is the least urgent of the gaps.
  • A native address-lookup-table program, so v0 transactions resolve and are executed rather than named and refused.
  • A sponsored-fee path, so a new account with no SOL is not refused before it can do anything.
  • Wiring solieum-clob to the sequencer, which is what turns the racing class from a refusal into an order state: a limit order that does not fill is resting, not failed. That is where the 94.6% ultimately belongs, and it is a larger piece of work than this ADR.