From fc2f60fabda1fe219fcefd7e01f2ec426cc718fe Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Tue, 7 Jul 2026 13:17:10 +0545 Subject: [PATCH 1/9] feat(bridge): gate unstake txs on the first fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- crates/common/src/fork.rs | 15 ++++ .../prover/worker/src/backend/native.rs | 2 +- crates/proof/statements/src/program.rs | 3 +- .../bridge-v1/subprotocol/src/errors.rs | 5 ++ .../bridge-v1/subprotocol/src/handler.rs | 85 ++++++++++++++++--- .../bridge-v1/subprotocol/src/subprotocol.rs | 20 ++++- guest-builder/sp1/guest-asm/src/main.rs | 3 +- tests/asm/admin_to_stf.rs | 4 +- 8 files changed, 119 insertions(+), 18 deletions(-) diff --git a/crates/common/src/fork.rs b/crates/common/src/fork.rs index 0f4e26b7..575f065e 100644 --- a/crates/common/src/fork.rs +++ b/crates/common/src/fork.rs @@ -79,6 +79,11 @@ impl ForkSchedule { Self { fork1: u64::MAX } } + /// Schedule with every fork active since genesis (activation at `0`). + pub const fn all_enabled() -> Self { + Self { fork1: 0 } + } + /// Returns the activation height of `fork`. pub fn activation_height(&self, fork: ForkId) -> u64 { match fork { @@ -120,6 +125,16 @@ pub struct StfParams { pub forks: ForkSchedule, } +impl StfParams { + /// Params with every fork active since genesis, matching current mainline + /// behavior. + pub const fn all_forks_enabled() -> Self { + Self { + forks: ForkSchedule::all_enabled(), + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/extensions/prover/worker/src/backend/native.rs b/crates/extensions/prover/worker/src/backend/native.rs index fe28171e..fd3d5329 100644 --- a/crates/extensions/prover/worker/src/backend/native.rs +++ b/crates/extensions/prover/worker/src/backend/native.rs @@ -44,7 +44,7 @@ pub(super) async fn build_native_hosts( use zkaleido_native_adapter::NativeHost; // Matches the schedule baked into the production ASM guest. - let stf_params = StfParams::default(); + let stf_params = StfParams::all_forks_enabled(); Ok(( NativeHost::new(asm_signing_key.clone(), move |env| { process_asm_stf(env, stf_params.clone()) diff --git a/crates/proof/statements/src/program.rs b/crates/proof/statements/src/program.rs index 1381921d..42a02df1 100644 --- a/crates/proof/statements/src/program.rs +++ b/crates/proof/statements/src/program.rs @@ -100,7 +100,8 @@ mod tests { fn test_stf() { let runtime_input = create_runtime_input(); - let output = AsmStfProofProgram::execute(&runtime_input, StfParams::default()).unwrap(); + let output = + AsmStfProofProgram::execute(&runtime_input, StfParams::all_forks_enabled()).unwrap(); dbg!(output); } } diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/errors.rs b/crates/subprotocols/bridge-v1/subprotocol/src/errors.rs index 1da60b65..0c2656a0 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/errors.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/errors.rs @@ -19,6 +19,11 @@ pub enum BridgeSubprotocolError { #[error("failed to validate unstake tx: {0}")] UnstakeTxValidation(#[from] UnstakeValidationError), + + /// The unstake fork has not activated yet, so unstake transactions are not + /// a recognized transaction type and are skipped. + #[error("unstake txs are not supported before the unstake fork (height {height})")] + UnstakeForkInactive { height: u64 }, } /// Errors that can occur when validating deposit transactions at the subprotocol level. diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs b/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs index 25018062..56d4c5c0 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs @@ -1,6 +1,6 @@ use strata_asm_common::{ - AsmLogEntry, AuxRequestCollector, MsgRelayer, VerifiedAuxData, - logging::{error, info}, + AsmLogEntry, AuxRequestCollector, ForkId, MsgRelayer, StfParams, VerifiedAuxData, + logging::{error, info, warn}, }; use strata_asm_logs::{DepositLog, NewExportEntry}; use strata_asm_proto_bridge_v1_txs::{ @@ -41,6 +41,8 @@ pub(crate) fn handle_parsed_tx( parsed_tx: ParsedTx, verified_aux_data: &VerifiedAuxData, relayer: &mut impl MsgRelayer, + stf_params: &StfParams, + height: u64, ) -> Result<(), BridgeSubprotocolError> { match parsed_tx { ParsedTx::Deposit(info) => { @@ -123,6 +125,13 @@ pub(crate) fn handle_parsed_tx( Ok(()) } ParsedTx::Unstake(info) => { + // Gate BEFORE touching aux data: pre-processing skips the aux + // request under the same condition, so reaching for it here would + // panic on the (deliberately) missing entry. + if !stf_params.forks.is_active(ForkId::Fork1, height) { + return Err(BridgeSubprotocolError::UnstakeForkInactive { height }); + } + let outpoint = info.stake_inpoint().outpoint(); let stake_connector_txout = verified_aux_data .get_bitcoin_txout(outpoint) @@ -148,6 +157,8 @@ pub(crate) fn preprocess_parsed_tx( parsed_tx: ParsedTx, _state: &BridgeV1State, collector: &mut AuxRequestCollector, + stf_params: &StfParams, + target_height: u64, ) { match parsed_tx { ParsedTx::Deposit(info) => { @@ -163,6 +174,17 @@ pub(crate) fn preprocess_parsed_tx( collector.request_bitcoin_tx(info.stake_inpoint().0.txid); } ParsedTx::Unstake(info) => { + // Gate on the same fork condition as `handle_parsed_tx`: pre-fork, + // unstake txs are skipped there without touching aux data, so + // requesting it here would fetch data nothing consumes. + if !stf_params.forks.is_active(ForkId::Fork1, target_height) { + warn!( + target_height, + "Skipping unstake tx aux request; unstake fork not active" + ); + return; + } + // Request the Bitcoin transaction spent by the stake connector input. The handler // compares its `scriptPubKey` against the canonical stake-connector commitment // reconstructed from the witness @@ -173,6 +195,7 @@ pub(crate) fn preprocess_parsed_tx( #[cfg(test)] mod tests { + use strata_asm_common::StfParams; use strata_asm_proto_bridge_v1_txs::{ deposit_request::DrtHeaderAux, parser::ParsedTx, @@ -212,8 +235,15 @@ mod tests { // 4. Handle the transaction let mut relayer = MockMsgRelayer; - handle_parsed_tx(&mut state, parsed_tx, &verified_aux_data, &mut relayer) - .expect("handling valid deposit tx should succeed"); + handle_parsed_tx( + &mut state, + parsed_tx, + &verified_aux_data, + &mut relayer, + &StfParams::all_forks_enabled(), + 0, + ) + .expect("handling valid deposit tx should succeed"); // 5. Should add a new entry in the deposits table assert!( @@ -253,8 +283,15 @@ mod tests { // 3. Handle the transaction let mut relayer = MockMsgRelayer; - handle_parsed_tx(&mut state, parsed_tx, &aux, &mut relayer) - .expect("handling deposit tx should success"); + handle_parsed_tx( + &mut state, + parsed_tx, + &aux, + &mut relayer, + &StfParams::all_forks_enabled(), + 0, + ) + .expect("handling deposit tx should success"); assert!( state @@ -280,7 +317,14 @@ mod tests { // 5. Handle the transaction let parsed_tx = ParsedTx::Slash(info); let mut relayer = MockMsgRelayer; - let result = handle_parsed_tx(&mut state, parsed_tx, &aux, &mut relayer); + let result = handle_parsed_tx( + &mut state, + parsed_tx, + &aux, + &mut relayer, + &StfParams::all_forks_enabled(), + 0, + ); assert!(result.is_ok(), "Handle parsed tx should succeed"); @@ -305,7 +349,14 @@ mod tests { // Handle the transaction let parsed_tx = ParsedTx::Unstake(info); let mut relayer = MockMsgRelayer; - let result = handle_parsed_tx(&mut state, parsed_tx, &aux, &mut relayer); + let result = handle_parsed_tx( + &mut state, + parsed_tx, + &aux, + &mut relayer, + &StfParams::all_forks_enabled(), + 0, + ); assert!(result.is_ok(), "Handle parsed tx should succeed"); @@ -332,7 +383,14 @@ mod tests { let empty_aux = create_verified_aux_data(vec![]); let parsed_tx = ParsedTx::Deposit(info); let mut relayer = MockMsgRelayer; - let _ = handle_parsed_tx(&mut state, parsed_tx, &empty_aux, &mut relayer); + let _ = handle_parsed_tx( + &mut state, + parsed_tx, + &empty_aux, + &mut relayer, + &StfParams::all_forks_enabled(), + 0, + ); } #[test] @@ -346,6 +404,13 @@ mod tests { let empty_aux = create_verified_aux_data(vec![]); let parsed_tx = ParsedTx::Slash(info); let mut relayer = MockMsgRelayer; - let _ = handle_parsed_tx(&mut state, parsed_tx, &empty_aux, &mut relayer); + let _ = handle_parsed_tx( + &mut state, + parsed_tx, + &empty_aux, + &mut relayer, + &StfParams::all_forks_enabled(), + 0, + ); } } diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs b/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs index f6bcf068..0417bd1f 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/subprotocol.rs @@ -47,7 +47,7 @@ impl Subprotocol for BridgeV1Subproto { state: &Self::State, txs: &[TxInputRef<'_>], collector: &mut AuxRequestCollector, - _ctx: &PreProcessTxsCtx<'_>, + ctx: &PreProcessTxsCtx<'_>, ) { // Pre-Process each transaction for tx in txs { @@ -56,7 +56,13 @@ impl Subprotocol for BridgeV1Subproto { // bridge subprotocol (e.g. `DepositRequest`, `Commit`) or are otherwise unparseable // are silently skipped. if let Some(parsed_tx) = parse_tx(tx) { - preprocess_parsed_tx(parsed_tx, state, collector); + preprocess_parsed_tx( + parsed_tx, + state, + collector, + ctx.stf_params, + ctx.target_height, + ); } } } @@ -91,7 +97,15 @@ impl Subprotocol for BridgeV1Subproto { let Some(parsed_tx) = parse_tx(tx) else { continue; }; - match handle_parsed_tx(state, parsed_tx, ctx.verified_aux_data, relayer) { + let height = header_vs.last_verified_block.height() as u64; + match handle_parsed_tx( + state, + parsed_tx, + ctx.verified_aux_data, + relayer, + ctx.stf_params, + height, + ) { // `handle_parsed_tx` already emits a type-specific info log on success, so this // is only a coarse trace marker. `txid` is computed inside the macro, because // logging is compiled to noop in ZkVM. diff --git a/guest-builder/sp1/guest-asm/src/main.rs b/guest-builder/sp1/guest-asm/src/main.rs index b7e1fccf..1ef8e4d8 100644 --- a/guest-builder/sp1/guest-asm/src/main.rs +++ b/guest-builder/sp1/guest-asm/src/main.rs @@ -6,5 +6,6 @@ use zkaleido_sp1_guest_env::Sp1ZkVmEnv; fn main() { // Hardcoded on purpose: the verifying key must commit to the STF params. - process_asm_stf(&Sp1ZkVmEnv, StfParams::default()) + // Unstake has been supported since genesis, so its fork is active at 0. + process_asm_stf(&Sp1ZkVmEnv, StfParams::all_forks_enabled()) } diff --git a/tests/asm/admin_to_stf.rs b/tests/asm/admin_to_stf.rs index e1af915a..aadc146d 100644 --- a/tests/asm/admin_to_stf.rs +++ b/tests/asm/admin_to_stf.rs @@ -157,12 +157,12 @@ async fn test_proof_program_reflects_predicate_update() { pre_anchor_state.as_ssz_bytes(), step_input.as_ssz_bytes(), ); - let attestation = AsmStfProofProgram::execute(&runtime_input, StfParams::default()) + let attestation = AsmStfProofProgram::execute(&runtime_input, StfParams::all_forks_enabled()) .expect("AsmStfProofProgram::execute failed"); // Independently compute the expected post-state. let stf_output = compute_asm_transition::( - &StfParams::default(), + &StfParams::all_forks_enabled(), &pre_anchor_state, &activation_block, step_input.aux_data(), From dd153a4d0c1d6b247d4a12658a287222d1b2977c Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Tue, 7 Jul 2026 15:17:44 +0545 Subject: [PATCH 2/9] feat(storage): add sled-backed fork-activation store 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. --- Cargo.lock | 3 + crates/common/Cargo.toml | 1 + crates/common/src/fork.rs | 40 +++++ crates/storage/Cargo.toml | 1 + crates/storage/src/fork_activation.rs | 39 +++++ crates/storage/src/lib.rs | 8 +- crates/storage/src/sled/fork_activation.rs | 163 +++++++++++++++++++++ crates/storage/src/sled/mod.rs | 5 +- guest-builder/sp1/guest-asm/Cargo.lock | 1 + 9 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 crates/storage/src/fork_activation.rs create mode 100644 crates/storage/src/sled/fork_activation.rs diff --git a/Cargo.lock b/Cargo.lock index 3e2548c0..866919e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -797,6 +797,7 @@ dependencies = [ "strata-identifiers", "strata-merkle", "strata-merkle-node-store", + "strata-predicate", "tempfile", ] @@ -7630,6 +7631,7 @@ dependencies = [ "strata-l1-txfmt", "strata-merkle", "strata-msg-fmt", + "strata-predicate", "strata-test-utils-arb", "thiserror 2.0.18", "tracing", @@ -8416,6 +8418,7 @@ version = "0.1.0" source = "git+https://github.com/alpenlabs/strata-common?tag=v0.3.0-rc.1#861ef15a7a2e0335ca84d21d35c2326c85638904" dependencies = [ "arbitrary", + "borsh", "hex", "k256", "serde", diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 57a7c602..0dbbcaf9 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -14,6 +14,7 @@ strata-identifiers.workspace = true strata-l1-txfmt.workspace = true strata-merkle = { workspace = true, features = ["ssz"] } strata-msg-fmt.workspace = true +strata-predicate.workspace = true bitcoin.workspace = true borsh.workspace = true diff --git a/crates/common/src/fork.rs b/crates/common/src/fork.rs index 575f065e..37724a7d 100644 --- a/crates/common/src/fork.rs +++ b/crates/common/src/fork.rs @@ -8,6 +8,7 @@ //! outcome at every height it executes (see `StfParams`). use serde::{Deserialize, Serialize}; +use strata_predicate::PredicateKey; /// Identifies a named fork. /// @@ -60,6 +61,35 @@ impl TryFrom for ForkId { } } +/// A discovered fork activation. +/// +/// Records that the block at `enacting_height` enacted an ASM VK upgrade +/// which activates `fork` from the next block onward, switching the ASM STF +/// predicate to `new_predicate`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ForkActivation { + /// Height of the L1 block whose ASM VK upgrade enactment triggered the + /// activation. + pub enacting_height: u32, + + /// The activated fork. + pub fork: ForkId, + + /// The ASM STF predicate the upgrade enacted. Enactment removes the + /// update from the admin queue and only surfaces it in the emitted log, + /// so this record is the worker's durable copy of the VK the boundary + /// switched to. + pub new_predicate: PredicateKey, +} + +impl ForkActivation { + /// Height from which the fork's rules apply: the block after the + /// enacting one. + pub fn activation_height(&self) -> u64 { + self.enacting_height as u64 + 1 + } +} + /// Activation heights for every named fork. /// /// A fork is active at L1 height `h` iff `h >= activation_height`. `0` means @@ -196,4 +226,14 @@ mod tests { assert_eq!(ForkId::try_from(0u16).unwrap(), ForkId::Fork1); assert_eq!(ForkId::try_from(0xFFFFu16), Err(0xFFFF)); } + + #[test] + fn activation_is_block_after_enactment() { + let activation = ForkActivation { + enacting_height: 41, + fork: ForkId::Fork1, + new_predicate: PredicateKey::always_accept(), + }; + assert_eq!(activation.activation_height(), 42); + } } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 54aa330d..0b513cf4 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -11,6 +11,7 @@ strata-asm-common.workspace = true strata-identifiers.workspace = true strata-merkle = { workspace = true, features = ["ssz"] } strata-merkle-node-store.workspace = true +strata-predicate = { workspace = true, features = ["borsh"] } anyhow.workspace = true borsh.workspace = true diff --git a/crates/storage/src/fork_activation.rs b/crates/storage/src/fork_activation.rs new file mode 100644 index 00000000..21888f17 --- /dev/null +++ b/crates/storage/src/fork_activation.rs @@ -0,0 +1,39 @@ +//! Storage trait for discovered fork activations. +//! +//! Each record says "the block at `enacting_height` enacted an ASM VK upgrade +//! that activates `fork` at `activation_height`" (the block after the enacting +//! one), carrying the predicate the upgrade switched the ASM STF to. The +//! worker persists a record *before* committing the enacting block's +//! anchor state, so an activation can never lag a committed anchor, and prunes +//! records above the fork point when a reorg abandons the enacting block. + +use std::fmt::Debug; + +use strata_asm_common::ForkActivation; + +/// Persistence interface for fork-activation records. +/// +/// Async methods with an associated error type. +pub trait AsmForkActivationDb { + /// The error type returned by database operations. + type Error: Debug; + + /// Stores a fork activation, keyed by `(enacting_height, fork)`. + /// + /// Idempotent: replaying the enacting block rewrites the same record. + fn put( + &self, + activation: ForkActivation, + ) -> impl Future> + Send; + + /// Returns every stored activation, ascending by enacting height. + fn list(&self) -> impl Future, Self::Error>> + Send; + + /// Removes all activations whose enacting height is strictly above + /// `after_height` (which is kept). Used on reorgs to drop activations + /// enacted on the abandoned branch. + fn prune_after( + &self, + after_height: u32, + ) -> impl Future> + Send; +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 24392992..aadc8936 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -10,6 +10,8 @@ //! - [`AsmAuxDataDb`] / [`SledAsmAuxDataDb`] — auxiliary data, keyed by block commitment //! - [`AsmManifestDb`] / [`SledAsmManifestDb`] — full manifests, keyed by block commitment //! - [`AsmManifestMmrDb`] / [`SledAsmManifestMmrDb`] — manifest hash MMR, keyed by L1 height +//! - [`AsmForkActivationDb`] / [`SledForkActivationDb`] — discovered fork activations, keyed by +//! `(enacting height, fork)` //! //! The commitment-keyed stores ([`AsmStateDb`], [`AsmAuxDataDb`], //! [`AsmManifestDb`]) key each entry by its [`L1BlockCommitment`] — height plus @@ -27,13 +29,17 @@ //! [`L1BlockCommitment`]: strata_identifiers::L1BlockCommitment mod aux; +mod fork_activation; mod manifest; mod manifest_mmr; mod sled; mod state; pub use aux::AsmAuxDataDb; +pub use fork_activation::AsmForkActivationDb; pub use manifest::AsmManifestDb; pub use manifest_mmr::AsmManifestMmrDb; -pub use sled::{SledAsmAuxDataDb, SledAsmManifestDb, SledAsmManifestMmrDb, SledAsmStateDb}; +pub use sled::{ + SledAsmAuxDataDb, SledAsmManifestDb, SledAsmManifestMmrDb, SledAsmStateDb, SledForkActivationDb, +}; pub use state::AsmStateDb; diff --git a/crates/storage/src/sled/fork_activation.rs b/crates/storage/src/sled/fork_activation.rs new file mode 100644 index 00000000..92bc7ed4 --- /dev/null +++ b/crates/storage/src/sled/fork_activation.rs @@ -0,0 +1,163 @@ +//! [`AsmForkActivationDb`] implementation backed by sled. + +use anyhow::{Context, Result, anyhow}; +use strata_asm_common::{ForkActivation, ForkId}; +use strata_predicate::PredicateKey; + +use crate::fork_activation::AsmForkActivationDb; + +/// Size of an encoded activation key: 4-byte BE enacting height + 1-byte fork id. +const ENCODED_KEY_SIZE: usize = 4 + 1; + +/// Sled-backed [`AsmForkActivationDb`] keyed by `(enacting_height, fork)`, +/// with the enacted predicate as the value. +/// +/// The composite key allows several fork activations at one enacting height; +/// the big-endian height prefix keeps sled's lexicographic ordering aligned +/// with height ordering so `prune_after` can range-scan. +#[derive(Debug, Clone)] +pub struct SledForkActivationDb { + activations: sled::Tree, +} + +impl SledForkActivationDb { + /// Opens or creates the fork-activation tree in the given sled instance. + pub fn open(db: &sled::Db) -> Result { + Ok(Self { + activations: db.open_tree("asm_fork_activations")?, + }) + } + + /// Synchronous variant of [`AsmForkActivationDb::put`]. The ASM worker runs + /// on a sync thread (via `ServiceBuilder::launch_sync`), where awaiting is + /// not possible; calling this directly avoids that. + pub fn put(&self, activation: ForkActivation) -> Result<()> { + let key = encode_key(activation.enacting_height, activation.fork); + let value = borsh::to_vec(&activation.new_predicate)?; + self.activations.insert(key, value)?; + Ok(()) + } + + /// Synchronous variant of [`AsmForkActivationDb::list`]. See [`Self::put`]. + pub fn list(&self) -> Result> { + self.activations + .iter() + .map(|entry| { + let (key, value) = entry?; + decode_entry(&key, &value) + }) + .collect() + } + + /// Synchronous variant of [`AsmForkActivationDb::prune_after`]. See [`Self::put`]. + pub fn prune_after(&self, after_height: u32) -> Result<()> { + let Some(first_removed) = after_height.checked_add(1) else { + return Ok(()); + }; + let lower: &[u8] = &first_removed.to_be_bytes(); + for entry in self.activations.range(lower..) { + let (key, _) = entry?; + self.activations.remove(&key)?; + } + Ok(()) + } +} + +impl AsmForkActivationDb for SledForkActivationDb { + type Error = anyhow::Error; + + async fn put(&self, activation: ForkActivation) -> Result<()> { + self.put(activation) + } + + async fn list(&self) -> Result> { + self.list() + } + + async fn prune_after(&self, after_height: u32) -> Result<()> { + self.prune_after(after_height) + } +} + +/// Encodes an activation key as `[enacting_height_be(4)][fork_id(1)]`. +fn encode_key(enacting_height: u32, fork: ForkId) -> [u8; ENCODED_KEY_SIZE] { + let mut buf = [0u8; ENCODED_KEY_SIZE]; + buf[0..4].copy_from_slice(&enacting_height.to_be_bytes()); + buf[4] = fork.into(); + buf +} + +/// Decodes a tree entry back into a [`ForkActivation`]. +fn decode_entry(key: &[u8], value: &[u8]) -> Result { + let enacting_height = u32::from_be_bytes( + key[0..4] + .try_into() + .context("fork activation key shorter than 4 bytes")?, + ); + let fork = ForkId::try_from(key[4]) + .map_err(|id| anyhow!("unknown fork id {id} in fork activation store"))?; + let new_predicate = borsh::from_slice::(value) + .context("malformed predicate in fork activation store")?; + Ok(ForkActivation { + enacting_height, + fork, + new_predicate, + }) +} + +#[cfg(test)] +mod tests { + use strata_predicate::PredicateTypeId; + + use super::*; + use crate::sled::test_util::test_db; + + /// A per-height predicate, so roundtrip failures can't hide behind a + /// shared constant. + fn predicate(enacting_height: u32) -> PredicateKey { + PredicateKey::new(PredicateTypeId::AlwaysAccept, vec![enacting_height as u8]) + } + + fn activation(enacting_height: u32) -> ForkActivation { + ForkActivation { + enacting_height, + fork: ForkId::Fork1, + new_predicate: predicate(enacting_height), + } + } + + #[test] + fn put_list_roundtrip() { + let (db, _dir) = test_db(); + let store = SledForkActivationDb::open(&db).unwrap(); + + store.put(activation(7)).unwrap(); + store.put(activation(3)).unwrap(); + + // Ascending by enacting height regardless of insertion order. + assert_eq!(store.list().unwrap(), vec![activation(3), activation(7)]); + } + + #[test] + fn put_is_idempotent() { + let (db, _dir) = test_db(); + let store = SledForkActivationDb::open(&db).unwrap(); + + store.put(activation(5)).unwrap(); + store.put(activation(5)).unwrap(); + assert_eq!(store.list().unwrap(), vec![activation(5)]); + } + + #[test] + fn prune_after_drops_only_higher_entries() { + let (db, _dir) = test_db(); + let store = SledForkActivationDb::open(&db).unwrap(); + + store.put(activation(3)).unwrap(); + store.put(activation(5)).unwrap(); + store.put(activation(6)).unwrap(); + + store.prune_after(5).unwrap(); + assert_eq!(store.list().unwrap(), vec![activation(3), activation(5)]); + } +} diff --git a/crates/storage/src/sled/mod.rs b/crates/storage/src/sled/mod.rs index 3569c168..80c1fcd0 100644 --- a/crates/storage/src/sled/mod.rs +++ b/crates/storage/src/sled/mod.rs @@ -10,13 +10,14 @@ use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; mod aux; +mod fork_activation; mod manifest; mod manifest_mmr; mod state; pub use self::{ - aux::SledAsmAuxDataDb, manifest::SledAsmManifestDb, manifest_mmr::SledAsmManifestMmrDb, - state::SledAsmStateDb, + aux::SledAsmAuxDataDb, fork_activation::SledForkActivationDb, manifest::SledAsmManifestDb, + manifest_mmr::SledAsmManifestMmrDb, state::SledAsmStateDb, }; // ── Key encoding ────────────────────────────────────────────────────── diff --git a/guest-builder/sp1/guest-asm/Cargo.lock b/guest-builder/sp1/guest-asm/Cargo.lock index ff3f55d9..663ba087 100644 --- a/guest-builder/sp1/guest-asm/Cargo.lock +++ b/guest-builder/sp1/guest-asm/Cargo.lock @@ -1774,6 +1774,7 @@ dependencies = [ "strata-l1-txfmt", "strata-merkle", "strata-msg-fmt", + "strata-predicate", "thiserror", "tracing", "tree_hash", From 3f45ccae643693265e58f0d625ced9e347d6e8d2 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Tue, 7 Jul 2026 15:18:48 +0545 Subject: [PATCH 3/9] feat(worker): add fork-activation persistence as a context concern 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. --- bin/asm-runner/src/bootstrap.rs | 2 ++ bin/asm-runner/src/storage.rs | 6 +++- bin/asm-runner/src/worker_context.rs | 36 ++++++++++++++++++++++-- crates/worker/src/lib.rs | 5 +++- crates/worker/src/test_utils.rs | 38 ++++++++++++++++++++++++-- crates/worker/src/traits.rs | 41 +++++++++++++++++++++------- 6 files changed, 111 insertions(+), 17 deletions(-) diff --git a/bin/asm-runner/src/bootstrap.rs b/bin/asm-runner/src/bootstrap.rs index 75d486b6..2b8b262e 100644 --- a/bin/asm-runner/src/bootstrap.rs +++ b/bin/asm-runner/src/bootstrap.rs @@ -32,6 +32,7 @@ pub(crate) async fn bootstrap( aux_db, manifest_db, mmr_db, + fork_activation_db, export_entries_db, } = create_storage(&config.database)?; @@ -65,6 +66,7 @@ pub(crate) async fn bootstrap( aux_db.clone(), manifest_db.clone(), mmr_db.clone(), + fork_activation_db, ); // 5. Launch ASM worker. diff --git a/bin/asm-runner/src/storage.rs b/bin/asm-runner/src/storage.rs index f415e926..803c2bd0 100644 --- a/bin/asm-runner/src/storage.rs +++ b/bin/asm-runner/src/storage.rs @@ -3,7 +3,9 @@ use std::sync::Arc; use anyhow::Result; -use asm_storage::{SledAsmAuxDataDb, SledAsmManifestDb, SledAsmManifestMmrDb, SledAsmStateDb}; +use asm_storage::{ + SledAsmAuxDataDb, SledAsmManifestDb, SledAsmManifestMmrDb, SledAsmStateDb, SledForkActivationDb, +}; use strata_asm_moho_storage::SledExportEntriesDb; use crate::config::DatabaseConfig; @@ -14,6 +16,7 @@ pub(crate) struct Storage { pub aux_db: Arc, pub manifest_db: Arc, pub mmr_db: Arc, + pub fork_activation_db: Arc, pub export_entries_db: SledExportEntriesDb, } @@ -25,6 +28,7 @@ pub(crate) fn create_storage(config: &DatabaseConfig) -> Result { aux_db: Arc::new(SledAsmAuxDataDb::open(&db)?), manifest_db: Arc::new(SledAsmManifestDb::open(&db)?), mmr_db: Arc::new(SledAsmManifestMmrDb::open(&db)?), + fork_activation_db: Arc::new(SledForkActivationDb::open(&db)?), export_entries_db: SledExportEntriesDb::open(&db)?, }) } diff --git a/bin/asm-runner/src/worker_context.rs b/bin/asm-runner/src/worker_context.rs index 11c0a634..077981fd 100644 --- a/bin/asm-runner/src/worker_context.rs +++ b/bin/asm-runner/src/worker_context.rs @@ -6,12 +6,15 @@ use std::sync::Arc; -use asm_storage::{SledAsmAuxDataDb, SledAsmManifestDb, SledAsmManifestMmrDb, SledAsmStateDb}; +use asm_storage::{ + SledAsmAuxDataDb, SledAsmManifestDb, SledAsmManifestMmrDb, SledAsmStateDb, SledForkActivationDb, +}; use bitcoin::{Block, BlockHash, Network, block::Header}; use bitcoind_async_client::{Client, error::ClientError, traits::Reader}; -use strata_asm_common::{AnchorState, AsmManifest, AsmManifestHash, AuxData}; +use strata_asm_common::{AnchorState, AsmManifest, AsmManifestHash, AuxData, ForkActivation}; use strata_asm_worker::{ - AnchorStateStore, AuxDataStore, L1DataProvider, ManifestMmrStore, WorkerError, WorkerResult, + AnchorStateStore, AuxDataStore, ForkActivationStore, L1DataProvider, ManifestMmrStore, + WorkerError, WorkerResult, }; use strata_btc_types::{BitcoinTxid, L1BlockIdBitcoinExt, RawBitcoinTx}; use strata_identifiers::{L1BlockCommitment, L1BlockId}; @@ -36,9 +39,14 @@ pub(crate) struct AsmWorkerContext { aux_db: Arc, manifest_db: Arc, mmr_db: Arc, + fork_activation_db: Arc, } impl AsmWorkerContext { + #[expect( + clippy::too_many_arguments, + reason = "one argument per storage concern" + )] pub(crate) fn new( runtime_handle: Handle, bitcoin_client: Arc, @@ -47,6 +55,7 @@ impl AsmWorkerContext { aux_db: Arc, manifest_db: Arc, mmr_db: Arc, + fork_activation_db: Arc, ) -> Self { Self { runtime_handle, @@ -57,6 +66,7 @@ impl AsmWorkerContext { aux_db, manifest_db, mmr_db, + fork_activation_db, } } } @@ -233,6 +243,26 @@ impl ManifestMmrStore for AsmWorkerContext { } } +impl ForkActivationStore for AsmWorkerContext { + fn record_fork_activation(&self, activation: ForkActivation) -> WorkerResult<()> { + self.fork_activation_db + .put(activation) + .map_err(|_| WorkerError::DbError) + } + + fn list_fork_activations(&self) -> WorkerResult> { + self.fork_activation_db + .list() + .map_err(|_| WorkerError::DbError) + } + + fn prune_fork_activations_after(&self, after_height: u32) -> WorkerResult<()> { + self.fork_activation_db + .prune_after(after_height) + .map_err(|_| WorkerError::DbError) + } +} + impl AuxDataStore for AsmWorkerContext { fn store_aux_data(&self, blockid: &L1BlockCommitment, data: &AuxData) -> WorkerResult<()> { self.aux_db diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index f4eacf63..aeb2c729 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -26,4 +26,7 @@ pub use service::{AsmWorkerService, AsmWorkerStatus}; pub use state::AsmWorkerServiceState; pub use subscription::{Subscribers, Subscription}; pub use sync::{SyncError, SyncPlan, plan_sync}; -pub use traits::{AnchorStateStore, AuxDataStore, L1DataProvider, ManifestMmrStore, WorkerContext}; +pub use traits::{ + AnchorStateStore, AuxDataStore, ForkActivationStore, L1DataProvider, ManifestMmrStore, + WorkerContext, +}; diff --git a/crates/worker/src/test_utils.rs b/crates/worker/src/test_utils.rs index 5c74bfdf..33a79610 100644 --- a/crates/worker/src/test_utils.rs +++ b/crates/worker/src/test_utils.rs @@ -13,7 +13,7 @@ use std::{ use bitcoin::{Block, BlockHash, Network, Txid, block::Header, params::Params}; use bitcoind_async_client::{Client, traits::Reader}; -use strata_asm_common::{AnchorState, AsmManifest, AsmManifestHash}; +use strata_asm_common::{AnchorState, AsmManifest, AsmManifestHash, ForkActivation}; use strata_btc_types::{BitcoinTxid, BlockHashExt, L1BlockIdBitcoinExt, RawBitcoinTx}; use strata_btc_verification::{L1Anchor, get_relative_difficulty_adjustment_height}; use strata_identifiers::{L1BlockCommitment, L1BlockId}; @@ -22,7 +22,8 @@ use strata_merkle_node_store::{MemMmr, StoredMmr}; use tokio::{runtime::Handle, task::block_in_place}; use crate::{ - AnchorStateStore, AuxDataStore, L1DataProvider, ManifestMmrStore, WorkerError, WorkerResult, + AnchorStateStore, AuxDataStore, ForkActivationStore, L1DataProvider, ManifestMmrStore, + WorkerError, WorkerResult, }; /// Shared mutable state for the test worker context. @@ -43,6 +44,8 @@ pub struct TestWorkerStateInner { pub manifest_mmr: MemMmr<[u8; 32]>, /// Stored manifests in insertion order pub manifests: Vec, + /// Discovered fork activations, kept sorted by enacting height. + pub fork_activations: Vec, } /// Test implementation of WorkerContext for integration tests @@ -251,6 +254,37 @@ impl ManifestMmrStore for TestAsmWorkerContext { } } +impl ForkActivationStore for TestAsmWorkerContext { + fn record_fork_activation(&self, activation: ForkActivation) -> WorkerResult<()> { + let mut inner = self.inner.lock().unwrap(); + let acts = &mut inner.fork_activations; + // Keyed by (enacting_height, fork): replace on replay, insert sorted otherwise. + match acts.iter().position(|a| { + (a.enacting_height, a.fork) == (activation.enacting_height, activation.fork) + }) { + Some(pos) => acts[pos] = activation, + None => { + let pos = acts.partition_point(|a| a.enacting_height <= activation.enacting_height); + acts.insert(pos, activation); + } + } + Ok(()) + } + + fn list_fork_activations(&self) -> WorkerResult> { + Ok(self.inner.lock().unwrap().fork_activations.clone()) + } + + fn prune_fork_activations_after(&self, after_height: u32) -> WorkerResult<()> { + self.inner + .lock() + .unwrap() + .fork_activations + .retain(|a| a.enacting_height <= after_height); + Ok(()) + } +} + impl AuxDataStore for TestAsmWorkerContext { fn store_aux_data( &self, diff --git a/crates/worker/src/traits.rs b/crates/worker/src/traits.rs index 8ddd38bc..4dfa57ac 100644 --- a/crates/worker/src/traits.rs +++ b/crates/worker/src/traits.rs @@ -1,21 +1,22 @@ //! Traits for the chain worker to interface with the underlying system. //! -//! The worker's dependencies split into four concerns, each backed by a +//! The worker's dependencies split into five concerns, each backed by a //! distinct subsystem in production: //! //! - [`L1DataProvider`] — reads L1 data from the Bitcoin node (blocks, txs, network). //! - [`AnchorStateStore`] — persists and loads the [`AnchorState`]. //! - [`ManifestMmrStore`] — manifest persistence and the manifest-hash MMR. //! - [`AuxDataStore`] — per-block [`AuxData`] for prover consumption. +//! - [`ForkActivationStore`] — fork activations discovered from ASM VK upgrade logs. //! -//! [`WorkerContext`] is the umbrella that combines all four. It has a blanket -//! impl, so an implementor just implements the four concern traits and gets +//! [`WorkerContext`] is the umbrella that combines all five. It has a blanket +//! impl, so an implementor just implements the five concern traits and gets //! `WorkerContext` for free; consumers that only need one concern can depend on //! the narrower trait instead of the whole context. use bitcoin::{Block, Network, block::Header}; use strata_asm_common::{ - AnchorState, AsmManifest, AsmManifestHash, AuxData, MMR_SENTINEL_DUMMY_LEAF, + AnchorState, AsmManifest, AsmManifestHash, AuxData, ForkActivation, MMR_SENTINEL_DUMMY_LEAF, }; use strata_btc_types::{BitcoinTxid, RawBitcoinTx}; use strata_identifiers::{L1BlockCommitment, L1BlockId}; @@ -186,18 +187,38 @@ pub trait AuxDataStore { fn get_aux_data(&self, blockid: &L1BlockCommitment) -> WorkerResult; } +/// Persists fork activations discovered from ASM VK upgrade logs. +pub trait ForkActivationStore { + /// Records a discovered fork activation. + /// + /// Called *before* the enacting block's anchor state is committed, so an + /// activation can never lag a committed anchor. Idempotent: crash-replay + /// of the enacting block rewrites the same record. + fn record_fork_activation(&self, activation: ForkActivation) -> WorkerResult<()>; + + /// Returns every recorded activation, ascending by enacting height. + fn list_fork_activations(&self) -> WorkerResult>; + + /// Removes activations whose enacting height is strictly above + /// `after_height` (which is kept). Called on reorgs so activations enacted + /// on the abandoned branch are dropped; re-processing the new branch + /// re-discovers any that survive. + fn prune_fork_activations_after(&self, after_height: u32) -> WorkerResult<()>; +} + /// Context trait for a worker to interact with the database and Bitcoin Client. /// -/// Umbrella over the four concern traits ([`L1DataProvider`], -/// [`AnchorStateStore`], [`ManifestMmrStore`], [`AuxDataStore`]). The blanket -/// impl means any type that implements all four automatically implements -/// `WorkerContext`, so implementors never name it directly. +/// Umbrella over the five concern traits ([`L1DataProvider`], +/// [`AnchorStateStore`], [`ManifestMmrStore`], [`AuxDataStore`], +/// [`ForkActivationStore`]). The blanket impl means any type that implements +/// all five automatically implements `WorkerContext`, so implementors never +/// name it directly. pub trait WorkerContext: - L1DataProvider + AnchorStateStore + ManifestMmrStore + AuxDataStore + L1DataProvider + AnchorStateStore + ManifestMmrStore + AuxDataStore + ForkActivationStore { } impl WorkerContext for T where - T: L1DataProvider + AnchorStateStore + ManifestMmrStore + AuxDataStore + T: L1DataProvider + AnchorStateStore + ManifestMmrStore + AuxDataStore + ForkActivationStore { } From 0d47a97c0d666ecaf3c0474eac961fe74dcd03c0 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Wed, 8 Jul 2026 11:39:58 +0545 Subject: [PATCH 4/9] feat(admin): carry the activating fork in the ASM STF VK update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/logs/src/asm_stf.rs | 55 +++++++++++++++---- .../admin/subprotocol/src/handler.rs | 15 +++-- .../txs/src/actions/updates/asm_stf_vk.rs | 39 ++++++++++--- tests/asm/admin_to_stf.rs | 12 +++- tests/harness/admin.rs | 7 ++- 5 files changed, 97 insertions(+), 31 deletions(-) diff --git a/crates/logs/src/asm_stf.rs b/crates/logs/src/asm_stf.rs index 12a486a8..d375d78f 100644 --- a/crates/logs/src/asm_stf.rs +++ b/crates/logs/src/asm_stf.rs @@ -6,17 +6,25 @@ use strata_predicate::PredicateKey; use crate::constants::AsmLogTypeId; -/// Details for an execution environment verification key update. +/// Details for an ASM STF verification key update. #[derive(Debug, Clone)] pub struct AsmStfUpdate { - /// New execution environment state transition function verification key. + /// New ASM state transition function verification key. new_predicate: PredicateKey, + + /// Raw id of the fork the new proving artifact implements, carried + /// verbatim from the enacted update. Raw so that artifacts predating the + /// fork can still emit it; the worker maps ids it knows. + fork_id: u16, } impl AsmStfUpdate { /// Create a new AsmStfUpdate instance. - pub fn new(new_predicate: PredicateKey) -> Self { - Self { new_predicate } + pub fn new(new_predicate: PredicateKey, fork_id: u16) -> Self { + Self { + new_predicate, + fork_id, + } } pub fn new_predicate(&self) -> &PredicateKey { @@ -26,16 +34,25 @@ impl AsmStfUpdate { pub fn into_new_predicate(self) -> PredicateKey { self.new_predicate } + + pub fn fork_id(&self) -> u16 { + self.fork_id + } } impl Codec for AsmStfUpdate { fn decode(dec: &mut impl Decoder) -> Result { let new_predicate = CodecSsz::::decode(dec)?.into_inner(); - Ok(Self { new_predicate }) + let fork_id = CodecSsz::::decode(dec)?.into_inner(); + Ok(Self { + new_predicate, + fork_id, + }) } fn encode(&self, enc: &mut impl Encoder) -> Result<(), CodecError> { - CodecSsz::new(self.new_predicate.clone()).encode(enc) + CodecSsz::new(self.new_predicate.clone()).encode(enc)?; + CodecSsz::new(self.fork_id).encode(enc) } } @@ -62,7 +79,7 @@ mod tests { proptest! { #[test] fn from_log_is_infallible(key in predicate_key_strategy()) { - let log = AsmStfUpdate::new(key); + let log = AsmStfUpdate::new(key, 1); prop_assert!(AsmLogEntry::from_log(&log).is_ok()); } } @@ -70,14 +87,28 @@ mod tests { #[test] fn from_log_boundary_cases() { let cases = [ - AsmStfUpdate::new(PredicateKey::new(PredicateTypeId::AlwaysAccept, vec![])), - AsmStfUpdate::new(PredicateKey::new( - PredicateTypeId::AlwaysAccept, - vec![0u8; MAX_CONDITION_LEN as usize], - )), + AsmStfUpdate::new(PredicateKey::new(PredicateTypeId::AlwaysAccept, vec![]), 1), + AsmStfUpdate::new( + PredicateKey::new( + PredicateTypeId::AlwaysAccept, + vec![0u8; MAX_CONDITION_LEN as usize], + ), + 1, + ), ]; for log in cases { assert!(AsmLogEntry::from_log(&log).is_ok()); } } + + #[test] + fn roundtrip_preserves_fork_id() { + let log = AsmStfUpdate::new(PredicateKey::always_accept(), 7); + let entry = AsmLogEntry::from_log(&log).expect("encoding is infallible"); + let back = entry + .try_into_log::() + .expect("log should decode back"); + assert_eq!(back.fork_id(), 7); + assert_eq!(back.new_predicate(), log.new_predicate()); + } } diff --git a/crates/subprotocols/admin/subprotocol/src/handler.rs b/crates/subprotocols/admin/subprotocol/src/handler.rs index e86a63e2..c516b9c4 100644 --- a/crates/subprotocols/admin/subprotocol/src/handler.rs +++ b/crates/subprotocols/admin/subprotocol/src/handler.rs @@ -187,9 +187,15 @@ fn handle_update( relay_checkpoint_predicate(relayer, update.into_key()); } UpdateAction::AsmStfVk(update) => { - let key = update.into_key(); - debug!(?key, "new ASM STF verifying key"); - let log_entry = AsmLogEntry::from_log(&AsmStfUpdate::new(key)) + // The fork id is deliberately not validated here: the multisig is + // expected to name the newly activating fork when authoring the + // action, and the id's representation may still change. + // TODO: once the fork id representation settles, consider tracking + // enacted updates in state so the activating fork can be validated + // (or auto-derived) at enactment instead of trusted operationally. + let (key, fork_id) = update.into_parts(); + debug!(?key, fork_id, "new ASM STF verifying key"); + let log_entry = AsmLogEntry::from_log(&AsmStfUpdate::new(key, fork_id)) .expect("AsmStfUpdate encoding is infallible"); relayer.emit_log(log_entry); info!("emitted ASM STF verifying key update log"); @@ -719,7 +725,7 @@ mod tests { let predicate = PredicateKey::always_accept(); - let update = UpdateAction::AsmStfVk(AsmStfVkUpdate::new(predicate.clone())); + let update = UpdateAction::AsmStfVk(AsmStfVkUpdate::new(predicate.clone(), 1)); let update_id = state.next_update_id(); let activation_height = 42; state.enqueue(QueuedUpdate::new(update_id, update, activation_height)); @@ -737,6 +743,7 @@ mod tests { .try_into_log::() .expect("log should deserialize as AsmStfUpdate"); assert_eq!(asm_update.new_predicate(), &predicate); + assert_eq!(asm_update.fork_id(), 1); } /// Test that cancel actions properly remove queued updates: diff --git a/crates/subprotocols/admin/txs/src/actions/updates/asm_stf_vk.rs b/crates/subprotocols/admin/txs/src/actions/updates/asm_stf_vk.rs index 33796359..42e12340 100644 --- a/crates/subprotocols/admin/txs/src/actions/updates/asm_stf_vk.rs +++ b/crates/subprotocols/admin/txs/src/actions/updates/asm_stf_vk.rs @@ -6,20 +6,39 @@ use strata_predicate::PredicateKey; use crate::actions::{IndentedDetails, RenderSigningMessage}; /// An update to the verifying key for the ASM STF. +/// +/// Every update carries the raw id of the fork the new proving artifact +/// implements. The id is opaque at this layer: future forks must be enactable +/// by artifacts that predate them, so the action cannot validate the id +/// against a known set. Consumers that know the mapping (the worker) activate +/// the fork. Each update is expected to name the fork it newly activates; +/// upholding that is on the multisig when authoring the action — the id +/// renders into the signing message, and signing one that names an +/// already-active fork is an operational flaw. #[derive(Clone, Debug, Eq, PartialEq, Arbitrary, Encode, Decode)] -pub struct AsmStfVkUpdate(PredicateKey); +pub struct AsmStfVkUpdate { + /// The new verifying key for the ASM STF. + key: PredicateKey, + + /// Raw id of the fork the new artifact implements. + fork_id: u16, +} impl AsmStfVkUpdate { - pub fn new(key: PredicateKey) -> Self { - Self(key) + pub fn new(key: PredicateKey, fork_id: u16) -> Self { + Self { key, fork_id } } pub fn key(&self) -> &PredicateKey { - &self.0 + &self.key + } + + pub fn fork_id(&self) -> u16 { + self.fork_id } - pub fn into_key(self) -> PredicateKey { - self.0 + pub fn into_parts(self) -> (PredicateKey, u16) { + (self.key, self.fork_id) } } @@ -29,7 +48,8 @@ impl RenderSigningMessage for AsmStfVkUpdate { } fn render_details(&self, details: &mut IndentedDetails<'_>) { - super::render::predicate(&self.0, details) + super::render::predicate(&self.key, details); + details.push(format!("Fork Id: {}", self.fork_id)); } } @@ -49,7 +69,7 @@ mod tests { let condition = vec![0x42; 64]; let expected_hash = format!("{:x}", hash::raw(&condition)); let key = PredicateKey::new(PredicateTypeId::Sp1Groth16, condition); - let update = AsmStfVkUpdate::new(key); + let update = AsmStfVkUpdate::new(key, 7); let action = MultisigAction::Update(UpdateAction::AsmStfVk(update)); let message = SigningMessage::for_action(&action, 5); @@ -62,7 +82,8 @@ mod tests { Sequence: 5\n\ Action Details:\n \ Predicate Type: Sp1Groth16\n \ - Predicate Hash: {expected_hash}" + Predicate Hash: {expected_hash}\n \ + Fork Id: 7" ), ); } diff --git a/tests/asm/admin_to_stf.rs b/tests/asm/admin_to_stf.rs index aadc146d..d6cf2365 100644 --- a/tests/asm/admin_to_stf.rs +++ b/tests/asm/admin_to_stf.rs @@ -16,7 +16,7 @@ use integration_tests::harness; use moho_runtime_impl::RuntimeInput; use moho_types::ExportState; use ssz::Encode; -use strata_asm_common::{AuxData, StfParams}; +use strata_asm_common::{AuxData, ForkId, StfParams}; use strata_asm_logs::AsmStfUpdate; use strata_asm_proof_impl::{ moho_program::{input::AsmStepInput, program::advance_export_state_with_logs}, @@ -45,7 +45,10 @@ async fn test_asm_predicate_update_emits_log() { // Submit an ASM predicate update (gets queued for StrataAdministrator role) let new_predicate = PredicateKey::always_accept(); harness - .submit_admin_action(&mut ctx, asm_stf_vk_update(new_predicate.clone())) + .submit_admin_action( + &mut ctx, + asm_stf_vk_update(new_predicate.clone(), ForkId::Fork1.into()), + ) .await .unwrap(); @@ -106,7 +109,10 @@ async fn test_proof_program_reflects_predicate_update() { // Submit an ASM predicate update (gets queued for StrataAdministrator role). let new_predicate = PredicateKey::never_accept(); harness - .submit_admin_action(&mut ctx, asm_stf_vk_update(new_predicate.clone())) + .submit_admin_action( + &mut ctx, + asm_stf_vk_update(new_predicate.clone(), ForkId::Fork1.into()), + ) .await .unwrap(); diff --git a/tests/harness/admin.rs b/tests/harness/admin.rs index 88772457..eaece26e 100644 --- a/tests/harness/admin.rs +++ b/tests/harness/admin.rs @@ -277,9 +277,10 @@ pub fn multisig_config_update( MultisigAction::Update(update) } -/// Create an ASM STF verifying key update action. -pub fn asm_stf_vk_update(key: PredicateKey) -> MultisigAction { - MultisigAction::Update(UpdateAction::AsmStfVk(AsmStfVkUpdate::new(key))) +/// Create an ASM STF verifying key update action carrying the raw id of the +/// fork the new artifact implements. +pub fn asm_stf_vk_update(key: PredicateKey, fork_id: u16) -> MultisigAction { + MultisigAction::Update(UpdateAction::AsmStfVk(AsmStfVkUpdate::new(key, fork_id))) } /// Create an OL STF (rollup) verifying key update action. From a7ef2f2eb6fb3e6319b553539a1b67032c3be978 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Mon, 6 Jul 2026 10:59:20 +0545 Subject: [PATCH 5/9] feat(worker): discover fork activations from ASM VK upgrade logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + crates/worker/Cargo.toml | 1 + crates/worker/src/builder.rs | 4 +- crates/worker/src/service.rs | 12 ++++ crates/worker/src/state.rs | 135 ++++++++++++++++++++++++++++++++--- 5 files changed, 141 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 866919e2..203621a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8164,6 +8164,7 @@ dependencies = [ "futures", "serde", "strata-asm-common", + "strata-asm-logs", "strata-asm-stf", "strata-btc-types", "strata-btc-verification", diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index b642d934..024a369e 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] strata-asm-common.workspace = true +strata-asm-logs.workspace = true strata-asm-stf.workspace = true strata-btc-types.workspace = true strata-btc-verification.workspace = true diff --git a/crates/worker/src/builder.rs b/crates/worker/src/builder.rs index 418ea815..b09af4c1 100644 --- a/crates/worker/src/builder.rs +++ b/crates/worker/src/builder.rs @@ -45,7 +45,9 @@ impl AsmWorkerBuilder { /// Set the ASM params. The spec derives everything else from these: the /// genesis anchor state, adopted (after validation against the L1 source) /// when the store holds no prior state, and the base STF params the - /// worker starts from. + /// worker starts from. Fork activations discovered from the ASM VK + /// upgrade log are overlaid on top of the base STF params to form the + /// effective params each transition executes under. pub fn with_params(mut self, params: S::Params) -> Self { self.params = Some(params); self diff --git a/crates/worker/src/service.rs b/crates/worker/src/service.rs index 82386038..4f923e31 100644 --- a/crates/worker/src/service.rs +++ b/crates/worker/src/service.rs @@ -175,6 +175,13 @@ where state.update_anchor_state(base_state, base_block); + // Fork activations enacted above the base belong to the branch being + // rewritten (or to blocks about to be deterministically re-applied); + // drop them before re-processing so the effective schedule cannot leak + // a rolled-back activation into the first re-processed block. A linear + // extension has nothing above the base, making this a no-op. + state.rollback_fork_activations(base_block.height())?; + // Phase 2: process the pending blocks oldest first. Collect them in applied // order so the caller can drive per-block follow-up work (e.g. proof // requests) over exactly the blocks the worker processed for this submit. @@ -269,6 +276,11 @@ where // Store auxiliary data for prover consumption. state.context.store_aux_data(block_id, &aux_data)?; + // Fork discovery before the commit point: if this block enacted an ASM VK + // upgrade a trigger maps to, persist the activation now so a committed + // anchor can never lack the activation it enacted. + state.discover_fork_activations(block_id, asm_stf_out.manifest.logs())?; + // Anchor state last: it is the block's commit point (see fn docs), so a // crash before it leaves the block uncommitted to be safely re-run. The // STF's logs are already persisted in the manifest recorded above. diff --git a/crates/worker/src/state.rs b/crates/worker/src/state.rs index fbfb189f..76f80f1e 100644 --- a/crates/worker/src/state.rs +++ b/crates/worker/src/state.rs @@ -1,7 +1,11 @@ use std::marker::PhantomData; use bitcoin::{Block, CompactTarget, params::Params}; -use strata_asm_common::{AnchorState, AsmSpec, AuxData, HeaderVerificationState, StfParams}; +use strata_asm_common::{ + AnchorState, AsmLogEntry, AsmSpec, AuxData, ForkActivation, ForkId, ForkSchedule, + HeaderVerificationState, StfParams, +}; +use strata_asm_logs::AsmStfUpdate; use strata_asm_stf::AsmStfOutput; use strata_btc_types::BlockHashExt; use strata_btc_verification::{ @@ -42,8 +46,12 @@ pub struct AsmWorkerServiceState { /// [`crate::AsmWorkerHandle::subscribe_blocks`]. pub(crate) subscribers: Subscribers, - /// STF params every transition executes under. - pub(crate) stf_params: StfParams, + /// Base fork schedule, as configured (the spec's initial STF params). + pub(crate) base_forks: ForkSchedule, + + /// Effective fork schedule: `base_forks` overlaid with every discovered + /// activation. This is what each transition executes under. + pub(crate) fork_schedule: ForkSchedule, /// ASM spec driving the subprotocol pipeline (type-level only). _spec: PhantomData, @@ -57,9 +65,9 @@ where /// Creates a new service state, loading the latest anchor or adopting the /// given genesis state. /// - /// `stf_params` are the params every transition executes under; - /// `genesis_state` is the genesis anchor the worker adopts when the store - /// holds no prior state. + /// `stf_params` are the configured base params (before any discovered fork + /// activations); `genesis_state` is the genesis anchor the worker adopts + /// when the store holds no prior state. /// /// Construction goes through [`crate::AsmWorkerBuilder`], which owns the /// shared [`Subscribers`] registry — hence `pub(crate)`. @@ -75,6 +83,18 @@ where .last_verified_block .height() as u64; + // The configured params are the base; activations discovered before a + // restart are overlaid to resume the effective schedule. + let base_forks = stf_params.forks; + let activations = context.list_fork_activations()?; + let fork_schedule = effective_schedule(&base_forks, &activations); + if !activations.is_empty() { + tracing::info!( + ?activations, + "resuming with persisted fork activations applied" + ); + } + // Align the manifest MMR with L1 heights before processing any block: // it is height-indexed, prefilled with sentinels for heights // `0..=genesis_height` so the manifest for height `h` lands at index @@ -110,7 +130,8 @@ where blkid, genesis_height, subscribers, - stf_params, + base_forks, + fork_schedule, }) } @@ -122,7 +143,13 @@ where /// Returns the actual ASM STF results and the auxiliary data used during the transition. /// /// A caller is responsible for ensuring the current anchor is a parent of a passed block. - pub fn transition(&self, block: &Block) -> WorkerResult<(AsmStfOutput, AuxData)> { + pub fn transition(&mut self, block: &Block) -> WorkerResult<(AsmStfOutput, AuxData)> { + // Execute under the effective fork schedule so activations discovered + // on earlier blocks take effect from their activation height on. + let stf_params = StfParams { + forks: self.fork_schedule.clone(), + }; + let cur_state = &self.anchor; // Pre process transition next block against current anchor state. @@ -130,7 +157,7 @@ where let span = tracing::debug_span!("asm.stf.pre_process", protocol_txs = Empty); let _guard = span.enter(); - let result = strata_asm_stf::pre_process_asm::(&self.stf_params, cur_state, block) + let result = strata_asm_stf::pre_process_asm::(&stf_params, cur_state, block) .map_err(WorkerError::AsmError)?; span.record("protocol_txs", result.txs.len()); @@ -157,7 +184,7 @@ where let coinbase_inclusion_proof = TxidInclusionProof::generate(&block.txdata, 0); strata_asm_stf::compute_asm_transition::( - &self.stf_params, + &stf_params, cur_state, block, &aux_data, @@ -172,6 +199,92 @@ where self.anchor = anchor; self.blkid = blkid; } + + /// Scans a processed block's logs for enacted ASM VK upgrades and activates + /// the fork each one names, from the next block on. + /// + /// MUST run before the enacting block's anchor state is committed: the + /// activation record is persisted here, and persisting it first guarantees + /// a committed anchor never lacks the activation it enacted (crash-replay + /// simply rewrites the same record). + pub(crate) fn discover_fork_activations( + &mut self, + block_id: &L1BlockCommitment, + logs: &[AsmLogEntry], + ) -> WorkerResult<()> { + for update in logs + .iter() + .filter_map(|l| l.try_into_log::().ok()) + { + let raw_id = update.fork_id(); + let Ok(fork) = ForkId::try_from(raw_id) else { + // A fork id this binary has no variant for: it cannot apply + // the fork's rules, so it cannot meaningfully activate it + // either. Once the unknown fork activates, this worker can no + // longer follow the chain. + // TODO: consider halting the worker at the activation height + // instead of diverging silently. + tracing::error!( + fork_id = raw_id, + %block_id, + "ASM VK upgrade names a fork id unknown to this binary" + ); + continue; + }; + let enacting_height = block_id.height(); + if self.fork_schedule.is_active(fork, enacting_height as u64) { + // Enacted updates are expected to name the fork they newly + // activate; one naming an already-active fork is an + // operational flaw on the authoring side. Leave the schedule + // untouched so a flawed update cannot retro-raise an + // activation height. + tracing::warn!( + ?fork, + enacting_height, + "ASM VK upgrade names an already-active fork; skipping" + ); + continue; + } + + let activation = ForkActivation { + enacting_height, + fork, + new_predicate: update.into_new_predicate(), + }; + let activation_height = activation.activation_height(); + self.context.record_fork_activation(activation)?; + self.fork_schedule.activate_at(fork, activation_height); + tracing::info!( + ?fork, + activation_height, + %block_id, + "fork activated by ASM VK upgrade" + ); + } + Ok(()) + } + + /// Drops fork activations enacted strictly above `base_height` and + /// recomputes the effective schedule. + /// + /// Called when a sync rebases onto an ancestor (reorg): activations + /// enacted on the abandoned branch must not leak into re-processing; + /// any still on the new branch are re-discovered as its blocks re-apply. + pub(crate) fn rollback_fork_activations(&mut self, base_height: u32) -> WorkerResult<()> { + self.context.prune_fork_activations_after(base_height)?; + let activations = self.context.list_fork_activations()?; + self.fork_schedule = effective_schedule(&self.base_forks, &activations); + Ok(()) + } +} + +/// Overlays `activations` onto the `base` schedule. +fn effective_schedule(base: &ForkSchedule, activations: &[ForkActivation]) -> ForkSchedule { + let mut schedule = base.clone(); + for activation in activations { + schedule.activate_at(activation.fork, activation.activation_height()); + } + schedule } impl ServiceState for AsmWorkerServiceState @@ -286,7 +399,7 @@ mod tests { /// `transition` runs the STF for a child of the current anchor. #[tokio::test(flavor = "multi_thread")] async fn transition_processes_child_of_anchor() { - let fx = fixtures::setup_state(101).await; + let mut fx = fixtures::setup_state(101).await; // A child of the genesis anchor: height 102, parent 101. let hashes = mine_blocks(&fx.node, &fx.client, 1, None) .await From d867438989baf269306e5abb2c265a2e5ef9eea9 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Mon, 6 Jul 2026 15:42:21 +0545 Subject: [PATCH 6/9] test: cover the unstake fork gate and upgrade lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Cargo.lock | 1 + .../bridge-v1/subprotocol/src/handler.rs | 106 +++++++++- crates/worker/Cargo.toml | 1 + crates/worker/src/state.rs | 132 ++++++++++++ tests/Cargo.toml | 4 + tests/asm/fork_unstake.rs | 200 ++++++++++++++++++ tests/harness/bridge.rs | 113 +++++++++- tests/harness/test_harness.rs | 35 ++- 8 files changed, 585 insertions(+), 7 deletions(-) create mode 100644 tests/asm/fork_unstake.rs diff --git a/Cargo.lock b/Cargo.lock index 203621a7..0795a81f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8172,6 +8172,7 @@ dependencies = [ "strata-l1-txfmt", "strata-merkle", "strata-merkle-node-store", + "strata-predicate", "strata-service", "strata-tasks", "strata-test-utils-btcio", diff --git a/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs b/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs index 56d4c5c0..ffacb130 100644 --- a/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs +++ b/crates/subprotocols/bridge-v1/subprotocol/src/handler.rs @@ -205,10 +205,13 @@ mod tests { use strata_test_utils_arb::ArbitraryGenerator; use super::handle_parsed_tx; - use crate::test_utils::{ - MockMsgRelayer, add_deposits_and_assignments, create_test_state, create_verified_aux_data, - create_withdrawal_info_from_assignment, setup_deposit_test, setup_slash_test, - setup_unstake_test, + use crate::{ + errors::BridgeSubprotocolError, + test_utils::{ + MockMsgRelayer, add_deposits_and_assignments, create_test_state, + create_verified_aux_data, create_withdrawal_info_from_assignment, setup_deposit_test, + setup_slash_test, setup_unstake_test, + }, }; #[test] @@ -367,6 +370,101 @@ mod tests { ); } + /// Before the unstake fork, a perfectly valid unstake tx is rejected and + /// the operator set is untouched. + #[test] + fn test_handle_unstake_tx_rejected_when_fork_inactive() { + let operator_idx = 0; + let (mut state, operators) = create_test_state(); + let (info, aux) = setup_unstake_test(operator_idx, &operators); + + let parsed_tx = ParsedTx::Unstake(info); + let mut relayer = MockMsgRelayer; + let result = handle_parsed_tx( + &mut state, + parsed_tx, + &aux, + &mut relayer, + &StfParams::default(), + 0, + ); + + assert!( + matches!( + result, + Err(BridgeSubprotocolError::UnstakeForkInactive { .. }) + ), + "expected UnstakeForkInactive, got {result:?}", + ); + assert!( + state.operators().is_in_current_multisig(operator_idx), + "operator must be retained pre-fork" + ); + } + + /// The gate flips exactly at the activation height. + #[test] + fn test_handle_unstake_tx_fork_boundary() { + let params = StfParams { + forks: strata_asm_common::ForkSchedule { fork1: 100 }, + }; + + for (height, expect_processed) in [(99, false), (100, true)] { + let operator_idx = 0; + let (mut state, operators) = create_test_state(); + let (info, aux) = setup_unstake_test(operator_idx, &operators); + + let mut relayer = MockMsgRelayer; + let result = handle_parsed_tx( + &mut state, + ParsedTx::Unstake(info), + &aux, + &mut relayer, + ¶ms, + height, + ); + + assert_eq!( + result.is_ok(), + expect_processed, + "unexpected outcome at height {height}: {result:?}", + ); + assert_eq!( + !state.operators().is_in_current_multisig(operator_idx), + expect_processed, + "operator removal must match gate outcome at height {height}", + ); + } + } + + /// Pre-fork the gate rejects *before* touching aux data, so an unstake tx + /// whose aux was (correctly) never requested does not panic. + #[test] + fn test_handle_unstake_tx_fork_inactive_skips_aux() { + let operator_idx = 0; + let (mut state, operators) = create_test_state(); + let (info, _aux) = setup_unstake_test(operator_idx, &operators); + + let empty_aux = create_verified_aux_data(vec![]); + let mut relayer = MockMsgRelayer; + let result = handle_parsed_tx( + &mut state, + ParsedTx::Unstake(info), + &empty_aux, + &mut relayer, + &StfParams::default(), + 0, + ); + + assert!( + matches!( + result, + Err(BridgeSubprotocolError::UnstakeForkInactive { .. }) + ), + "expected UnstakeForkInactive without an aux panic, got {result:?}", + ); + } + #[test] #[should_panic(expected = "invalid aux: deposit DRT not provided")] fn test_handle_deposit_tx_panics_on_missing_aux_data() { diff --git a/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index 024a369e..df3efc22 100644 --- a/crates/worker/Cargo.toml +++ b/crates/worker/Cargo.toml @@ -36,6 +36,7 @@ test-utils = ["dep:bitcoind-async-client", "dep:strata-merkle-node-store"] [dev-dependencies] strata-l1-txfmt.workspace = true +strata-predicate.workspace = true strata-test-utils-btcio.workspace = true bitcoind-async-client.workspace = true diff --git a/crates/worker/src/state.rs b/crates/worker/src/state.rs index 76f80f1e..bbf5f38e 100644 --- a/crates/worker/src/state.rs +++ b/crates/worker/src/state.rs @@ -585,4 +585,136 @@ mod tests { "prefill is idempotent across restart", ); } + + mod fork_activation { + use strata_asm_common::{AsmLogEntry, ForkActivation, ForkId, ForkSchedule}; + use strata_asm_logs::AsmStfUpdate; + use strata_identifiers::Buf32; + use strata_predicate::{PredicateKey, PredicateTypeId}; + + use super::*; + use crate::ForkActivationStore; + + /// Stands in for the upgraded artifact's VK, persisted with the + /// activation discovery records. + fn upgrade_predicate() -> PredicateKey { + PredicateKey::new(PredicateTypeId::Bip340Schnorr, vec![0x33; 32]) + } + + fn upgrade_log(fork_id: u16) -> AsmLogEntry { + AsmLogEntry::from_log(&AsmStfUpdate::new(upgrade_predicate(), fork_id)) + .expect("AsmStfUpdate encoding is infallible") + } + + fn block_at(height: u32) -> L1BlockCommitment { + L1BlockCommitment::new(height, Buf32::new([0xEE; 32]).into()) + } + + /// An upgrade log activates the fork it names at H+1, in memory and + /// on disk. + #[tokio::test(flavor = "multi_thread")] + async fn discover_activates_named_fork() { + let mut fx = fixtures::setup_state(101).await; + + fx.state + .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + .unwrap(); + + assert_eq!(fx.state.fork_schedule.activation_height(ForkId::Fork1), 151); + let stored = fx.state.context.list_fork_activations().unwrap(); + assert_eq!( + stored, + vec![ForkActivation { + enacting_height: 150, + fork: ForkId::Fork1, + new_predicate: upgrade_predicate(), + }], + ); + } + + /// An upgrade naming a fork already active at the enacting height is + /// an authoring flaw: it must not retro-raise the activation. + #[tokio::test(flavor = "multi_thread")] + async fn discover_ignores_already_active_fork() { + let mut fx = fixtures::setup_state(101).await; + fx.state.fork_schedule.activate_at(ForkId::Fork1, 10); + + fx.state + .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + .unwrap(); + + assert_eq!(fx.state.fork_schedule.activation_height(ForkId::Fork1), 10); + assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); + } + + /// An upgrade naming a fork id this binary has no variant for must + /// not activate anything. + #[tokio::test(flavor = "multi_thread")] + async fn discover_ignores_unknown_fork_id() { + let mut fx = fixtures::setup_state(101).await; + + fx.state + .discover_fork_activations(&block_at(150), &[upgrade_log(0xBEEF)]) + .unwrap(); + + assert_eq!(fx.state.fork_schedule, ForkSchedule::all_disabled()); + assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); + } + + /// A restart resumes the effective schedule from persisted activations. + #[tokio::test(flavor = "multi_thread")] + async fn new_resumes_persisted_activations() { + let fx = fixtures::setup_state(101).await; + let context = fx.state.context.clone(); // shares the in-memory store + context + .record_fork_activation(ForkActivation { + enacting_height: 120, + fork: ForkId::Fork1, + new_predicate: upgrade_predicate(), + }) + .unwrap(); + + let genesis = fixtures::genesis_state(&fx.client, 101).await; + let reloaded = AsmWorkerServiceState::<_, TestAsmSpec>::new( + context, + genesis, + StfParams::default(), + Subscribers::default(), + ) + .unwrap(); + + assert_eq!( + reloaded.fork_schedule.activation_height(ForkId::Fork1), + 121, + "restart must resume the discovered activation", + ); + } + + /// A reorg rollback prunes activations above the base and recomputes + /// the effective schedule from what survives. + #[tokio::test(flavor = "multi_thread")] + async fn rollback_prunes_and_recomputes() { + let mut fx = fixtures::setup_state(101).await; + fx.state + .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + .unwrap(); + assert!(fx.state.fork_schedule.is_active(ForkId::Fork1, 151)); + + // Reorg to a base below the enacting block: back to the base schedule. + fx.state.rollback_fork_activations(140).unwrap(); + assert_eq!( + fx.state.fork_schedule, + ForkSchedule::all_disabled(), + "activation enacted above the base must be dropped", + ); + assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); + + // A rollback at or above the enacting height keeps the activation. + fx.state + .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + .unwrap(); + fx.state.rollback_fork_activations(150).unwrap(); + assert!(fx.state.fork_schedule.is_active(ForkId::Fork1, 151)); + } + } } diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 72ae4ebd..37fbe985 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -95,3 +95,7 @@ path = "asm/admin_to_stf.rs" [[test]] name = "asm_admin_to_ee_stf" path = "asm/admin_to_ee_stf.rs" + +[[test]] +name = "asm_fork_unstake" +path = "asm/fork_unstake.rs" diff --git a/tests/asm/fork_unstake.rs b/tests/asm/fork_unstake.rs new file mode 100644 index 00000000..3d64b93b --- /dev/null +++ b/tests/asm/fork_unstake.rs @@ -0,0 +1,200 @@ +//! End-to-end fork-gated unstake upgrade flow at the worker level. +//! +//! Drives the exact choreography an ASM upgrade performs on-chain: the chain +//! starts under pre-fork rules (unstake never activates), the admin enacts an +//! ASM VK upgrade naming the fork it activates, and from the next block on +//! the worker applies post-fork rules. Also exercises the reorg path: +//! abandoning the enacting block rolls the activation back until the new +//! branch re-enacts it. + +#![allow( + unused_crate_dependencies, + reason = "test dependencies shared across test suite" +)] + +use harness::{ + admin::{asm_stf_vk_update, submit_and_activate, DEFAULT_CONFIRMATION_DEPTH}, + bridge::{submit_genuine_unstake_tx, BridgeExt}, + test_harness::{AsmTestHarnessBuilder, Setup}, +}; +use integration_tests::harness; +use strata_asm_common::{ForkId, ForkSchedule}; +use strata_asm_logs::AsmStfUpdate; +use strata_asm_worker::ForkActivationStore; +use strata_predicate::{PredicateKey, PredicateTypeId}; + +/// Stands in for the post-fork proving artifact's VK. At worker level nothing +/// verifies proofs, so any distinct predicate will do. +fn post_fork_predicate() -> PredicateKey { + PredicateKey::new(PredicateTypeId::Bip340Schnorr, vec![0x77; 32]) +} + +/// Builds a harness whose chain starts under pre-fork rules. +async fn pre_fork_setup() -> Setup { + AsmTestHarnessBuilder::default() + .with_txindex() + .with_fork_schedule(ForkSchedule::all_disabled()) + .build() + .await +} + +/// The full upgrade lifecycle: unstake ignored pre-fork, the VK upgrade +/// enactment activates the fork at H+1 (recorded and persisted), and the same +/// kind of unstake then removes the operator. +#[tokio::test(flavor = "multi_thread")] +async fn test_unstake_fork_upgrade_lifecycle() { + let Setup { + harness, + admin: mut admin_ctx, + bridge, + .. + } = pre_fork_setup().await; + + let victim_idx = 1u32; + assert!( + harness + .bridge_state() + .unwrap() + .operators() + .is_in_current_multisig(victim_idx), + "victim must start in the active multisig" + ); + + // 1. Pre-fork: a genuine, fully valid unstake is ignored. + submit_genuine_unstake_tx(&harness, &bridge, victim_idx) + .await + .unwrap(); + assert!( + harness + .bridge_state() + .unwrap() + .operators() + .is_in_current_multisig(victim_idx), + "unstake must be ignored before the fork activates", + ); + assert!( + harness.context.list_fork_activations().unwrap().is_empty(), + "nothing must activate before the upgrade", + ); + + // 2. Enact the ASM VK upgrade naming the fork. + submit_and_activate( + &harness, + &mut admin_ctx, + asm_stf_vk_update(post_fork_predicate(), ForkId::Fork1.into()), + ) + .await; + + // The enacting block's manifest carries the AsmStfUpdate log... + let manifests = harness.get_stored_manifests(); + let enacting_height = manifests + .iter() + .find(|m| { + m.logs + .iter() + .any(|log| log.try_into_log::().is_ok()) + }) + .map(|m| m.height()) + .expect("an enacting manifest must exist"); + + // ...and the worker recorded the activation for the block after it. + let activations = harness.context.list_fork_activations().unwrap(); + assert_eq!(activations.len(), 1, "exactly one activation expected"); + assert_eq!(activations[0].fork, ForkId::Fork1); + assert_eq!(activations[0].enacting_height, enacting_height); + assert_eq!( + activations[0].activation_height(), + enacting_height as u64 + 1 + ); + assert_eq!( + activations[0].new_predicate, + post_fork_predicate(), + "the record must carry the VK the upgrade enacted", + ); + + // 3. Post-fork: the same kind of unstake now removes the operator. + submit_genuine_unstake_tx(&harness, &bridge, victim_idx) + .await + .unwrap(); + assert!( + !harness + .bridge_state() + .unwrap() + .operators() + .is_in_current_multisig(victim_idx), + "unstake must be processed after the fork activates", + ); +} + +/// Reorging out the upgrade rolls the fork back, and the new branch re-enacts +/// it once the (re-mined) update reaches its activation height again. +/// +/// The reorg invalidates the *submission* block, so the admin commit/reveal +/// txs are evicted to the mempool and re-mined into the first replacement +/// block. The queued update therefore re-activates at the same height `S + D`; +/// until the new branch reaches it the fork is rolled back. +#[tokio::test(flavor = "multi_thread")] +async fn test_reorg_rolls_back_and_rediscovers_fork() { + let Setup { + harness, + admin: mut admin_ctx, + bridge, + .. + } = pre_fork_setup().await; + + let victim_idx = 1u32; + submit_and_activate( + &harness, + &mut admin_ctx, + asm_stf_vk_update(post_fork_predicate(), ForkId::Fork1.into()), + ) + .await; + + let activations = harness.context.list_fork_activations().unwrap(); + assert_eq!(activations.len(), 1, "activation recorded at enactment"); + let enacting_height = activations[0].enacting_height; + let submission_height = enacting_height as u64 - DEFAULT_CONFIRMATION_DEPTH as u64; + + // Reorg out the submission block and everything above it. The evicted + // admin txs are re-mined into the first replacement block (same height S), + // so the update re-queues with the same activation height S + D. Two + // replacement blocks leave the tip one short of re-enactment. + harness + .reorg(submission_height, DEFAULT_CONFIRMATION_DEPTH as usize) + .await + .unwrap(); + + assert!( + harness.context.list_fork_activations().unwrap().is_empty(), + "activation enacted on the abandoned branch must be rolled back", + ); + + // With the fork rolled back, a genuine unstake is ignored again. (This + // also advances the chain, re-enacting the re-mined update along the way.) + let pre_unstake_activations = harness.context.list_fork_activations().unwrap(); + assert!(pre_unstake_activations.is_empty()); + + // Mine to the re-enactment height and confirm the fork re-activates on + // the new branch. + harness.mine_blocks(2).await.unwrap(); + let activations = harness.context.list_fork_activations().unwrap(); + assert_eq!( + activations.len(), + 1, + "the re-mined update must re-enact on the new branch", + ); + assert_eq!(activations[0].fork, ForkId::Fork1); + + // And post-fork behavior applies again. + submit_genuine_unstake_tx(&harness, &bridge, victim_idx) + .await + .unwrap(); + assert!( + !harness + .bridge_state() + .unwrap() + .operators() + .is_in_current_multisig(victim_idx), + "unstake must be processed once the re-enacted fork activates", + ); +} diff --git a/tests/harness/bridge.rs b/tests/harness/bridge.rs index 99bb98cf..bbbd1321 100644 --- a/tests/harness/bridge.rs +++ b/tests/harness/bridge.rs @@ -51,7 +51,10 @@ use strata_crypto::{ }; use strata_l1_txfmt::ParseConfig; use strata_test_utils_arb::ArbitraryGenerator; -use strata_test_utils_btcio::{address::derive_musig2_p2tr_address, signing::sign_musig2_keypath}; +use strata_test_utils_btcio::{ + address::derive_musig2_p2tr_address, + signing::{sign_musig2_keypath, sign_musig2_scriptpath}, +}; use super::test_harness::AsmTestHarness; @@ -404,6 +407,114 @@ fn build_trivial_script() -> ScriptBuf { script::Builder::new().push_int(1).into_script() } +// ============================================================================ +// Genuine unstake +// ============================================================================ + +/// Submits a *genuine* unstake: a taproot script-path spend of the canonical +/// stake connector bound to the operator set's real N/N aggregated key, signed +/// by the full operator set via MuSig2. +/// +/// Every validation rule passes, so the tx's fate is decided purely by the +/// unstake fork gate: pre-fork it is skipped (operator retained), post-fork it +/// removes `operator_idx` from the active set. +/// +/// Returns the block hash containing the unstake transaction. +pub async fn submit_genuine_unstake_tx( + harness: &AsmTestHarness, + bridge: &BridgeContext, + operator_idx: OperatorIdx, +) -> anyhow::Result { + // 1. The live N/N aggregated key. Sanity-check it matches the aggregation of the context's + // operator keys, which MuSig2 signing below relies on. + let bridge_state = harness.bridge_state()?; + let nn_pubkey = bridge_state.operators().agg_key().to_xonly_public_key(); + let (_, derived_key) = derive_musig2_p2tr_address(bridge.operator_privkeys())?; + anyhow::ensure!( + nn_pubkey == derived_key, + "operator key aggregation mismatch between live state and context keys" + ); + + // 2. Canonical stake connector committing to (stake_hash, N/N key): P2TR(NUMS, single + // stake-connector leaf). + let preimage = [0x5Au8; 32]; + let stake_hash = sha256::Hash::hash(&preimage).to_byte_array(); + let leaf_script = stake_connector_script(stake_hash, nn_pubkey); + let spend_info = TaprootBuilder::new() + .add_leaf(0, leaf_script.clone())? + .finalize(SECP256K1, *UNSPENDABLE_PUBLIC_KEY) + .map_err(|_| anyhow::anyhow!("failed to finalize stake-connector taproot"))?; + let stake_connector_address = Address::p2tr( + SECP256K1, + *UNSPENDABLE_PUBLIC_KEY, + spend_info.merkle_root(), + Network::Regtest, + ); + + // 3. Fund and confirm the stake connector so the unstake has a spendable prevout. + let funding_amount = Amount::from_sat(10_000); + let (funding_txid, funding_vout) = harness + .create_funding_utxo(&stake_connector_address, funding_amount) + .await?; + harness.mine_block(None).await?; + + // 4. SPS-50 OP_RETURN naming the operator, plus a change output for the fee. + let aux = UnstakeTxHeaderAux::new(operator_idx); + let tag_data = aux.build_tag_data(); + let op_return_script = + ParseConfig::new(harness.asm_params.genesis.magic).encode_script_buf(&tag_data.as_ref())?; + let fee = AsmTestHarness::DEFAULT_FEE; + let change_address = harness.client.get_new_address().await?; + + let prevout = TxOut { + value: funding_amount, + script_pubkey: stake_connector_address.script_pubkey(), + }; + let mut unstake_tx = Transaction { + version: Version::TWO, + lock_time: LockTime::ZERO, + input: vec![TxIn { + previous_output: OutPoint::new(funding_txid, funding_vout), + script_sig: ScriptBuf::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + witness: Witness::new(), + }], + output: vec![ + TxOut { + value: Amount::ZERO, + script_pubkey: op_return_script, + }, + TxOut { + value: funding_amount - fee, + script_pubkey: change_address.script_pubkey(), + }, + ], + }; + + // 5. MuSig2 script-path signature by the full operator set, then the witness in the [preimage, + // sig, script, control_block] layout ASM parses. + let nn_sig = sign_musig2_scriptpath( + &unstake_tx, + bridge.operator_privkeys(), + slice::from_ref(&prevout), + 0, + &leaf_script, + LeafVersion::TapScript, + )?; + let control_block = spend_info + .control_block(&(leaf_script.clone(), LeafVersion::TapScript)) + .ok_or_else(|| anyhow::anyhow!("control block must exist for stake-connector leaf"))?; + + let mut witness = Witness::new(); + witness.push(preimage); + witness.push(nn_sig.serialize()); + witness.push(leaf_script.as_bytes()); + witness.push(control_block.serialize()); + unstake_tx.input[0].witness = witness; + + harness.submit_and_mine_tx(&unstake_tx).await +} + // ============================================================================ // Forged unstake reproduction // ============================================================================ diff --git a/tests/harness/test_harness.rs b/tests/harness/test_harness.rs index 3639f27a..c51d279d 100644 --- a/tests/harness/test_harness.rs +++ b/tests/harness/test_harness.rs @@ -50,8 +50,8 @@ use bitcoind_async_client::{ }; use corepc_node::Node; use rand::RngCore; -use strata_asm_common::{AnchorState, AsmLogEntry}; -use strata_asm_params::{AdministrationInitConfig, AsmParams, SubprotocolInstance}; +use strata_asm_common::{AnchorState, AsmLogEntry, ForkSchedule}; +use strata_asm_params::{AdministrationInitConfig, AsmParams, StfConfig, SubprotocolInstance}; use strata_asm_spec::StrataAsmSpec; use strata_asm_worker::{ test_utils::{get_l1_anchor, TestAsmWorkerContext}, @@ -158,6 +158,23 @@ impl AsmTestHarness { Ok(hashes) } + /// Forces a reorg: invalidates the block at `invalidate_height` (dropping + /// it and every block above it), then mines `new_len` replacement blocks — + /// each processed by the worker like any mined block. + /// + /// `invalidate_block` is forceful, so the new branch becomes active + /// regardless of length; `new_len` need only be `>= 1` so there is a new + /// tip to submit. + pub async fn reorg( + &self, + invalidate_height: u64, + new_len: usize, + ) -> anyhow::Result> { + let bad = self.client.get_block_hash(invalidate_height).await?; + self.bitcoind.client.invalidate_block(bad)?; + self.mine_blocks(new_len).await + } + /// Mine a single block containing exactly `txs`, in the given order, then process it. /// /// `generate_to_address` gives no ordering guarantee for independent transactions, so @@ -577,6 +594,7 @@ pub struct AsmTestHarnessBuilder { admin_customize: Option, num_operators: usize, txindex: bool, + fork_schedule: ForkSchedule, } impl Default for AsmTestHarnessBuilder { @@ -587,6 +605,9 @@ impl Default for AsmTestHarnessBuilder { admin_customize: None, num_operators: DEFAULT_NUM_OPERATORS, txindex: false, + // Matches the production guest: unstake active since genesis, so + // existing bridge tests exercise the full unstake path. + fork_schedule: ForkSchedule::all_enabled(), } } } @@ -652,6 +673,13 @@ impl AsmTestHarnessBuilder { self } + /// Sets the base fork schedule the worker starts from (default: unstake + /// active since genesis, matching the production guest). + pub fn with_fork_schedule(mut self, schedule: ForkSchedule) -> Self { + self.fork_schedule = schedule; + self + } + /// Builds the harness and returns it alongside the per-subprotocol contexts. Panics on /// failure (test setup); see [`Setup`]. pub async fn build(self) -> Setup { @@ -699,6 +727,9 @@ impl AsmTestHarnessBuilder { SubprotocolInstance::Checkpoint(cfg) => *cfg = checkpoint_config.clone(), } } + asm_params.stf = StfConfig { + forks: self.fork_schedule, + }; let asm_params = Arc::new(asm_params); // 5. Create worker context. The worker prefills the height-indexed MMR From 558aba5c5f7dde24cd066a7e181c1d1a583cfff4 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Wed, 8 Jul 2026 16:50:35 +0545 Subject: [PATCH 7/9] fix(worker): reject unsupported fork activation blocks --- crates/worker/src/errors.rs | 9 ++ crates/worker/src/service.rs | 165 +++++++++++++++++++++++++++++++---- crates/worker/src/state.rs | 48 ++++++---- 3 files changed, 188 insertions(+), 34 deletions(-) diff --git a/crates/worker/src/errors.rs b/crates/worker/src/errors.rs index f8e93b4f..04054e63 100644 --- a/crates/worker/src/errors.rs +++ b/crates/worker/src/errors.rs @@ -66,6 +66,15 @@ pub enum WorkerError { #[error("missing aux data for the block {0:?}")] MissingAuxData(L1BlockCommitment), + #[error( + "cannot process L1 block at height {block_height}: ASM VK upgrade activates unsupported fork id {fork_id}; worker remains stuck at height {stuck_height}; load an image that supports the fork" + )] + UnsupportedForkActivation { + fork_id: u16, + block_height: u32, + stuck_height: u32, + }, + /// A Bitcoin RPC call failed after exhausting its retry budget. The /// payload carries the underlying error's display so the operator sees /// the actual cause (block not found, timeout, connection refused, auth, diff --git a/crates/worker/src/service.rs b/crates/worker/src/service.rs index 4f923e31..5fc9ebd4 100644 --- a/crates/worker/src/service.rs +++ b/crates/worker/src/service.rs @@ -243,19 +243,23 @@ fn plan_block_processing( }) } -/// Runs the STF for `block_id`, then persists the results in a deliberate -/// order — the manifest (into the height-indexed MMR) and the prover aux data -/// first, the anchor state last — before advancing the in-memory anchor. +/// Runs the STF for `block_id`, discovers any fork activations from its logs, +/// then persists the results in a deliberate order — the manifest (into the +/// height-indexed MMR) and the prover aux data first, the anchor state last — +/// before advancing the in-memory anchor. /// -/// The order is the crash-safety contract. The anchor state is this block's -/// commit point: [`plan_block_processing`] treats a block as processed only -/// once its anchor state is stored, so it is written after everything derived -/// from the block. If an error aborts after the manifest or aux data write but -/// before the anchor state, the block stays uncommitted and the next sync -/// re-runs its STF. That re-run is safe: every write on this path is an -/// idempotent, block-keyed overwrite (the MMR leaf is replaced by height, aux -/// data and anchor state are keyed by block id, and the STF is deterministic, -/// so it reproduces identical values. +/// Fork activation discovery happens before any per-block persistence. If a +/// block enacts a fork this worker does not support, the worker returns a +/// specific error and leaves the block entirely uncommitted: no manifest leaf, +/// aux data, or anchor state is written. Otherwise, the anchor state remains +/// the block's commit point: [`plan_block_processing`] treats a block as +/// processed only once its anchor state is stored, so it is written after +/// everything derived from the block. If an error aborts after the manifest or +/// aux data write but before the anchor state, the block stays uncommitted and +/// the next sync re-runs its STF. That re-run is safe: every write on this path +/// is an idempotent, block-keyed overwrite (the MMR leaf is replaced by height, +/// aux data and anchor state are keyed by block id, and the STF is +/// deterministic, so it reproduces identical values). fn apply_block( state: &mut AsmWorkerServiceState, block_id: &L1BlockCommitment, @@ -269,6 +273,12 @@ where let block = state.context.get_l1_block(block_id.blkid())?; let (asm_stf_out, aux_data) = state.transition(&block)?; + // Fork discovery before any per-block persistence: if this block enacted + // an ASM VK upgrade this binary cannot map to a known fork, the block is + // not committed at all. For supported upgrades, persist the activation now + // so a committed anchor can never lack the activation it enacted. + state.discover_fork_activations(block_id, asm_stf_out.manifest.logs())?; + // Persist the manifest and record its hash in the height-indexed MMR. state .context @@ -276,11 +286,6 @@ where // Store auxiliary data for prover consumption. state.context.store_aux_data(block_id, &aux_data)?; - // Fork discovery before the commit point: if this block enacted an ASM VK - // upgrade a trigger maps to, persist the activation now so a committed - // anchor can never lack the activation it enacted. - state.discover_fork_activations(block_id, asm_stf_out.manifest.logs())?; - // Anchor state last: it is the block's commit point (see fn docs), so a // crash before it leaves the block uncommitted to be safely re-run. The // STF's logs are already persisted in the manifest recorded above. @@ -309,9 +314,15 @@ mod tests { use std::thread; use bitcoind_async_client::traits::Reader; - use strata_asm_common::{AsmManifestHash, AuxRequestCollector, StfParams}; + use strata_asm_common::{ + AsmLogEntry, AsmManifestHash, AuxRequestCollector, MsgRelayer, NullMsg, ProcessMsgsCtx, + ProcessTxsCtx, SectionState, StfParams, Subprotocol, SubprotocolId, TxInputRef, + }; + use strata_asm_logs::AsmStfUpdate; use strata_btc_types::L1BlockIdBitcoinExt; + use strata_btc_verification::L1Anchor; use strata_identifiers::{Buf32, L1BlockId}; + use strata_predicate::PredicateKey; use strata_service::CommandCompletionSender; use tokio::{sync::oneshot, task::block_in_place}; @@ -321,9 +332,67 @@ mod tests { test_utils::{ TestAsmWorkerContext, fixtures::{self, TestAsmSpec}, + get_l1_anchor, }, }; + const UNSUPPORTED_FORK_ID: u16 = 0xBEEF; + const EMIT_UPDATE_SUBPROTO_ID: SubprotocolId = 253; + + #[derive(Debug)] + struct UnsupportedForkLogSubproto; + + impl Subprotocol for UnsupportedForkLogSubproto { + const ID: SubprotocolId = EMIT_UPDATE_SUBPROTO_ID; + + type InitConfig = (); + type State = u8; + type Msg = NullMsg; + + fn init(_config: &Self::InitConfig) -> Self::State { + 0 + } + + fn process_txs( + _state: &mut Self::State, + _txs: &[TxInputRef<'_>], + relayer: &mut impl MsgRelayer, + _ctx: &ProcessTxsCtx<'_>, + ) { + let log = AsmLogEntry::from_log(&AsmStfUpdate::new( + PredicateKey::always_accept(), + UNSUPPORTED_FORK_ID, + )) + .expect("AsmStfUpdate encoding is infallible"); + relayer.emit_log(log); + } + + fn process_msgs(_state: &mut Self::State, _msgs: &[Self::Msg], _ctx: &ProcessMsgsCtx<'_>) {} + } + + #[derive(Debug)] + struct UnsupportedForkLogSpec; + + impl AsmSpec for UnsupportedForkLogSpec { + type Subprotocols = (UnsupportedForkLogSubproto,); + type Params = L1Anchor; + + fn construct_genesis_state(anchor: &L1Anchor) -> AnchorState { + let mut state = fixtures::genesis_state_from_anchor(anchor.clone()); + state.sections = vec![ + SectionState::from_state::(&0) + .expect("test section fits"), + ] + .try_into() + .expect("single test section fits"); + state + } + + fn stf_params(_params: &L1Anchor) -> StfParams { + StfParams::default() + } + } + /// Leaf count of the accumulator carried by the current in-memory anchor — /// the snapshot size [`AsmWorkerServiceState::transition`] resolves aux data /// against. @@ -472,6 +541,66 @@ mod tests { assert_eq!(fx.state.context.mmr_leaf_count(), 105); } + /// A block that enacts an ASM VK update for a fork unknown to this binary + /// is not committed. Retrying after a restart will target the same block + /// again, so no unsupported marker has to be persisted. + #[tokio::test(flavor = "multi_thread")] + async fn sync_rejects_block_with_unsupported_fork_update() { + let fx = fixtures::setup_context(101).await; + let tip = fx.client.get_block_hash(101).await.unwrap(); + let anchor = get_l1_anchor(&fx.client, &tip) + .await + .expect("genesis anchor"); + let genesis = UnsupportedForkLogSpec::construct_genesis_state(&anchor); + let mut state = AsmWorkerServiceState::<_, UnsupportedForkLogSpec>::new( + fx.context.clone(), + genesis, + StfParams::default(), + Subscribers::default(), + ) + .expect("create service state"); + let stuck = state.blkid; + let target = fixtures::mine(&fx._node, &fx.client, 1).await[0]; // 102 + + let err = sync_to_block(&mut state, target.blkid()) + .expect_err("sync should reject the unsupported fork update block"); + + assert!( + matches!( + &err, + WorkerError::UnsupportedForkActivation { + fork_id: UNSUPPORTED_FORK_ID, + block_height: 102, + stuck_height: 101, + } + ), + "expected unsupported fork activation error, got {err:?}", + ); + assert!( + err.to_string() + .contains("worker remains stuck at height 101"), + "error should name the stuck height: {err}", + ); + assert!( + err.to_string() + .contains("load an image that supports the fork"), + "error should tell operators how to proceed: {err}", + ); + assert_eq!(state.blkid, stuck, "in-memory anchor stays stuck"); + assert!( + matches!( + state.context.get_anchor_state(&target), + Err(WorkerError::MissingAsmState(_)) + ), + "rejected block must not get an anchor state", + ); + assert_eq!( + state.context.mmr_leaf_count(), + 102, + "no manifest leaf is written for the rejected block", + ); + } + /// Re-submitting an already-processed block — a duplicate or lagging ZMQ /// notification for an ancestor of the current tip — is a no-op: it processes /// nothing, leaves the in-memory tip where it was, and writes nothing. diff --git a/crates/worker/src/state.rs b/crates/worker/src/state.rs index bbf5f38e..01708859 100644 --- a/crates/worker/src/state.rs +++ b/crates/worker/src/state.rs @@ -200,8 +200,12 @@ where self.blkid = blkid; } - /// Scans a processed block's logs for enacted ASM VK upgrades and activates - /// the fork each one names, from the next block on. + /// Scans a processed block's logs for enacted ASM VK upgrades and + /// activates the fork each known update names, from the next block on. + /// + /// If an update names a fork id this binary does not know, the block must + /// not be committed: the worker cannot safely follow the chain until it is + /// restarted with an image that supports that fork. /// /// MUST run before the enacting block's anchor state is committed: the /// activation record is persisted here, and persisting it first guarantees @@ -218,18 +222,18 @@ where { let raw_id = update.fork_id(); let Ok(fork) = ForkId::try_from(raw_id) else { - // A fork id this binary has no variant for: it cannot apply - // the fork's rules, so it cannot meaningfully activate it - // either. Once the unknown fork activates, this worker can no - // longer follow the chain. - // TODO: consider halting the worker at the activation height - // instead of diverging silently. + let stuck_height = block_id.height().saturating_sub(1); tracing::error!( fork_id = raw_id, %block_id, - "ASM VK upgrade names a fork id unknown to this binary" + stuck_height, + "ASM VK upgrade activates a fork id unknown to this binary; refusing to commit block" ); - continue; + return Err(WorkerError::UnsupportedForkActivation { + fork_id: raw_id, + block_height: block_id.height(), + stuck_height, + }); }; let enacting_height = block_id.height(); if self.fork_schedule.is_active(fork, enacting_height as u64) { @@ -647,16 +651,28 @@ mod tests { assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); } - /// An upgrade naming a fork id this binary has no variant for must - /// not activate anything. + /// An upgrade naming a fork id this binary has no variant for prevents + /// the enacting block from being committed at all. #[tokio::test(flavor = "multi_thread")] - async fn discover_ignores_unknown_fork_id() { + async fn discover_rejects_unknown_fork_id() { let mut fx = fixtures::setup_state(101).await; - fx.state + let err = fx + .state .discover_fork_activations(&block_at(150), &[upgrade_log(0xBEEF)]) - .unwrap(); - + .unwrap_err(); + + assert!( + matches!( + err, + WorkerError::UnsupportedForkActivation { + fork_id: 0xBEEF, + block_height: 150, + stuck_height: 149, + } + ), + "expected unsupported fork activation error, got {err:?}", + ); assert_eq!(fx.state.fork_schedule, ForkSchedule::all_disabled()); assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); } From aaabec4a5d57c6adbda515e9b5679adea07bde83 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Wed, 8 Jul 2026 22:19:11 +0545 Subject: [PATCH 8/9] refactor(worker): split fork discovery from activation persistence 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. --- crates/worker/src/service.rs | 3 +- crates/worker/src/state.rs | 95 +++++++++++++++++++++++------------- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/crates/worker/src/service.rs b/crates/worker/src/service.rs index 5fc9ebd4..a591cc52 100644 --- a/crates/worker/src/service.rs +++ b/crates/worker/src/service.rs @@ -277,7 +277,8 @@ where // an ASM VK upgrade this binary cannot map to a known fork, the block is // not committed at all. For supported upgrades, persist the activation now // so a committed anchor can never lack the activation it enacted. - state.discover_fork_activations(block_id, asm_stf_out.manifest.logs())?; + let activations = state.discover_fork_activations(block_id, asm_stf_out.manifest.logs())?; + state.apply_fork_activations(activations)?; // Persist the manifest and record its hash in the height-indexed MMR. state diff --git a/crates/worker/src/state.rs b/crates/worker/src/state.rs index 01708859..3e5f83e2 100644 --- a/crates/worker/src/state.rs +++ b/crates/worker/src/state.rs @@ -200,22 +200,19 @@ where self.blkid = blkid; } - /// Scans a processed block's logs for enacted ASM VK upgrades and - /// activates the fork each known update names, from the next block on. + /// Scans a processed block's logs for enacted ASM VK upgrades and returns + /// the fork activation each known update names, without touching any + /// state; [`Self::apply_fork_activations`] enacts them. /// /// If an update names a fork id this binary does not know, the block must /// not be committed: the worker cannot safely follow the chain until it is /// restarted with an image that supports that fork. - /// - /// MUST run before the enacting block's anchor state is committed: the - /// activation record is persisted here, and persisting it first guarantees - /// a committed anchor never lacks the activation it enacted (crash-replay - /// simply rewrites the same record). pub(crate) fn discover_fork_activations( - &mut self, + &self, block_id: &L1BlockCommitment, logs: &[AsmLogEntry], - ) -> WorkerResult<()> { + ) -> WorkerResult> { + let mut activations = Vec::new(); for update in logs .iter() .filter_map(|l| l.try_into_log::().ok()) @@ -239,9 +236,8 @@ where if self.fork_schedule.is_active(fork, enacting_height as u64) { // Enacted updates are expected to name the fork they newly // activate; one naming an already-active fork is an - // operational flaw on the authoring side. Leave the schedule - // untouched so a flawed update cannot retro-raise an - // activation height. + // operational flaw on the authoring side. Drop it so a flawed + // update cannot retro-raise an activation height. tracing::warn!( ?fork, enacting_height, @@ -250,18 +246,36 @@ where continue; } - let activation = ForkActivation { + activations.push(ForkActivation { enacting_height, fork, new_predicate: update.into_new_predicate(), - }; + }); + } + Ok(activations) + } + + /// Persists each discovered activation and applies it to the in-memory + /// effective schedule. + /// + /// MUST run before the enacting block's anchor state is committed: + /// persisting the activation first guarantees a committed anchor never + /// lacks the activation it enacted (crash-replay simply rewrites the same + /// record). + pub(crate) fn apply_fork_activations( + &mut self, + activations: Vec, + ) -> WorkerResult<()> { + for activation in activations { + let fork = activation.fork; + let enacting_height = activation.enacting_height; let activation_height = activation.activation_height(); self.context.record_fork_activation(activation)?; self.fork_schedule.activate_at(fork, activation_height); tracing::info!( ?fork, activation_height, - %block_id, + enacting_height, "fork activated by ASM VK upgrade" ); } @@ -614,26 +628,31 @@ mod tests { L1BlockCommitment::new(height, Buf32::new([0xEE; 32]).into()) } - /// An upgrade log activates the fork it names at H+1, in memory and - /// on disk. + /// An upgrade log yields the activation of the fork it names; applying + /// it activates the fork at H+1, in memory and on disk. Discovery + /// alone touches nothing. #[tokio::test(flavor = "multi_thread")] - async fn discover_activates_named_fork() { + async fn discover_then_apply_activates_named_fork() { let mut fx = fixtures::setup_state(101).await; - fx.state + let discovered = fx + .state .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) .unwrap(); + let expected = vec![ForkActivation { + enacting_height: 150, + fork: ForkId::Fork1, + new_predicate: upgrade_predicate(), + }]; + assert_eq!(discovered, expected); + assert_eq!(fx.state.fork_schedule, ForkSchedule::all_disabled()); + assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); + + fx.state.apply_fork_activations(discovered).unwrap(); + assert_eq!(fx.state.fork_schedule.activation_height(ForkId::Fork1), 151); - let stored = fx.state.context.list_fork_activations().unwrap(); - assert_eq!( - stored, - vec![ForkActivation { - enacting_height: 150, - fork: ForkId::Fork1, - new_predicate: upgrade_predicate(), - }], - ); + assert_eq!(fx.state.context.list_fork_activations().unwrap(), expected); } /// An upgrade naming a fork already active at the enacting height is @@ -643,10 +662,12 @@ mod tests { let mut fx = fixtures::setup_state(101).await; fx.state.fork_schedule.activate_at(ForkId::Fork1, 10); - fx.state + let activations = fx + .state .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) .unwrap(); + assert!(activations.is_empty()); assert_eq!(fx.state.fork_schedule.activation_height(ForkId::Fork1), 10); assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); } @@ -655,7 +676,7 @@ mod tests { /// the enacting block from being committed at all. #[tokio::test(flavor = "multi_thread")] async fn discover_rejects_unknown_fork_id() { - let mut fx = fixtures::setup_state(101).await; + let fx = fixtures::setup_state(101).await; let err = fx .state @@ -711,9 +732,13 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn rollback_prunes_and_recomputes() { let mut fx = fixtures::setup_state(101).await; - fx.state - .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + let logs = [upgrade_log(ForkId::Fork1.into())]; + + let activations = fx + .state + .discover_fork_activations(&block_at(150), &logs) .unwrap(); + fx.state.apply_fork_activations(activations).unwrap(); assert!(fx.state.fork_schedule.is_active(ForkId::Fork1, 151)); // Reorg to a base below the enacting block: back to the base schedule. @@ -726,9 +751,11 @@ mod tests { assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); // A rollback at or above the enacting height keeps the activation. - fx.state - .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + let activations = fx + .state + .discover_fork_activations(&block_at(150), &logs) .unwrap(); + fx.state.apply_fork_activations(activations).unwrap(); fx.state.rollback_fork_activations(150).unwrap(); assert!(fx.state.fork_schedule.is_active(ForkId::Fork1, 151)); } From 2538ba5a2834bfe64f3c4a31a76468a2e06ae8a4 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Wed, 8 Jul 2026 22:20:05 +0545 Subject: [PATCH 9/9] fix(worker): reject upgrades naming already-active forks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/worker/src/errors.rs | 20 ++++ crates/worker/src/service.rs | 17 ++-- crates/worker/src/state.rs | 175 +++++++++++++++++++++++++---------- 3 files changed, 156 insertions(+), 56 deletions(-) diff --git a/crates/worker/src/errors.rs b/crates/worker/src/errors.rs index 04054e63..b5accfec 100644 --- a/crates/worker/src/errors.rs +++ b/crates/worker/src/errors.rs @@ -1,4 +1,5 @@ use bitcoin::Network; +use strata_asm_common::ForkId; use strata_btc_types::BitcoinTxid; use strata_identifiers::{L1BlockCommitment, L1BlockId}; use thiserror::Error; @@ -75,6 +76,25 @@ pub enum WorkerError { stuck_height: u32, }, + /// An enacted ASM VK upgrade named a fork that is already active. + /// + /// Enacted updates are expected to name the fork they newly activate, so + /// this is a flawed upgrade on the authoring side, and there is no safe + /// reading of it: applying it would retro-raise the fork's activation + /// height (reinterpreting blocks processed under the old boundary), while + /// ignoring it would let the worker's schedule silently diverge from the + /// predicate the chain just enacted. The worker refuses to commit the + /// enacting block instead of guessing. + #[error( + "cannot process L1 block at height {block_height}: ASM VK upgrade names fork {fork:?} already active since height {active_since}; worker remains stuck at height {stuck_height}; the enacted upgrade is flawed and needs operator intervention" + )] + RedundantForkActivation { + fork: ForkId, + active_since: u64, + block_height: u32, + stuck_height: u32, + }, + /// A Bitcoin RPC call failed after exhausting its retry budget. The /// payload carries the underlying error's display so the operator sees /// the actual cause (block not found, timeout, connection refused, auth, diff --git a/crates/worker/src/service.rs b/crates/worker/src/service.rs index a591cc52..a9b36824 100644 --- a/crates/worker/src/service.rs +++ b/crates/worker/src/service.rs @@ -249,10 +249,12 @@ fn plan_block_processing( /// before advancing the in-memory anchor. /// /// Fork activation discovery happens before any per-block persistence. If a -/// block enacts a fork this worker does not support, the worker returns a -/// specific error and leaves the block entirely uncommitted: no manifest leaf, -/// aux data, or anchor state is written. Otherwise, the anchor state remains -/// the block's commit point: [`plan_block_processing`] treats a block as +/// block's ASM VK upgrade does not map to a valid new activation — it names a +/// fork id unknown to this binary, or a fork that is already active — the +/// worker returns a specific error and leaves the block entirely uncommitted: +/// no manifest leaf, aux data, or anchor state is written. Otherwise, the +/// anchor state remains the block's commit point: [`plan_block_processing`] +/// treats a block as /// processed only once its anchor state is stored, so it is written after /// everything derived from the block. If an error aborts after the manifest or /// aux data write but before the anchor state, the block stays uncommitted and @@ -274,9 +276,10 @@ where let (asm_stf_out, aux_data) = state.transition(&block)?; // Fork discovery before any per-block persistence: if this block enacted - // an ASM VK upgrade this binary cannot map to a known fork, the block is - // not committed at all. For supported upgrades, persist the activation now - // so a committed anchor can never lack the activation it enacted. + // an ASM VK upgrade the worker cannot accept (an unknown fork id, or a + // fork that is already active), the block is not committed at all. For + // valid upgrades, persist the activation now so a committed anchor can + // never lack the activation it enacted. let activations = state.discover_fork_activations(block_id, asm_stf_out.manifest.logs())?; state.apply_fork_activations(activations)?; diff --git a/crates/worker/src/state.rs b/crates/worker/src/state.rs index 3e5f83e2..5e41e8f0 100644 --- a/crates/worker/src/state.rs +++ b/crates/worker/src/state.rs @@ -201,58 +201,67 @@ where } /// Scans a processed block's logs for enacted ASM VK upgrades and returns - /// the fork activation each known update names, without touching any - /// state; [`Self::apply_fork_activations`] enacts them. + /// the fork activation each update names, without touching any state; + /// [`Self::apply_fork_activations`] enacts them. /// - /// If an update names a fork id this binary does not know, the block must - /// not be committed: the worker cannot safely follow the chain until it is - /// restarted with an image that supports that fork. + /// An error means the block must not be committed: + /// + /// - An update names a fork id this binary does not know + /// ([`WorkerError::UnsupportedForkActivation`]): the worker cannot safely follow the chain + /// until it is restarted with an image that supports that fork. + /// - An update names a fork that is already active, including one activated by an earlier + /// update in the same block ([`WorkerError::RedundantForkActivation`]): such an update has no + /// safe reading (see the variant docs), so the worker halts instead of guessing. + /// + /// Collects into a `Vec` deliberately: every update must validate before + /// [`Self::apply_fork_activations`] persists anything, so a flawed update + /// cannot leave a prefix of the block's activations on disk. pub(crate) fn discover_fork_activations( &self, block_id: &L1BlockCommitment, logs: &[AsmLogEntry], ) -> WorkerResult> { - let mut activations = Vec::new(); - for update in logs - .iter() + let enacting_height = block_id.height(); + let stuck_height = enacting_height.saturating_sub(1); + // In-block duplicates need their own tracker: an activation from + // earlier in this block takes effect at H+1, which the schedule check + // at H cannot see — and checking at H+1 instead would false-fire on a + // crash-replay, where the resumed schedule legitimately holds H+1 for + // this very block. + let mut seen: Vec = Vec::new(); + logs.iter() .filter_map(|l| l.try_into_log::().ok()) - { - let raw_id = update.fork_id(); - let Ok(fork) = ForkId::try_from(raw_id) else { - let stuck_height = block_id.height().saturating_sub(1); - tracing::error!( - fork_id = raw_id, - %block_id, - stuck_height, - "ASM VK upgrade activates a fork id unknown to this binary; refusing to commit block" - ); - return Err(WorkerError::UnsupportedForkActivation { - fork_id: raw_id, - block_height: block_id.height(), - stuck_height, - }); - }; - let enacting_height = block_id.height(); - if self.fork_schedule.is_active(fork, enacting_height as u64) { - // Enacted updates are expected to name the fork they newly - // activate; one naming an already-active fork is an - // operational flaw on the authoring side. Drop it so a flawed - // update cannot retro-raise an activation height. - tracing::warn!( - ?fork, + .map(|update| { + let fork = ForkId::try_from(update.fork_id()).map_err(|fork_id| { + WorkerError::UnsupportedForkActivation { + fork_id, + block_height: enacting_height, + stuck_height, + } + })?; + let named_earlier = seen.contains(&fork); + if named_earlier || self.fork_schedule.is_active(fork, enacting_height as u64) { + let active_since = if named_earlier { + // What the earlier update in this block activates. + enacting_height as u64 + 1 + } else { + self.fork_schedule.activation_height(fork) + }; + return Err(WorkerError::RedundantForkActivation { + fork, + active_since, + block_height: enacting_height, + stuck_height, + }); + } + seen.push(fork); + Ok(ForkActivation { enacting_height, - "ASM VK upgrade names an already-active fork; skipping" - ); - continue; - } - - activations.push(ForkActivation { - enacting_height, - fork, - new_predicate: update.into_new_predicate(), - }); - } - Ok(activations) + fork, + new_predicate: update.into_new_predicate(), + }) + }) + .collect() } /// Persists each discovered activation and applies it to the in-memory @@ -656,22 +665,63 @@ mod tests { } /// An upgrade naming a fork already active at the enacting height is - /// an authoring flaw: it must not retro-raise the activation. + /// a flawed upgrade with no safe reading: the block must not be + /// committed, and the activation must not be retro-raised. #[tokio::test(flavor = "multi_thread")] - async fn discover_ignores_already_active_fork() { + async fn discover_rejects_already_active_fork() { let mut fx = fixtures::setup_state(101).await; fx.state.fork_schedule.activate_at(ForkId::Fork1, 10); - let activations = fx + let err = fx .state .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) - .unwrap(); + .unwrap_err(); - assert!(activations.is_empty()); + assert!( + matches!( + err, + WorkerError::RedundantForkActivation { + fork: ForkId::Fork1, + active_since: 10, + block_height: 150, + stuck_height: 149, + } + ), + "expected redundant fork activation error, got {err:?}", + ); assert_eq!(fx.state.fork_schedule.activation_height(ForkId::Fork1), 10); assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); } + /// Two updates in one block naming the same fork are the same flaw: + /// the second names a fork the first already activates. + #[tokio::test(flavor = "multi_thread")] + async fn discover_rejects_duplicate_fork_in_one_block() { + let fx = fixtures::setup_state(101).await; + + let logs = [ + upgrade_log(ForkId::Fork1.into()), + upgrade_log(ForkId::Fork1.into()), + ]; + let err = fx + .state + .discover_fork_activations(&block_at(150), &logs) + .unwrap_err(); + + assert!( + matches!( + err, + WorkerError::RedundantForkActivation { + fork: ForkId::Fork1, + active_since: 151, + block_height: 150, + stuck_height: 149, + } + ), + "expected redundant fork activation error, got {err:?}", + ); + } + /// An upgrade naming a fork id this binary has no variant for prevents /// the enacting block from being committed at all. #[tokio::test(flavor = "multi_thread")] @@ -698,6 +748,33 @@ mod tests { assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); } + /// A flawed update anywhere in the block's logs fails discovery as a + /// whole: the valid earlier update must not slip through, so nothing + /// is ever persisted for a block the worker refuses to commit. + #[tokio::test(flavor = "multi_thread")] + async fn discover_rejects_block_with_valid_and_flawed_updates() { + let fx = fixtures::setup_state(101).await; + let logs = [upgrade_log(ForkId::Fork1.into()), upgrade_log(0xBEEF)]; + + let err = fx + .state + .discover_fork_activations(&block_at(150), &logs) + .unwrap_err(); + + assert!( + matches!( + err, + WorkerError::UnsupportedForkActivation { + fork_id: 0xBEEF, + .. + } + ), + "expected unsupported fork activation error, got {err:?}", + ); + assert_eq!(fx.state.fork_schedule, ForkSchedule::all_disabled()); + assert!(fx.state.context.list_fork_activations().unwrap().is_empty()); + } + /// A restart resumes the effective schedule from persisted activations. #[tokio::test(flavor = "multi_thread")] async fn new_resumes_persisted_activations() {