Skip to content

feat(bridge): EIP-0045 4-child migration + settlement host — verifyStark TRUE on-chain - #1

Draft
arkadianet wants to merge 27 commits into
mainfrom
feat/eip0045-4child
Draft

feat(bridge): EIP-0045 4-child migration + settlement host — verifyStark TRUE on-chain#1
arkadianet wants to merge 27 commits into
mainfrom
feat/eip0045-4child

Conversation

@arkadianet

@arkadianet arkadianet commented Jul 26, 2026

Copy link
Copy Markdown
Owner

EIP-0045 verifyStark was redesigned (5-child → 4-child, stock RISC0 profile, ErgoStatementV1
framing, raw-seal transport). This branch migrates Aegis onto it, then takes it the rest of the
way: a bridge withdrawal confirmed on-chain, and the change that makes a live withdrawal
settleable at all.

Landed: verifyStark TRUE on-chain

Release tx 386ebf54cf3fec9ff0f0a5f73a014e00f92fbaf9bb624a975ab30d025556df94, mined at devnet
height 797: the vault box spent through 0xB9, 150,000,000 USE released to a real P2PK, and a
successor vault carrying exactly the R4/R5/R6/R7 the guest committed. GPU prove 894.8 s at PO2 20
(450 segments), raw seal exactly 222,668 B.

Run log: ~/apps/aegis-eip45/RUN-LOG-stage1-settlement.md. Analysis and plan:
dev-docs/sidechain/settlement-host-stage1.md.

Why it needed more than plumbing

The migration was already proven at the crypto level, but no release could be assembled, and the
reasons were not oversights:

  • The node's spend proofs could not be settled at all. SpendCircuit emitted
    Proof<HidingEngineConfig> (uni-stark) while the settlement recursion tree verifies
    BatchProof<RecursionHidingConfig>. Different proof systems; neither the preprocessed
    commitment nor the witness needed to re-prove is recoverable from a submitted Tx. A recorded
    withdrawal was unsettleable by construction.
  • The deployed vault's R7 was all-zero, but F1's seam requires a real header preimage whose
    Poseidon2 id equals it. No settlement could ever start from it.
  • A genesis-rooted suffix was ~189 G cycles at ~24 M/block against a proven 442 M, so the vault
    has to anchor mid-chain.

What's here

settlement/epoch-host — the production artifact builder, replacing the dump_epoch
measurement fixture on the release path (its journal described a chain that does not exist: a
fabricated ergo_ref, a 33-byte blob where a recipient ErgoTree belongs). Split plan/emit
because the vault deploy happens between them: the settlement shape is note-determined, so the
deploy registers derive in milliseconds without proving. Every stage fails closed before the
expensive one — native verify_epoch, shape reproducibility, the live vault's registers, the
guest's own verify_suffix_shares and verify_anchor_linkage_min_depth, and the aggregation
root's surfaced digest.

Client spend proofs migrated to batch-stark (chain id v5 → v6). The load-bearing discovery is
that the verifier needs nothing from the prover: ProverData::from_instances forwards to
from_airs_and_degrees, consuming only the trace degree, so CommonData is a pure function of
(config, AIR, degree). Pinned under the same fixed public preprocessed salt the uni-stark path used
for its vk, it becomes a constant every party derives independently — so Tx keeps its shape,
with no common on the wire
, and a verifier never accepts a preprocessed-schedule commitment the
prover chose. The RNG split is preserved and re-asserted: fixed preprocessed salt, fresh OS masks,
two proofs of one spend still differ.

aegis-hn-types — the hn wire types (HnBlock and friends) plus to_engine_block, split out
so a settlement host can decode a block without linking the node (tokio, arkworks, the vendored
Curve-Trees stack). to_engine_block is the node↔guest parity point, so it now has one definition
rather than a copy that could drift. The postcard encoding is unchanged, established two ways: a
regression test round-trips a real /hn/v1/blocks slice served before the move, and the
refactored binary replayed the live chain's existing 15,504-block log with the pot chain continuous.

Two release-path defects, one root cause. CONTEXT.headers is TIP-first — [0] is the parent
of the block under validation — so the vault's anchor-index doc was inverted, and epoch-release's
preflight compared ergo_ref against the oldest header while the contract splices the newest.
That preflight would have passed a release the node rejects.

Verified live

On a v6 chain (fresh data dir, v5 left intact), both proved with the new circuit and verified by the
node at admission before being mined: a plain shielded tx and a real 100,000,000 USE peg-out to a
real P2PK.

Not done

The settled suffix is host-originated, not node-sourced — the chain source over live blocks is
scoped in settlement-host-stage1.md §5 (S2.3/S2.4). Everything the on-chain verification observes
is real; what is not yet real is the provenance of the suffix.

Depends on

The E4 anchor needs a genuine merge-mined Ergo block carrying the hn tip id in its extension. See
the ergo-side discussion — main already ships operator-configurable custom extension fields
(#199); what merge-mining additionally needs is a value that changes per block.

Gate

cargo fmt --all --check, clippy --workspace --all-targets --all-features -D warnings, and
cargo test green across the root workspace (529), engine, engine/recursion, settlement and
bridge-tools (35).

Byte-exact shared contract for the 5->4-child verifyStark migration:
- raw-seal extraction recipe (SuccinctReceipt.seal Vec<u32> -> 222,668 B LE,
  confirmed against RawSealV1Decoder + the independent real-seal KAT)
- ErgoStatementV1 KAT vectors reproduced byte-for-byte from ErgoStarkStatementSpec
  (empty/synthetic/final-B3 + claim chain) and extended to an AEGISPV1 payload
- chainDomainId mechanics + W1 (ReductionContext does not expose the genesis id)
- pinned-constants sheet (fixed vs per-devnet vs re-pin-at-cut)
Add ergo_statement_v1() + DOMAIN/version/prefix constants: the byte-exact
159-byte-prefixed statement the guest commits and the node opcode reconstructs
(DOMAIN(26) || 0x01 || chainDomainId || profileId || programId || contractId ||
u32le(payload_len) || payload). The AEGISPV1 epoch journal is the payload.

Oracle-pinned by a KAT-parity test against test-vectors/eip0045/
ergo_statement_v1.json vector 4 (aegis-epoch-single-withdrawal): the framed
451-byte statement must equal the reference-validated bytes.
The epoch guest now reads four framing ids (chainDomainId, profileId, programId,
contractId) as its first env::read inputs, wraps its existing AEGISPV1 epoch
journal as the statement applicationPayload, and commits the WHOLE
ErgoStatementV1 (env::commit_slice). Everything the guest proves (epoch
validity, anti-fabrication, aux-PoW) is unchanged — only the journal output is
re-framed so the node opcode's SHA-256(statement) claim check binds it.

Re-pins the image id (expected at devnet relaunch).
Replace the bincode InnerReceipt proof with the raw succinct seal
(InnerReceipt::Succinct -> get_seal_bytes(), exactly 222,668 bytes / 55,667
LE-u32 words) — the transport the EIP-0045 stock profile decodes. Fail-closed
build-time asserts: seal len == 222,668, word count == 55,667, seal[32] == 18
(outer recursion po2). Write raw_seal.bin instead of receipt_inner.bin.

build_env writes the four ErgoStatementV1 framing ids first (guest read order);
the committed journal is now the full statement, so the prefix/payload asserts
check the ErgoStatementV1 framing. Drop the now-unused bincode dep.
The epoch PegVault now emits the EIP-0045 4-child verifyStark (Payload::Four):
proofChunks, applicationPayload, programId, profileId. The node host-derives
chainDomainId (its own genesis) + contractId (BLAKE2b-256(SELF.propositionBytes))
and rebuilds the 159-byte prefix, so the vault drops vmType/costParams and pins
the FIXED stock profileId as the new 4th child (added to VaultSpec). The
applicationPayload is journal_expr UNCHANGED — the R4/R6/R7 root chain survives
the re-framing byte-identical.

chunk_proof partitions the exact 222,668-byte raw seal into the canonical fixed
[65535,65535,65535,26063] Coll[Coll[Byte]] (fail-closed on any other length).

Tests: new four_child_shape module asserts the 4-child AST order + payload
reconstruction + chunk partition round-trip (runs in the default gate); the
reconstruct suite and reject-path binding tests still pass. The 3 happy-path
binding tests + the real_verify tier are marked #[ignore] pending the node's
0xB9 Payload::Four dispatch (NODE-OPCODE agent deliverable).
…contractId)

The 4-child migration changed what the guest commits (an ErgoStatementV1, not
the bare AEGISPV1 journal), so the v6 image id 67837eaa… is dead: any receipt
it produced now frames differently and verifyStark would reject it. Re-derive
and re-pin.

  programId     fb29b593…  aux-pow guest, AEGIS_EPOCH_AUXPOW=1, reproduced
                           across two independent CARGO_TARGET_DIRs
  chainDomainId d6dcc221…  relaunch devnet genesis (height-1) Header.id
  profileId     23c4a123…  unchanged (chain-independent stock B3)
  contractId    ea74c6f2…  BLAKE2b-256 of the 537-byte vault propositionBytes

exec-epoch previously wrote a labelled placeholder for chainDomainId and
contractId. A placeholder there does not fail loudly — it produces a perfectly
valid receipt that the opcode silently rejects, hours later, because the node
rebuilds the prefix from its own genesis and SELF.propositionBytes. Both are now
the live values.

Add assert_pinned_image_id() on the prove path and to `image-id`: the driver
refuses to prove unless the guest linked into it hashes to EPOCH_IMAGE_ID.hex.
The pin is the aux-pow image, so this also catches a build that forgot
AEGIS_EPOCH_AUXPOW=1 — otherwise a lean E1/E3 guest silently proves a statement
no vault will accept.

Document (build spec §6) that the cascade converges only because contractId is
an env::read INPUT, not a compiled-in guest constant — the guest ELF, hence
programId, does not depend on it, so vault → contractId → host is a single pass.
F3's PINNED_VAULT_TREE_BYTES is the opposite case and does NOT converge: it is a
compiled-in constant whose required value (the vault ergoTree) embeds the image
id of the guest that contains it. Left empty (F3 fails closed, no peg-in can
mint) with the two design exits recorded.

Vector 4 of the statement KATs keeps its placeholders: it pins the framing
algorithm, and coupling it to per-devnet values would break the engine gate on
every re-pin without binding anything the deployment does not already bind.
…w converges

PINNED_VAULT_TREE_BYTES could never be pinned. The value it had to hold — the
vault's serialized ergoTree, which F3 compares every deposit box against —
embeds the guest image id, which is the id of the guest holding the constant.
Pinning it changed the ELF, which changed the image id, which changed the tree,
which changed the required constant. It sat empty, so F3 failed closed and no
peg-in could ever mint.

Make the tree a WITNESS and bind it in-guest instead: the guest already reads
contractId = BLAKE2b-256(SELF.propositionBytes), which the verifyStark opcode
derives HOST-side from the vault box actually being spent — nothing a script can
move. DepositParams::bind_to_contract accepts vault_tree_bytes only if it hashes
to that id, and F3 recognizes deposits by the accepted bytes.

This is strictly stronger than the pin (the deposit target is bound to the
opcode's own view of the spending vault, not to a build-time constant, so a
stale pin cannot outlive a re-deployed vault) and the self-reference is gone: the
guest holds a hash it receives, never bytes it must contain. The ELF no longer
depends on the vault tree, so the cascade is a single pass —
guest -> programId -> vault tree -> contractId -> host.

Re-derived and re-pinned (build spec §6):

  programId  369c541939a4e89cc51bbb3bc74856050b167299c2c0c4cde59181f3e208a890
             (was fb29b593…; reproduced across two independent CARGO_TARGET_DIRs)
  contractId 28e4d346229b20a07f9e2e502aa2a6485e33d4d66b934f6d2ec03cb6f0b4b711
             (was ea74c6f2…; BLAKE2b-256 of the new 537-byte tree)

chainDomainId and profileId are unchanged. The 537-byte tree is pinned verbatim
in exec-epoch as VAULT_TREE_HEX and is now the single source of truth: vault_pins()
recomputes contractId from it and asserts the recorded value, so a half re-pin
fails loudly at startup instead of burning a multi-hour prove on a statement the
opcode rejects. Derivation was oracle-checked by rebuilding the PREVIOUS tree
from image id fb29b593… and confirming it hashes to the recorded ea74c6f2….

PINNED_USE_TOKEN_ID keeps its pin and is set to the real minted devnet USE id
06c6af8414993a8933ba6e815b9ecd2de15eaf14c10d66e9f1de893e276391c8. The USE token
is an EIP-4 mint that exists before the guest is built, so it carries no
self-reference — a re-mint is an ordinary one-pass re-pin.

Also wire F3's host side, which was missing: the guest read a backing witness no
host ever wrote, so exec-epoch could not run an aux-pow guest at all. exec-epoch
now writes vault_tree_bytes + the backing wire in the guest's read order, and
dump_epoch dumps an explicit (empty, peg-in-free) backing.pc; artifact sets from
before F3 fall back to the empty witness rather than refusing to run.

Verified end-to-end on a freshly dumped 11-block / 2-withdrawal epoch: the guest
executes through the new read order (442.8 M cycles, F3 phase logged) and commits
a 497-byte ErgoStatementV1; with contractId flipped one byte the guest ABORTS at
the bind with VaultBindError instead of proceeding.

Test plan
  cargo fmt --all -- --check                                (root, engine, engine/recursion,
                                                             settlement, guest-epoch) — clean
  cargo clippy --workspace --all-targets --all-features -D warnings  (engine) — clean
  cargo clippy --workspace --all-targets -D warnings        (settlement, RISC0_SKIP_BUILD=1;
                                                             --all-features needs the CUDA
                                                             container) — clean
  cargo clippy --workspace --all-targets --features aux-pow,parallel -D warnings
                                                            (engine/recursion) — clean
  cargo test --workspace --all-features                     (engine: 152+20+10+1) — pass
  cargo test --release --features aux-pow,parallel          (engine/recursion) — pass
  cargo test --workspace                                    (root: 20 binaries) — pass
  exec-epoch image-id / execute --dir <fresh dump>          — pass

A pre-existing doc-list clippy error in engine/recursion/src/lib.rs blocked -D
warnings for the crate whose dump harness this touches; rewrapped.
bridge-tools could not be built at all: its `real-verify` feature forwarded to
`ergo-sigma/stark-verify`, and the 4-child branch compiles the EIP-0045 STARK
verifier unconditionally, so that feature no longer exists. Cargo validates the
whole feature table, so naming a missing feature broke EVERY build rather than
just the real-verify tier. Drop the forward; the tier is now a plain marker.

That alone is not enough to build, because the crate path-depends on
`../../../../ergo` — the shared checkout, which is normally parked on some
unrelated branch and must not be moved out from under whoever else is using it.
Add scripts/bridge-tools-shadow.sh, which reproduces that relative walk inside a
throwaway mirror pointed at the branch's own worktree. Only bridge-tools/
Cargo.toml is copied rather than symlinked: cargo resolves dependency paths
against the directory physically holding the manifest, so the walk has to start
in the mirror.

With the crate building again, two tests in the LEGACY batch vault suite fail.
They assert a TRUE outcome under an "M1 stub verifier" premise that the branch
retired: 0xB9 now verifies for real and only dispatches the 4-child
Payload::Four, which that vault (still Payload::Five) does not emit. Mark those
two #[ignore] with the reason — matching how the epoch suite already handles the
same situation — and note on the tier that its remaining reject-path tests now
pass vacuously, so the staleness is visible instead of reading as coverage.
…isters

`deploy-vault` builds the BATCH vault and writes only R4/R5. That produces a
vault the epoch predicate can never spend: it reads vault.R6 and vault.R7 into
the journal it reconstructs, so a box missing either register fails every
release. The registers therefore have to be set AT DEPLOY — there is no later
opportunity, since spending the vault is what needs them.

Add `epoch-deploy-vault` (and `epoch-vault-address`) building the 4-child
vault_epoch tree and initialising R4 state root, R5 counter, R6 settled-burn set
root and R7 sealed tip header id.

The genesis values are not free. R7 is the sentinel BELOW genesis, so the first
epoch's suffix starts at the hn genesis block, whose prev_root is the empty
note-tree root — that is R4. R6 is the empty settled-set root because no burn
has been settled. All four default to exactly those, computed from the engine
rather than pasted as hex, so a genesis deploy is one command with nothing to
get wrong; image id and profile id default to the pins for the same reason.

The deploy prints BLAKE2b-256 of the tree it is about to deploy, using the
node's own hash — the opcode derives contractId the same way from
SELF.propositionBytes, so this is what lets the operator confirm the deployed
vault is the one the prover pins.
…ecute`

The ErgoStatementV1 check only ran in `prove`, and only checked the framing
shape — domain tag, version, payload length. It never checked the four ids the
statement actually names.

Those ids reach the guest as env::read inputs, so a stale pin in build_env
produces a perfectly well-formed statement that simply names the wrong chain,
profile, program or vault. The node opcode rebuilds the statement host-side and
compares SHA-256, so such a receipt is rejected — after a multi-hour GPU prove,
with nothing before it having objected.

Factor the check into assert_statement, compare all four ids against the pins,
and call it from `execute` as well. The whole framing is now verified on CPU,
before any GPU time is committed. `execute` also asserts the pinned image id,
which it previously skipped.
The epoch release had a tx builder (`build_release_epoch`) but no way to
call it: the only `release` subcommand still went through the single-
withdrawal batch path. Worse, that command takes every journal-bound field
(`new_root`, `counter`, `amount`, `recipient_tree`) as an operator flag, so
a typo assembles a tx whose reconstructed journal silently disagrees with
the proof it carries.

`epoch-release` instead reads the `exec-epoch prove` out-dir and derives
ALL of them from the committed `ErgoStatementV1`: successor R4/R6/R7 and
the R5 advance are the journal's `new`/`out`/`tip_new` endpoints, and the
recipient outputs are its entry list, in journal order. Nothing about the
tx is left for the operator to get wrong.

What the operator CAN still get wrong is what lives outside the proof, so
the preflight checks exactly that and fails closed: the vault's R4/R6/R7
must equal the journal's `prev` endpoints (the state chain welds there, and
a wrong input box can't be fixed downstream), and the journal's `ergo_ref`
must still occupy the `CONTEXT.headers(ANCHOR)` slot the contract splices —
an anchor that has slid out of the recent-header window forces a re-prove,
and that is far cheaper to learn before submitting than after.
…ild receipt

Three things kept this test from ever passing on a real receipt, all of them
consequences of the 4-child migration that landed after it was written.

`journal.bin` is now the WHOLE ErgoStatementV1, not the bare AEGISPV1
journal, so parsing it as a journal tripped the tag assert on the statement's
own domain tag before any verification could happen. Strip the 159-byte
prefix — and reuse `EpochJournal` rather than keeping a second copy of the
layout that can drift from it.

The other two are the same root cause: under the 4-child interface the opcode
derives `chainDomainId` and `contractId` host-side instead of reading them
from the script, and both enter the statement the guest committed. So a
receipt is only verifiable against the chain and the vault tree it was proved
for, and the fixture's arbitrary NFT/USE ids (wrong tree -> wrong contractId)
plus `minimal()`'s zeroed chain domain guaranteed a false. Pin both to the
relaunch deployment.

Because those pins are now load-bearing, the test asserts the receipt's own
committed ids agree with them first, so a future re-pin fails as "wrong
deployment" instead of looking like the verifier rejected a sound proof.
R4/R6/R7 were checked but R5 was not, and it is bound the same way: the
journal's `counter_next` was proved as `vault.R5 + n`, so a vault at any
other count fails both the structural advance and the byte-exact journal
match. Leaving it unchecked meant the one endpoint that is a number rather
than a digest could still surprise the operator at submit time.
…in settlement host

Captures the full state at the point the migration was validated end-to-end at the
crypto level (real GPU proof -> raw seal -> ported Rust verifier TRUE -> full vault
predicate TRUE in the real interpreter) and the one thing that blocks an on-chain
release: no EpochWitness/AnchorWitness builder exists for the LIVE chain, only the
synthetic dump_epoch fixture. Records the converged pins, the live devnet/Aegis
coordinates, the off-limits systems, the path-bound image-id trap and the other
time-sinks, and the document map.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a7b98319-4b58-4550-bcd4-5f359e171f1e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/eip0045-4child

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ath defects

The handoff assumed a live-chain settlement host was missing plumbing. It is
not. Records the evidence for each blocker and splits the work into a stage
that lands an on-chain release now and a stage that makes the suffix
node-sourced.

B1 the node's spend proofs are uni-stark `Proof<HidingEngineConfig>`, while the
   recursion tree consumes `BatchProof<RecursionHidingConfig>` + the prover's
   salted `SpendCommonData` — not re-derivable, and the witness to re-prove is
   not in a submitted `Tx`. No host-side fix; a live withdrawal cannot settle
   until the node's proof system moves, which is chain-id-breaking.
B2 the deployed vault's R7 is all-zero, and F1's seam demands a real header
   PREIMAGE whose Poseidon2 id equals it. Unsettleable; the box is abandoned.
B3 the live chain is at height 7,859 and a suffix block costs ~24 M cycles, so
   a genesis-rooted settlement is ~189 G cycles against a proven 442 M.

Not blockers: E2 shares (`verify_share` never requires the share's Ergo header
to be on the devnet, so the host can mine them) and the guest's pinned USE
token id (read only by F3, which a peg-in-free epoch never reaches — the proven
image id is reused as-is).

Also records two defects with one root cause: `CONTEXT.headers` is TIP-first,
so the vault's anchor-index doc is inverted and `epoch-release`'s preflight
compares `ergo_ref` against the oldest header while the contract splices the
newest.
`Devnet::last_headers` walked `first..=tip` and documented the result as
"OLDEST FIRST — the same ordering the vault's ANCHOR_HEADER_INDEX indexes
into". It is not. The evaluator presents `CONTEXT.headers` tip-first, `[0]`
being the parent of the block under validation (`ergo-validation`'s
`CandidateValidationContext::last_headers`; `ergo-node`'s mempool-admission
`build_tip_context` builds the same window from `block_context_headers()[0]`,
so admission and inclusion agree).

So `epoch-release`'s preflight compared the journal's `ergo_ref` against the
OLDEST header in the window while the contract splices the NEWEST. That is the
worst shape a preflight can have: it passes a release the node then rejects,
and blocks a correct one. Reverse the window and print it with slot indices,
since the whole check is only as good as the ordering.

`ANCHOR_HEADER_INDEX` stays 0, but for the real reason rather than the stated
one: the contract reads a single slot, so `ergo_ref` is an exact header at any
index, and binding the parent is the slot a driven miner can hit
deterministically — freeze at tip T, prove against id(T), submit, mine one.
The only artifact set that existed came from `engine/recursion/tests/dump_epoch.rs`,
a measurement fixture whose journal describes a chain that does not exist: a
fabricated `ergo_ref`, a 33-byte blob where a recipient ErgoTree belongs, and
endpoints unrelated to any deployed vault. `epoch-release` fails its preflight
on it and `--force` dies at tx assembly, which is why the proven GPU receipt
could never become a release.

`epoch-host` emits the same five artifacts for a settlement that can: a real
recipient `ErgoTree`, a real amount, real `prove_spend_batch` proofs through the
real recursion aggregation, E2 shares mined over a live devnet header, and an E4
anchor walked over real devnet blocks.

Split into `plan` and `emit`, because the deploy has to happen BETWEEN them: the
settlement shape is note-determined, not proof-determined, so every register a
vault must be deployed with is derivable in milliseconds without proving. `plan`
prints them, the operator deploys, `emit` re-derives the identical shape,
verifies it against the LIVE vault box, and only then spends minutes proving.
Getting that backwards costs a whole prove.

Each stage fails closed before the expensive one: the suffix must satisfy
`verify_epoch` natively; the shape must be reproducible from its inputs; the
vault's R4/R5/R6/R7 must equal the settlement's starting endpoints; the mined
shares must pass the guest's own `verify_suffix_shares` (F6b included); the
anchor must pass `verify_anchor_linkage_min_depth`; and the aggregation root's
surfaced digest must equal `epoch_spend_root` over the suffix.

Served devnet extensions are re-hashed against each header's PoW-committed
`extension_root` before any inclusion proof is built over them — the REST server
is not trusted to decide which leaf the guest will check.

The suffix is host-originated, not read from the hn node, and the module says so
at the top: the node's spend proofs are uni-stark and cannot enter the recursion
tree at all (settlement-host-stage1.md §1 B1). Stage 2 replaces `local_chain`
behind the same seam once the node's proof system moves.
…er the deploy

Two failures found by driving a real settlement end to end.

The aggregation tree is 2-to-1 and needs at least one node, so a single spend
has nothing to fold into a root — `aggregate_settlement_sha` panics. Reaching
that panic in `emit` is far too late: by then a vault has been deployed against
registers no receipt can ever be proved for, and since the vault holds the NFT
and the whole USE supply behind `verifyStark`, it is stuck for good. Enforce
`MIN_WITHDRAWALS` at shape-build time, which is where `plan` runs.

The anchor walk took the FIRST committing block found walking back from
`ergo_ref`, i.e. the newest — but the commitment is sourced from a file the
miner re-reads per candidate, so a run of consecutive blocks carries the same
id, and the newest gives depth 0 and fails F5 for no reason. Take the deepest of
the run instead: every one of them is a valid `H_anchor` (the guest checks
linkage and `depth >= A_MIN`, nothing more), and the deepest is the strongest
burial claim available.

Withdrawals are plural throughout as a result: `--amount` and `--recipient-tree`
repeat, with a single tree reusable for every amount.
…deploy

The old vault (`16b2772b…`) is unsettleable: its R7 is all-zero, and F1's seam
needs a real header preimage whose Poseidon2 id equals it. It holds the NFT and
the whole USE supply behind `verifyStark`, so the tokens are abandoned and the
redeploy mints fresh ones — NFT `15e61fd3…`, USE `10237e1c…` — with R4/R7 taken
from the settlement the host actually builds.

Only the tree and its `contractId` move. The guest image id does not: the vault
tree embeds the image id, but `contractId` reaches the guest as an `env::read`
input (the opcode derives it host-side from `SELF.propositionBytes`), which is
exactly the property that keeps this cascade one-pass. `exec-epoch image-id`
confirms built == pinned == `369c5419…` after the change.

The guest's `PINNED_USE_TOKEN_ID` is deliberately left stale. It is read only by
F3's `DepositParams`, and a peg-in-free epoch never reaches the token check — so
re-pinning it would change the image id, and with it the vault tree, for nothing.
It must be re-pinned before the first peg-in-carrying settlement.
Both were only findable by running a real settlement, and both are now refused
at `plan` time rather than after money has been spent: a one-withdrawal epoch
cannot be aggregated (the tree is 2-to-1 and needs a node), and the anchor walk
has to take the deepest committing block, since the commitment file is re-read
per candidate and a whole run of blocks carries the same id.

The first is the expensive one — hitting it in `emit` leaves a vault deployed
against registers no receipt can ever chain to, with the NFT and the entire USE
supply locked behind `verifyStark`.
Release tx 386ebf54… mined at devnet height 797: the vault box spent through
`verifyStark`, 150,000,000 USE released to a real P2PK, and the successor vault
carrying exactly the R4/R5/R6/R7 the guest committed. GPU prove 894.8 s at
PO2 20, 450 segments, raw seal 222,668 B.

The first prove attempt died at segment verification ~13 minutes in. Recorded in
the run log as prover-side rather than input-side, because `execute` had already
run the guest to completion on CPU — so every segment IS a valid execution trace
and a failing segment PROOF cannot be the artifact's fault. No overclock, no
Xid, well inside VRAM; ~449 segments verified and one did not, and the identical
rerun succeeded. The host-driver/container CUDA skew (595.71.05/13.2 vs 12.6) is
noted as the first thing to check if it recurs.

What this does NOT establish is written down next to what it does: the settled
suffix is host-originated, not node-sourced, because the node's spend proofs
cannot enter the recursion tree at all (§1 B1). That is Stage 2.
…, chain v6)

Stage-1 established `verifyStark` on-chain but could not settle a LIVE
withdrawal, and the reason was not plumbing: `SpendCircuit` emitted
`Proof<HidingEngineConfig>` (uni-stark) while the settlement recursion tree
verifies `BatchProof<RecursionHidingConfig>`. Different proof systems. Neither
the preprocessed commitment nor the witness needed to re-prove is recoverable
from a submitted `Tx`, so no host could bridge it — a recorded withdrawal was
unsettleable by construction.

Moves the wallet to `prove_spend_batch`. The spend AIR is untouched; only the
wrapper differs.

The load-bearing discovery is that the verifier needs nothing from the prover.
`ProverData::from_instances` forwards to `from_airs_and_degrees(config, airs,
log_ext_degrees)` — it consumes the trace's DEGREE and nothing else — so
`CommonData` is a pure function of (config, AIR, degree). Committing it under
the same fixed public preprocessed salt the uni-stark path used for its `vk`
makes it a constant every party derives independently, and `spend_common()` is
now that constant, memoized.

That matters for soundness, not just ergonomics. `common` carries the
preprocessed-schedule commitment; a verifier that accepts a prover-supplied one
accepts proofs against a schedule the prover chose. The node derives it instead,
so `Tx` keeps its exact shape — no `common` on the wire at all. Asserted
directly: a `CommonData` built from scratch in a separate config verifies a
proof it had no part in producing.

The RNG split the uni-stark path documented is preserved and re-asserted: the
preprocessed salt is fixed (public schedule, leaks nothing), the per-proof masks
are fresh OS entropy, and two proofs of one spend still differ.

Wire proof bytes change, so the chain id breaks: v5 -> v6, `0x484E_0006`.
`epoch-host` tracks it, which retires the stage-1 vault — a v5 settlement can
never be replayed under v6, by design.
Records why the batch-stark migration dissolved B1 rather than working around
it, and what the node-backed chain source still has to reconstruct: the
historical frontier at T_prev (replay the per-block leaf order and assert the
root against the block's committed state_root, so a wrong order fails loudly),
the header-only seam, and the settled-set paths.

Also calls out that S2.2 is a deployment decision, not just a step: the chain-id
break ends the running v5 chain's continuity and retires the stage-1 vault.
…ision

The v6 chain runs in a fresh data dir on a separate port, so the chain-id break
cost nothing — the v5 chain is still running and intact.

Both spends on it were proved with the new batch-stark circuit and verified by
the node at admission before being mined: a plain shielded tx and a real
100,000,000 USE peg-out to a real P2PK. That is the migration working end to end
on a live chain, not just in tests.

Records why it is one peg-out plus one plain tx rather than two peg-outs: the
circuit is 2-in/2-out and mints no dummies, so a wallet's note count falls by one
per spend and the 2 genesis faucet notes buy exactly one peg-out. The
aggregation's constraint is >= 2 SPENDS, not >= 2 withdrawals — which makes
`epoch-host`'s MIN_WITHDRAWALS the special case that happened to hold when every
spend was a withdrawal, and something `NodeChain` must restate over spends.

S2.3 is left at a decision rather than guessed: the REST route already serves
complete blocks, but decoding them needs `HnBlock`, and pulling that from
`aegis-node` drags the vendored Curve-Trees stack, tokio and arkworks into the
settlement workspace for one wire struct. Lifting the block types into a light
shared crate is the alternative.
`HnBlock` and friends are a node↔consumer CONTRACT — the bytes in the block log
and on `/hn/v1/blocks` — but they lived in `aegis-node`, so decoding a block
meant linking the whole node: tokio, arkworks, and the vendored
Curve-Trees/bulletproofs stack via `aegis-crypto`, for one struct. The
settlement host has to decode blocks to build an epoch witness (S2.3), so this
was in the way.

NOT `aegis-types`, despite the name being the obvious home: that crate depends
on `aegis-crypto` and therefore drags in exactly the stack this split exists to
keep out of a settler's graph. The new crate's only dependencies are serde,
`aegis-hn-wallet` (which owns `Tx`) and the engine's digest width — so a
consumer that already speaks the hash-native engine adds nothing new.

Consensus logic (validate/apply/rollback/fork choice) stays in `aegis-node`, and
every existing `hn::state::…` path still resolves via a re-export, so no caller
changed.

The postcard encoding is load-bearing: a live chain replays its log on every
restart, so a reordered field would silently reinterpret history. Two things
establish it is unchanged rather than assumed. The regression test decodes a real
`/hn/v1/blocks` slice served by the node BEFORE the move and asserts it
re-encodes byte-identically — the oracle is the chain, not this code, which a
hand-written expected hex would not have been. And the refactored binary was
restarted on the LIVE v6 chain's existing 15,504-block log: it replayed, kept the
pot chain continuous, resumed producing, and still reports the recorded
withdrawal `nf0 = c39ab686…ff21` unchanged.
…-types`

`to_engine_block` is documented as the node↔guest parity point — the single
mapping from a stored block to the view the epoch-validity guest re-derives a
header id from — but it was `pub(crate)` in `aegis-node`, so a settlement host
had no way to reach it and would have had to keep a second copy. Two copies of a
parity point is exactly the drift that makes honest blocks fail the guest's
header-chain check.

It moves beside the wire types it converts, where both sides use one definition:
the node's `hn_header_id` now delegates to it, and the standing
`node_matches_engine_header_id` test still guards node-vs-engine agreement (it
passes unchanged).

Its dependencies are the same ones `aegis-hn-types` already had, so the crate
stays light.
Records that the split did NOT go into `aegis-types` as the decision assumed:
that crate depends on `aegis-crypto`, so it drags in the exact Curve-Trees stack
the split exists to keep out of a settler's graph. `aegis-hn-types` is genuinely
light, and also carries `to_engine_block` so the node↔guest parity point has one
definition rather than a settler-side copy that could drift.

Scopes the four remaining pieces of the chain source, including the one design
change it forces: a spend slot that can be either a trace to prove or a proof
already on-chain, which the migration makes possible because `SpendProofInput`
needs no witness.
@arkadianet arkadianet changed the title feat(bridge): EIP-0045 verifyStark 4-child migration — ErgoStatementV1 journal, raw-seal transport, 4-child PegVault feat(bridge): EIP-0045 4-child migration + settlement host — verifyStark TRUE on-chain Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant