diff --git a/Cargo.lock b/Cargo.lock index 3e2548c0..0795a81f 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", @@ -8162,6 +8164,7 @@ dependencies = [ "futures", "serde", "strata-asm-common", + "strata-asm-logs", "strata-asm-stf", "strata-btc-types", "strata-btc-verification", @@ -8169,6 +8172,7 @@ dependencies = [ "strata-l1-txfmt", "strata-merkle", "strata-merkle-node-store", + "strata-predicate", "strata-service", "strata-tasks", "strata-test-utils-btcio", @@ -8416,6 +8420,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/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/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 0f4e26b7..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 @@ -79,6 +109,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 +155,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::*; @@ -181,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/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/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/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/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/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/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..ffacb130 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, @@ -182,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] @@ -212,8 +238,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 +286,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 +320,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 +352,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"); @@ -316,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() { @@ -332,7 +481,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 +502,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/crates/worker/Cargo.toml b/crates/worker/Cargo.toml index b642d934..df3efc22 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 @@ -35,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/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/errors.rs b/crates/worker/src/errors.rs index f8e93b4f..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; @@ -66,6 +67,34 @@ 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, + }, + + /// 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/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/service.rs b/crates/worker/src/service.rs index 82386038..a9b36824 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. @@ -236,19 +243,25 @@ 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'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 +/// 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, @@ -262,6 +275,14 @@ 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 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)?; + // Persist the manifest and record its hash in the height-indexed MMR. state .context @@ -297,9 +318,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}; @@ -309,9 +336,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. @@ -460,6 +545,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 fbfb189f..5e41e8f0 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,119 @@ where self.anchor = anchor; self.blkid = blkid; } + + /// Scans a processed block's logs for enacted ASM VK upgrades and returns + /// the fork activation each update names, without touching any state; + /// [`Self::apply_fork_activations`] enacts them. + /// + /// 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 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()) + .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, + fork, + new_predicate: update.into_new_predicate(), + }) + }) + .collect() + } + + /// 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, + enacting_height, + "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 +426,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 @@ -472,4 +612,229 @@ 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 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_then_apply_activates_named_fork() { + let mut fx = fixtures::setup_state(101).await; + + 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); + assert_eq!(fx.state.context.list_fork_activations().unwrap(), expected); + } + + /// An upgrade naming a fork already active at the enacting height is + /// 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_rejects_already_active_fork() { + let mut fx = fixtures::setup_state(101).await; + fx.state.fork_schedule.activate_at(ForkId::Fork1, 10); + + let err = fx + .state + .discover_fork_activations(&block_at(150), &[upgrade_log(ForkId::Fork1.into())]) + .unwrap_err(); + + 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")] + async fn discover_rejects_unknown_fork_id() { + let fx = fixtures::setup_state(101).await; + + let err = fx + .state + .discover_fork_activations(&block_at(150), &[upgrade_log(0xBEEF)]) + .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()); + } + + /// 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() { + 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; + 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. + 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. + 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)); + } + } } 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 { } 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", 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/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/admin_to_stf.rs b/tests/asm/admin_to_stf.rs index e1af915a..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(); @@ -157,12 +163,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(), 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/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. 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