Skip to content

feat: fork-based ASM upgradeability - #183

Draft
prajwolrg wants to merge 22 commits into
mainfrom
worktree-upgradability
Draft

feat: fork-based ASM upgradeability#183
prajwolrg wants to merge 22 commits into
mainfrom
worktree-upgradability

Conversation

@prajwolrg

@prajwolrg prajwolrg commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Description

The proving-side completion of fork-based ASM upgradeability. #187 landed the fork schedule/params groundwork and #188 the worker-side machinery (the unstake gate, the fork id on the admin action, activation discovery and persistence). This PR makes the prover survive the upgrade those PRs enact, and proves the full choreography end to end:

  • Prover artifact switching: backends hold a set of ASM artifacts (ELF paths for sp1; signing key + baked StfParams per entry for native) instead of exactly one. Each block's step proof is produced by the host whose predicate equals the parent MohoState.next_predicate — the same value the recursion demands — including the one-line fix without which any VK upgrade would stall the recursive chain (the moho input's step predicate was fixed config instead of the parent state's next_predicate). A predicate with no matching host fails loudly instead of stalling silently. Entry 0 is the genesis-time artifact; its predicate seeds the genesis Moho state.
  • guest-asm-pre-unstake: a test-only guest identical to guest-asm except its baked schedule never activates anything. Upgrade tests use its VK as the genesis ASM predicate and upgrade to the production guest, exercising the fork boundary without maintaining an actual historical ELF.
  • bin/asm-txgen: test-support CLI for multisig-signed VK updates (carrying the fork id), musig2-signed unstakes, and key/predicate derivation, so no crypto is duplicated in python. Also the seed of an ops tool for crafting production VK-update transactions.
  • fn_asm_fork_upgrade_test: drives the complete upgrade through the real runner — pre-fork proofs under the old artifact, a genuine unstake ignored, VK upgrade enactment, proofs continuing past the boundary (only possible if the prover switched artifacts and the recursion accepted the predicate handover), and the same unstake then removing the operator.
  • dbtool: exposes the fork-activation store (list / put / prune --after) so discovered activations can be inspected and rolled back alongside the other stores.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature/Enhancement (non-breaking change which adds functionality or enhances an existing one)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Refactor
  • New or updated tests
  • Dependency update
  • Security fix

Notes to Reviewers

Suggested entry points: crates/extensions/prover/worker/src/input.rs::build_moho_runtime_input (the step-predicate fix the recursion depends on), then backend/mod.rs (host selection by predicate), then the functional test for the end-to-end shape.

Breaking surface: the prover backend config (asm_elf_paths / asm_entries become lists). Worker, params, and action layouts were already changed by #187/#188.

Testing: prover host selection is unit-tested; fn_asm_fork_upgrade_test runs the full choreography through the real runner under both backends like fn_asm_proof_test — the sp1 variant stays a manual/local run: ASM_PROVER_BACKEND=sp1 SP1_PROOF_STRATEGY="" NETWORK_PRIVATE_KEY="" ./run_test.sh fn_asm_fork_upgrade_test. The dbtool surface is covered by the functional dbtool suite. Two quirks worth knowing: test keys must be even-parity (the params key types reject odd-parity keys and BIP-137 recovery must reproduce the configured key), and python's FORK_NEVER is 2^63-1 because the value rides through the prover's TOML config and TOML integers are i64.

Checklist

  • I have performed a self-review of my code.
  • I have commented my code where necessary.
  • I have updated the documentation if needed.
  • My changes do not introduce new warnings.
  • I have added tests that prove my changes are effective or that my feature works.
  • New and existing tests pass with my changes.

Related Issues

Stacked on #188 (which is stacked on #187).

@codecov

codecov Bot commented Jul 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.47080% with 38 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
crates/extensions/prover/worker/src/backend/sp1.rs 0.00% 11 Missing ⚠️
crates/storage/src/sled/fork_activation.rs 88.76% 10 Missing ⚠️
crates/extensions/prover/worker/src/backend/mod.rs 68.75% 5 Missing ⚠️
crates/extensions/prover/worker/src/service.rs 57.14% 3 Missing ⚠️
crates/worker/src/test_utils.rs 86.95% 3 Missing ⚠️
...tes/extensions/prover/worker/src/backend/native.rs 85.71% 2 Missing ⚠️
crates/extensions/prover/worker/src/config.rs 80.00% 2 Missing ⚠️
.../subprotocols/bridge-v1/subprotocol/src/handler.rs 98.14% 2 Missing ⚠️
Files with missing lines Coverage Δ
bin/asm-runner/src/bootstrap.rs 99.15% <100.00%> (+0.02%) ⬆️
bin/asm-runner/src/rpc_server.rs 97.77% <100.00%> (+0.01%) ⬆️
bin/asm-runner/src/storage.rs 100.00% <100.00%> (ø)
bin/asm-runner/src/worker_context.rs 83.33% <100.00%> (+11.14%) ⬆️
bin/asm-txgen/src/main.rs 100.00% <100.00%> (ø)
bin/dbtool/src/cmd/fork_activation.rs 100.00% <100.00%> (ø)
bin/dbtool/src/cmd/mod.rs 100.00% <100.00%> (ø)
bin/dbtool/src/main.rs 100.00% <ø> (ø)
bin/dbtool/src/utils.rs 100.00% <100.00%> (ø)
crates/common/src/fork.rs 98.61% <100.00%> (+11.72%) ⬆️
... and 20 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown

Commit: 81c7854
SP1 Execution Results

program cycles gas
asm-stf 136,419,144 134,136,555
moho 5,223,535 5,525,318

@prajwolrg
prajwolrg force-pushed the worktree-upgradability branch from 9a619fd to 835242e Compare July 6, 2026 16:27
@prajwolrg
prajwolrg force-pushed the worktree-upgradability branch from b2c9de8 to d3c72ec Compare July 7, 2026 03:10
@prajwolrg
prajwolrg changed the base branch from main to upgradability-params-spec July 7, 2026 03:10
@prajwolrg

Copy link
Copy Markdown
Collaborator Author

Restructured as a stack: the params/spec/fork-type groundwork moved to #187 and this branch was rebased on top of it. The final tree is byte-identical to the previous head (b2c9de8) — only the commit boundaries changed.

@prajwolrg
prajwolrg force-pushed the upgradability-params-spec branch 2 times, most recently from 06d3317 to d2d1a3f Compare July 7, 2026 06:20
@prajwolrg
prajwolrg force-pushed the worktree-upgradability branch from d3c72ec to dbe2246 Compare July 7, 2026 06:40
@prajwolrg
prajwolrg force-pushed the upgradability-params-spec branch 3 times, most recently from f39db77 to 3b5a0f0 Compare July 8, 2026 06:31
prajwolrg added 12 commits July 8, 2026 15:44
EVM-style named forks with L1 activation heights. The schedule is not
committed state: proving artifacts bake 0/MAX extremes (each only ever
executes one side of an upgrade boundary) while the worker tracks the
real activation height, so every executor agrees on the gate outcome at
every height.

Upgrade actions will carry forks as raw u16 ids, because the artifact
that enacts a fork's activation predates the fork and cannot know it;
ForkId maps the ids a binary knows and leaves the rest to be skipped by
the consumer.
AsmParams was genesis-only, leaving no home for configuration of the
per-block state transition. Split it by consumer: GenesisParams is
consumed once to build the genesis anchor state, StfConfig (the base
fork schedule) configures the state transition function for every
block.

Both sections are serde-flattened, so the params file stays a single
flat object: the split is a property of the Rust types, not something
operators need to spell out.
The trait declared the pipeline as an instance method and built genesis
through &self, so every executor had to thread a spec value and the
invocation order was only a doc-comment invariant ("MUST NOT change
behavior per stage").

The pipeline is now a type-level subprotocol list, making invocation
order a compile-time constant of the spec type: it cannot vary per
stage, per execution, or with runtime configuration. The spec also owns
its Params type and derives everything configuration-dependent from it
(genesis state, base STF params) through pure functions, so the worker
takes the single params value at its boundary and constructs genesis
behind the spec — a genesis built by a different spec can no longer be
adopted silently.

The handle exposes the genesis block so downstream services (the Moho
worker, the prover input builder) read the chain's genesis point from
the worker rather than re-deriving it from params.
Genesis hand-rolled the same three subprotocols the spec already declares,
calling their state constructors directly (leaving Subprotocol::init dead
framework surface) and hand-ordering the sections to match the ascending-ID
layout the STF's section export asserts — an invariant nothing checked at
genesis. Drive it through the same Stage traversal as every execution stage,
locating each config in the params list by its InitConfig type (hence the
new Any bound), so the pipeline and the genesis layout cannot drift apart.
…structs

process_txs and process_msgs took loose ambient args (header
verification state, verified aux data, L1 block ref). Bundle the
read-only inputs of each phase into a method-aligned context struct
(ProcessTxsCtx / ProcessMsgsCtx) passed as the final parameter: each
field's purpose gets a documented home, and future context can grow
without breaking every implementor's signature again. Capabilities
(collector, relayer) stay as plain args; only read-only inputs live in
ctx.
The spec type is stateless, so params reach the STF entry points as
explicit arguments; the StrataAsmSpec struct carries them only across
interfaces that thread a single spec value (the Moho runtime). Guest
programs hardcode their params, making the verifying key commit to
them; the native prover host bakes the same schedule its guest
counterpart would; the worker passes the base params the spec derives
from its params file.

Each hook ctx gains the params, and pre-processing gets its own ctx
carrying the target block height, not otherwise derivable in that
phase: aux-data requests must be gateable on exactly the fork
conditions that gate the processing which consumes them, and message
handling in lockstep with the tx processing that produced the messages.
Nothing consumes the params yet; the first fork gate will.
The bridge gates unstake txs on ForkId::Fork1 in BOTH phases: pre-fork
the aux request is never made and the handler rejects before touching
aux data, keeping aux request/consumption in lockstep.

Behavior is unchanged for now — every executor still runs with the fork
active since genesis (StfParams::all_forks_enabled, replacing the
all-disabled Default at executor call sites).
Persistence for the fork activations the worker will discover from ASM
VK upgrade logs; the worker-side consumer lands in a follow-up commit.

Each record carries the predicate the upgrade enacted: enactment only
surfaces the new VK in the emitted log, so these records are the
worker's durable copy of the key each boundary switched to.
ForkActivationStore joins WorkerContext as its fifth concern, backed by
the sled store in the runner and an in-memory impl in tests. Plumbing
only; the discovery logic that populates it lands next.
Name the fork a VK upgrade activates in the signed action itself: the
chain is the single authority on what an upgrade activates, and the
fork id renders into the signing message so the multisig authorizes it
alongside the predicate hash. The emitted AsmStfUpdate log forwards it
to observers.

The admin does not validate the id: each update is expected to name
the fork it newly activates, and upholding that is on the multisig
when authoring the action. The action carries a raw u16 rather than
the ForkId enum because the upgrade that activates a fork is enacted
by the artifact that predates it, so the wire format cannot require
knowing the fork — and the id's representation may still change.
When a processed block's manifest carries an AsmStfUpdate, the worker
activates the fork the log names from the next block on and persists
the activation — with the predicate the upgrade enacted — keyed by
(enacting height, fork).

Ordering carries the guarantees: the record is written before the
enacting block's anchor commit so a committed anchor can never lack its
activation (crash-replay rewrites the same record), and every sync
rebase prunes activations above the base before re-processing so a
reorged-out enactment cannot leak into the new branch — its blocks
re-discover any activation that survives.

An upgrade naming an already-active fork is an operational flaw on the
authoring side; it is skipped loudly and leaves the schedule untouched
so it cannot retro-raise an activation height. One naming a fork id
unknown to this binary is skipped too, since a binary that cannot apply
the fork's rules cannot meaningfully activate it either.
Unit level: the bridge gate rejects valid unstakes pre-fork (without
touching aux data — the lockstep property that keeps the aux panic
unreachable), flips exactly at the activation height, and the worker's
discovery activates the fork an upgrade names (recording the enacted
VK alongside it), skips upgrades naming an already-active fork (an
authoring flaw that must not retro-raise the activation), skips
unknown fork ids, resumes persisted activations on restart, and rolls
back on rebase.

Integration level: the full choreography against regtest — genuine
musig2-signed unstake ignored pre-fork, admin VK upgrade enactment
activates the fork at H+1, unstake then removes the operator; plus the
reorg path where abandoning the submission block rolls the activation
back until the re-mined update re-enacts on the new branch. The harness
gains a fork schedule knob, a genuine-unstake builder (real N/N key,
full validation passes — only the fork gate decides), and a reorg
helper.
@prajwolrg
prajwolrg force-pushed the upgradability-params-spec branch from 3b5a0f0 to 32bd1d3 Compare July 8, 2026 10:14
prajwolrg added 3 commits July 8, 2026 16:50
Scanning a block's logs for enacted upgrades and persisting the
resulting activations are different concerns. Keep discovery a pure
read that returns the activations to enact, and move the write side
(activation record + in-memory schedule update) behind
apply_fork_activations, separating validation from persistence.
Warn-and-skip left the situation ambiguous: an enacted update naming an
already-active fork is a flawed upgrade with no safe reading — applying
it would retro-raise the activation height, ignoring it silently
diverges from the predicate the chain enacted. Refuse to commit the
enacting block instead, via a dedicated RedundantForkActivation error.

The check also catches two updates naming one fork within a single
block, which the schedule alone cannot see: the first takes effect at
H+1, above the enacting height H, and checking at H+1 instead would
false-fire on crash-replay.

Drop discovery-local error logs along the way: both variants carry the
full context in their Display, and the sync shutdown handler already
logs every fatal sync error.
prajwolrg added 7 commits July 8, 2026 22:31
The prover held exactly one ASM host and passed a fixed asm_predicate as
the recursion's step predicate, so the first ASM VK upgrade would fail
verify_and_chain's Merkle check and stall the proof chain.

Backends now take a list of ASM artifacts (ELF paths for sp1; signing
key + baked StfParams per entry for native, mirroring how each guest
hardcodes its own schedule). Each block's step proof is produced by the
host whose predicate equals the parent MohoState's next_predicate — the
same value the recursion demands — and the moho input's step predicate
comes from that parent state rather than config. A predicate with no
matching host fails loudly instead of stalling silently.

Entry 0 is the genesis-time artifact; its predicate seeds the genesis
Moho state.
Identical to guest-asm except the baked schedule never activates the
unstake fork. Upgrade tests seed the genesis ASM predicate with its VK
and upgrade to asm.elf's, exercising the fork boundary without
maintaining a historical ELF. The production guest bakes
unstake-active-since-genesis, matching current behavior.
The functional-test suite is Python but multisig-signed admin actions
and musig2-signed unstakes can only be built with this workspace's Rust
crates. asm-txgen bridges the gap: python shells out to it against the
test bitcoind (derive-predicate / submit-vk-update / submit-unstake) and
mines the result itself. Also the seed of an ops tool for crafting real
VK-update transactions.
New fork_upgrade env starts the chain under pre-fork rules: base schedule
never activates fork1, the genesis ASM predicate is the pre-fork
artifact's, and the prover holds both artifacts (two schnorr keys with
per-entry schedules in native mode; asm-pre-unstake.elf + asm.elf under
sp1). The VK-update action itself names the fork it activates, so no
worker-side trigger config is involved.

The test submits a genuine unstake (ignored), the admin VK upgrade
(enacted after a short confirmation depth), then asserts proofs continue
past the boundary — only possible if the prover switched artifacts and
the recursion accepted the predicate handover — and that the same
unstake now removes the operator. Runs under both backends via
ASM_PROVER_BACKEND, like fn_asm_proof_test.

A new getOperators RPC exposes the current multisig so python can assert
the operator set without SSZ decoding. python drives tx crafting through
asm-txgen; test keys are chosen even-parity because the params key types
reject odd-parity keys and BIP-137 recovery must reproduce the
configured key. Two operators so an unstake never empties the multisig
(a fatal bridge invariant).
The fork-upgrade functional test spawns asm-txgen, but the CI job only
built strata-asm-runner and dbtool into the PATH'd target dir, so the
test died with FileNotFoundError. run_test.sh already builds it locally.
ty infers the merged kwargs dict as a union of its value types and
rejects splatting it into build_asm_params. Widen to dict[str, Any],
matching what _asm_params_overrides actually returns.
Resolves the TODO in cli.rs: fork activations discovered by the worker
could be neither inspected nor rolled back alongside the other stores.

put derives the activation height (always enacting + 1, the only shape
the worker writes) instead of accepting one, and prune is --after-only
since activations are only removed when rolling back past their
enacting height, never from the front.
@prajwolrg
prajwolrg force-pushed the worktree-upgradability branch from dbe2246 to c67a9de Compare July 8, 2026 17:07
@prajwolrg
prajwolrg changed the base branch from upgradability-params-spec to upgradability-fork-activation July 8, 2026 17:07
@prajwolrg
prajwolrg changed the base branch from upgradability-fork-activation to main July 10, 2026 04:23
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