feat: fork-based ASM upgradeability - #183
Draft
prajwolrg wants to merge 22 commits into
Draft
Conversation
|
Commit: 81c7854
|
prajwolrg
force-pushed
the
worktree-upgradability
branch
from
July 6, 2026 16:27
9a619fd to
835242e
Compare
14 tasks
prajwolrg
force-pushed
the
worktree-upgradability
branch
from
July 7, 2026 03:10
b2c9de8 to
d3c72ec
Compare
Collaborator
Author
prajwolrg
force-pushed
the
upgradability-params-spec
branch
2 times, most recently
from
July 7, 2026 06:20
06d3317 to
d2d1a3f
Compare
prajwolrg
force-pushed
the
worktree-upgradability
branch
from
July 7, 2026 06:40
d3c72ec to
dbe2246
Compare
14 tasks
prajwolrg
force-pushed
the
upgradability-params-spec
branch
3 times, most recently
from
July 8, 2026 06:31
f39db77 to
3b5a0f0
Compare
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
force-pushed
the
upgradability-params-spec
branch
from
July 8, 2026 10:14
3b5a0f0 to
32bd1d3
Compare
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.
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
force-pushed
the
worktree-upgradability
branch
from
July 8, 2026 17:07
dbe2246 to
c67a9de
Compare
prajwolrg
changed the base branch from
upgradability-params-spec
to
upgradability-fork-activation
July 8, 2026 17:07
This was referenced Jul 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
StfParamsper entry for native) instead of exactly one. Each block's step proof is produced by the host whose predicate equals the parentMohoState.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'snext_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 toguest-asmexcept 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.list/put/prune --after) so discovered activations can be inspected and rolled back alongside the other stores.Type of Change
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), thenbackend/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_entriesbecome lists). Worker, params, and action layouts were already changed by #187/#188.Testing: prover host selection is unit-tested;
fn_asm_fork_upgrade_testruns the full choreography through the real runner under both backends likefn_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'sFORK_NEVERis2^63-1because the value rides through the prover's TOML config and TOML integers are i64.Checklist
Related Issues
Stacked on #188 (which is stacked on #187).