ADR-0023: The withdrawal leaf names its portal

The L2 puts one leaf in the state tree at key id, and the portal recomputes its value from the claim it is given: chain.json now records the portal a data…

Status: accepted (ACCEPTED AS THE DESIGN, SEQUENCED BEHIND ADR-0021). Dated 2026-09-19.

2026-09-19. Status: ACCEPTED AS THE DESIGN, SEQUENCED BEHIND ADR-0021. Nothing here is built, and it should not be built first — see "Sequencing" below. The owner delegated the decision; this records the call and why. It is a STATE_COMMITMENT_VERSION bump and therefore a new-chain-only migration. It answers item 2 of docs/notes/proposal-anchoring-and-bridging-2026-09-18.md §10a and unblocks Phase 1 (the token bridge) of the same note, which are the same bump and should not be two migrations.

Context

What a withdrawal leaf commits today

The L2 puts one leaf in the state tree at key id, and the portal recomputes its value from the claim it is given:

Two facts about that pair:

  • Neither names the chain or the portal. The value is a function of the claim alone.
  • Only the first exists on the L2. state.rs knows WD_DOMAIN and nothing else, DepositRecord carries no mint, and there is no withdraw --mint. The portal's entire token half — deposit_token, prove_token_withdrawal, finalize_token_withdrawal, per-mint D5 limits — is unreachable from the L2 that is supposed to feed it.

Three things that follow, and the third is the sharp one

  1. An unbacked leaf becomes payable when a portal appears. A chain that names no portal still commits leaves and still debits balances. Give that chain a portal and an SCC later and its whole history of leaves is provable, from a vault that never received them. try_withdraw skips the D2 solvency check entirely when there is no portal (main.rs:2321), so nothing judged them on the way in either.
  2. withdrawn_total carries those leaves into the guard (main.rs:2344, outside the has_portal branch), so naming a portal afterwards refuses honest new exits until the vault covers the junk. Silent adoption breaks the chain for its users as well as risking the vault.
  3. A second portal DEPLOYMENT can pay the same withdrawal twice. paid lives on [b"wd", id], a PDA derived on the portal's own program id (ProveWithdrawal, lib.rs:1344), while the root it proves against is the SCC's [b"root", n] with seeds::program = solieum_scc::ID, fixed at compile time. Two portal deployments compiled against one SCC therefore read the same roots and keep separate paid flags. An in-place upgrade is safe — same program id, same PDAs — but a redeploy at a new address re-opens every withdrawal the old portal ever paid. Nothing in the leaf, the proof or the program notices.

Why 1dff018 is not enough

chain.json now records the portal a data directory runs with and refuses a change without --adopt-portal. That closes the operator mistake, and it is worth having. It cannot close the property, for a reason that is central to Solieum's own recovery story: a node rebuilt from published bytes has no chain.json. Rebuilding from DA alone is the thing this project tells users a replacement sequencer can do; such a node stamps whatever it is started with and compares against nothing. Local state cannot carry a fact that has to travel with the chain. Only the commitment can.

The version number is not a free choice

STATE_COMMITMENT_VERSION is 10, MIN_SUPPORTED is 2, 5 and 6 are retired, and TX_BINDING_VERSION is 11 — a constant ADR-0021 compares with genesis.commitment_version >= TX_BINDING_VERSION (main.rs:892). So the next cuttable version is 11, and any new version switches on ADR-0021's node-side binding as a side effect. This ADR cannot pick a number that avoids that, and should not pretend to.

Decision

D1. New leaf preimages, binding the chain and the portal

chain is the genesis hash; portal is the portal program id the chain names. New domain tags, not new fields under the old tags, so a v1 leaf can never be read as v2 or the reverse — the same reason WD_TOKEN_DOMAIN differs from WD_DOMAIN today.

Binding both follows the precedent already set twice: c6c99d1 made a receipt name its chain, 65a5b09 bound each bond to one chain. The portal is what closes consequence 3; the chain is what stops one SCC's roots meaning anything on a portal deployed for a different chain. Cost: two more 32-byte inputs to one hash, no growth in the tree, no new account.

A chain that names no portal cannot form a v2 leaf at all — there is no portal id to put in it. That is the point: under v2, try_withdraw on a portal-less chain has nothing to commit, so the unbacked leaf stops being a policy question and becomes unrepresentable.

D2. The L2 grows the token half

WD_TOKEN_DOMAIN leaves, a deposit record that carries a mint, L2 crediting for token deposits, and withdraw --mint. Only the leaf is in the commitment; the rest is plumbing that can land after the bump.

D3. One version, 11, carrying all of it

Cut new chains at commitment 11: ADR-0021's node-side binding (already built and inert), the v2 lamport leaf, the v2 token leaf. Existing chains stay where they are — gamma at 2, and every local chain at 2.

ADR-0021's missing on-chain half is program code, not a commitment, so it can arrive later as a program upgrade without a second bump. That is why one version can carry everything without waiting for it — and why cutting at 11 does not mean the forged-step gap is closed. It is not, and a chain at 11 still must not carry value until it is.

D4. The portal learns which chain and which version it serves

The portal today knows its SCC at compile time and nothing else. It gains a ChainBinding PDA recording { chain genesis hash, leaf version }, written once at initialization:

  • a separate PDA, not new fields on Portal, for the reason Guard is separate (lib.rs:976): Portal is already initialized on devnet and resizing a live account is a migration nobody needs;
  • prove_withdrawal and prove_token_withdrawal recompute v1 or v2 from it, and a portal bound to v2 refuses v1 outright, which is what stops an old leaf being replayed into a new portal.

A portal with no ChainBinding behaves exactly as today. That is what keeps gamma working while it stays un-upgraded.

D5. Migration order, and what does not move

  1. Land D1 and D4 behind commitment 11; cut a new devnet chain at 11 and prove the round trip on it, as the 2026-09-18 run did on 9333.
  2. Deploy a portal with a ChainBinding for that chain only.
  3. D2's plumbing, then the token round trip under D5 caps (24 h window, per mint 1,000 in / 500 out, 100 large tier — §7.3 of the anchoring note).
  4. Gamma's portal stays where the owner left it. No live chain migrates: a commitment version is not upgradable in place, and re-cutting gamma is a separate decision with its own cost.

Sequencing: why this is not the next thing built

ADR-0021's status says its remaining work includes "every class reworked onto the bound witness". The withdrawal step is one of those classes, and D1 changes exactly what its witness rebuilds (trace.rs:3246). Building D1 first therefore means reworking the withdrawal class twice — once onto the v2 leaf, then again onto the bound witness — and the second rework would land on code written against an interface ADR-0021 is about to replace. The forged-step finding also PAUSED new Path A class work for this reason.

So the order is: ADR-0021's on-chain half, then this. That ordering costs nothing, because a chain cannot carry value until ADR-0021 is finished anyway, and this ADR's whole purpose is to make value safe to carry. Shipping D1 into a world where a proposer can still win a dispute with a forged step would be buying a better lock for a door that is off its hinges.

What may be built before ADR-0021 without waste:

  • D4's ChainBinding PDA, which touches the portal program only and no class at all.
  • The differential test for D1's preimages — the node and the portal each hashing the same tuple and asserting equality — which is test code that survives whatever the class rework does around it.
  • D2's plumbing that is not the leaf: a deposit record carrying a mint, L2 token crediting, withdraw --mint. None of it is in the commitment.

Consequences

  • New chains only. Nothing existing gains the property. Anyone who wants it on gamma is asking for a new gamma.
  • The dispute class changes with the leaf. trace.rs rebuilds the withdrawal leaf in its witness (trace.rs:3246), so the one-step class for a withdrawal step moves with D1 — that is the real implementation cost, not the hash itself. state.rs, svm-spike and the node's own proof tests move with it.
  • A portal-less chain can no longer initiate a withdrawal. For 8899, 9001, 9002, 9232 and 9233 that is a fix, not a loss: today they commit leaves nothing can pay (54e3982 made the CLI say so out loud).
  • Two live implementations of one value, briefly. The node and the portal both compute the preimage. Per the project's own rule — one value, one implementation, or a differential test pinning agreement — D1 ships with a test that hashes the same tuple through both and asserts equality.
  • What this does not fix: the forged-step gap (ADR-0021's on-chain half), and the SULI chain's bridge, which is attested rather than proven and waits on validators.

Alternatives considered

  • Leave it at 1dff018 (policy only). Rejected: a node rebuilt from published bytes has no chain.json, which is exactly the recovery path Solieum advertises.
  • Put the portal in the leaf KEY instead of the value. The key is the id and the tree is keyed by it; moving the binding into the key changes every lookup and buys nothing the value does not.
  • Rely on the PDA being per-portal. That is the defect, not the defence: per-portal paid flags are precisely what lets a second deployment pay again.
  • Two bumps, one for the portal binding and one for tokens. Rejected: both change the leaf, and a second migration costs another chain re-cut for no property the first did not already have to establish.

Implementation status

Nothing built. ⚠ Neither this nor ADR-0021's on-chain half can be verified on the current machine: cargo-build-sbf is absent and Docker — which backpackapp/build:v0.30.1 needs and which is how deploys build — is installed but not running. The portal .so in target/deploy is from 2026-09-12. Program work that cannot be compiled cannot be claimed, so the toolchain comes before either. 1dff018 (datadir binding), 867dd9f (a coin chain refuses a withdrawal) and 54e3982 (the CLI stops promising a vault that does not exist) are the parts that did not need a bump and are live on every chain in the fleet as of 2026-09-19.