From b61a874f6828386c362d8935d89cb5b9d9d00b15 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 12:05:11 +0545 Subject: [PATCH 01/10] feat(moho): add subscription-driven Moho worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Materializes per-block MohoState from the ASM worker's commit stream as a deterministic forward-only fold: for each committed block it reads the anchor state and logs the ASM worker already persisted and derives the Moho state from them, persisting through a caller-supplied context. The context splits reads from writes — AsmStateProvider fetches the anchor state and its logs (committed atomically per block, hence the shared MissingAsmState miss), MohoStateStore loads and persists the derived state — mirroring how strata-asm-worker takes a WorkerContext. --- Cargo.lock | 22 ++ Cargo.toml | 4 + crates/extensions/moho/worker/Cargo.toml | 29 ++ crates/extensions/moho/worker/src/builder.rs | 107 ++++++ crates/extensions/moho/worker/src/compute.rs | 42 +++ .../extensions/moho/worker/src/constants.rs | 4 + crates/extensions/moho/worker/src/errors.rs | 31 ++ crates/extensions/moho/worker/src/handle.rs | 25 ++ crates/extensions/moho/worker/src/lib.rs | 32 ++ crates/extensions/moho/worker/src/service.rs | 62 ++++ crates/extensions/moho/worker/src/state.rs | 306 ++++++++++++++++++ crates/extensions/moho/worker/src/traits.rs | 60 ++++ 12 files changed, 724 insertions(+) create mode 100644 crates/extensions/moho/worker/Cargo.toml create mode 100644 crates/extensions/moho/worker/src/builder.rs create mode 100644 crates/extensions/moho/worker/src/compute.rs create mode 100644 crates/extensions/moho/worker/src/constants.rs create mode 100644 crates/extensions/moho/worker/src/errors.rs create mode 100644 crates/extensions/moho/worker/src/handle.rs create mode 100644 crates/extensions/moho/worker/src/lib.rs create mode 100644 crates/extensions/moho/worker/src/service.rs create mode 100644 crates/extensions/moho/worker/src/state.rs create mode 100644 crates/extensions/moho/worker/src/traits.rs diff --git a/Cargo.lock b/Cargo.lock index dd4522fd..cb39d75c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7707,6 +7707,28 @@ dependencies = [ "tree_hash_derive", ] +[[package]] +name = "strata-asm-moho-worker" +version = "0.1.0" +dependencies = [ + "anyhow", + "moho-runtime-interface", + "moho-types", + "serde", + "strata-asm-common", + "strata-asm-params", + "strata-asm-proof-impl", + "strata-asm-spec", + "strata-asm-worker", + "strata-identifiers", + "strata-predicate", + "strata-service", + "strata-tasks", + "strata-test-utils-arb", + "thiserror 2.0.18", + "tracing", +] + [[package]] name = "strata-asm-params" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index fbbe00c0..fe4cfe53 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,9 @@ members = [ "crates/worker", "crates/storage", + # extensions + "crates/extensions/moho/worker", + # tests "tests", @@ -61,6 +64,7 @@ strata-ams-test-utils = { path = "crates/test-utils-btcio" } strata-asm-common = { path = "crates/common" } strata-asm-logs = { path = "crates/logs" } strata-asm-manifest-types = { path = "crates/manifest-types" } +strata-asm-moho-worker = { path = "crates/extensions/moho/worker" } strata-asm-params = { path = "crates/params" } strata-asm-proof-db = { path = "crates/proof/db" } strata-asm-proof-impl = { path = "crates/proof/statements" } diff --git a/crates/extensions/moho/worker/Cargo.toml b/crates/extensions/moho/worker/Cargo.toml new file mode 100644 index 00000000..c0ccedb2 --- /dev/null +++ b/crates/extensions/moho/worker/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "strata-asm-moho-worker" +version = "0.1.0" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +strata-asm-common.workspace = true +strata-asm-proof-impl.workspace = true +strata-asm-worker.workspace = true +strata-identifiers.workspace = true +strata-predicate.workspace = true +strata-service.workspace = true +strata-tasks.workspace = true + +moho-runtime-interface.workspace = true +moho-types.workspace = true + +anyhow.workspace = true +serde.workspace = true +thiserror.workspace = true +tracing.workspace = true + +[dev-dependencies] +strata-asm-params = { workspace = true, features = ["arbitrary"] } +strata-asm-spec.workspace = true +strata-test-utils-arb.workspace = true diff --git a/crates/extensions/moho/worker/src/builder.rs b/crates/extensions/moho/worker/src/builder.rs new file mode 100644 index 00000000..1ea3078f --- /dev/null +++ b/crates/extensions/moho/worker/src/builder.rs @@ -0,0 +1,107 @@ +//! Builder for constructing and launching the Moho worker service. + +use strata_asm_worker::Subscription; +use strata_identifiers::L1BlockCommitment; +use strata_predicate::PredicateKey; +use strata_service::{ServiceBuilder, StreamInput}; +use strata_tasks::TaskExecutor; + +use crate::{ + MohoWorkerContext, MohoWorkerHandle, constants, errors::MohoWorkerError, + service::MohoWorkerService, state::MohoWorkerServiceState, +}; + +/// Builder for launching a Moho worker driven by the ASM worker's per-block +/// subscription. +/// +/// Wire it with the storage context, the subscription handed out by +/// [`AsmWorkerHandle::subscribe_blocks`](strata_asm_worker::AsmWorkerHandle::subscribe_blocks), +/// the genesis block, and the ASM predicate that seeds the genesis Moho state. +/// +/// Subscribe *before* the ASM worker begins committing blocks: the subscription +/// has no replay, so the worker must be wired in while the stream still starts +/// at the genesis successor. +#[derive(Debug)] +pub struct MohoWorkerBuilder { + context: Option, + subscription: Option>, + genesis_block: Option, + asm_predicate: Option, +} + +impl MohoWorkerBuilder { + /// Create a new builder instance. + pub fn new() -> Self { + Self { + context: None, + subscription: None, + genesis_block: None, + asm_predicate: None, + } + } + + /// Set the storage context (implements [`MohoWorkerContext`]). + pub fn with_context(mut self, context: W) -> Self { + self.context = Some(context); + self + } + + /// Set the ASM commit subscription driving the worker. + pub fn with_subscription(mut self, subscription: Subscription) -> Self { + self.subscription = Some(subscription); + self + } + + /// Set the genesis block whose ASM anchor state seeds the genesis Moho state. + pub fn with_genesis_block(mut self, genesis_block: L1BlockCommitment) -> Self { + self.genesis_block = Some(genesis_block); + self + } + + /// Set the ASM predicate carried by the genesis Moho state. + pub fn with_asm_predicate(mut self, asm_predicate: PredicateKey) -> Self { + self.asm_predicate = Some(asm_predicate); + self + } + + /// Launch the Moho worker service and return a handle to it. + /// + /// Validates dependencies, seeds or resumes the service state, adapts the + /// subscription into a stream input, and spawns the async worker. + pub async fn launch(self, executor: &TaskExecutor) -> anyhow::Result + where + W: MohoWorkerContext + Send + Sync + 'static, + { + let context = self + .context + .ok_or(MohoWorkerError::MissingDependency("context"))?; + let subscription = self + .subscription + .ok_or(MohoWorkerError::MissingDependency("subscription"))?; + let genesis_block = self + .genesis_block + .ok_or(MohoWorkerError::MissingDependency("genesis_block"))?; + let asm_predicate = self + .asm_predicate + .ok_or(MohoWorkerError::MissingDependency("asm_predicate"))?; + + // Seed or resume synchronously before launch, mirroring the ASM worker: + // the genesis Moho state must exist before the first commit is folded. + let state = MohoWorkerServiceState::new(context, genesis_block, asm_predicate)?; + + let input = StreamInput::new(subscription); + let monitor = ServiceBuilder::, _>::new() + .with_state(state) + .with_input(input) + .launch_async(constants::SERVICE_NAME, executor) + .await?; + + Ok(MohoWorkerHandle::new(monitor)) + } +} + +impl Default for MohoWorkerBuilder { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/extensions/moho/worker/src/compute.rs b/crates/extensions/moho/worker/src/compute.rs new file mode 100644 index 00000000..653d1c82 --- /dev/null +++ b/crates/extensions/moho/worker/src/compute.rs @@ -0,0 +1,42 @@ +//! Derivation of [`MohoState`] from committed ASM anchor states. +//! +//! The Moho state is a thin projection of the ASM anchor state: the inner +//! commitment is the tree hash of the anchor state, while the predicate and +//! export state are advanced by replaying the STF logs the ASM worker recorded +//! for the block. Neither requires re-running the STF — everything needed lives +//! in the committed [`AnchorState`] and its [`AsmLogEntry`]s. + +use moho_runtime_interface::MohoProgram; +use moho_types::{ExportState, MohoState}; +use strata_asm_common::{AnchorState, AsmLogEntry}; +use strata_asm_proof_impl::moho_program::program::{ + AsmStfProgram, advance_export_state_with_logs, extract_next_predicate_from_logs, +}; +use strata_predicate::PredicateKey; + +/// Seeds the genesis [`MohoState`]: there is no prior state to chain forward +/// from, so we pair the genesis anchor commitment with the configured +/// `asm_predicate` and an empty export state. +pub(crate) fn construct_genesis_moho_state( + asm_predicate: PredicateKey, + genesis: &AnchorState, +) -> MohoState { + let inner = AsmStfProgram::compute_state_commitment(genesis); + let export_state = ExportState::new(vec![]).expect("empty export state is always valid"); + MohoState::new(inner, asm_predicate, export_state) +} + +/// Chains the [`MohoState`] forward from its parent: the STF logs drive the +/// predicate and export-state updates, and the inner commitment is recomputed +/// from the new anchor state. +pub(crate) fn construct_next_moho_state( + prev: &MohoState, + anchor_state: &AnchorState, + logs: &[AsmLogEntry], +) -> MohoState { + let next_predicate = + extract_next_predicate_from_logs(logs).unwrap_or_else(|| prev.next_predicate().clone()); + let next_export_state = advance_export_state_with_logs(prev.export_state().clone(), logs); + let inner = AsmStfProgram::compute_state_commitment(anchor_state); + MohoState::new(inner, next_predicate, next_export_state) +} diff --git a/crates/extensions/moho/worker/src/constants.rs b/crates/extensions/moho/worker/src/constants.rs new file mode 100644 index 00000000..3a8ef894 --- /dev/null +++ b/crates/extensions/moho/worker/src/constants.rs @@ -0,0 +1,4 @@ +//! Constants for the Moho worker. + +/// Service identifier for the Moho worker. +pub(crate) const SERVICE_NAME: &str = "moho_worker"; diff --git a/crates/extensions/moho/worker/src/errors.rs b/crates/extensions/moho/worker/src/errors.rs new file mode 100644 index 00000000..1c7ec264 --- /dev/null +++ b/crates/extensions/moho/worker/src/errors.rs @@ -0,0 +1,31 @@ +use strata_identifiers::L1BlockCommitment; +use thiserror::Error; + +/// Return type for Moho worker operations. +pub type MohoWorkerResult = Result; + +#[derive(Debug, Error)] +pub enum MohoWorkerError { + /// The ASM anchor state the Moho state derives from was not found. The ASM + /// worker commits the anchor state before emitting its block notification, + /// so a miss here means the ASM and Moho stores are out of sync. + #[error("missing ASM anchor state for block {0:?}")] + MissingAsmState(L1BlockCommitment), + + /// An incoming ASM commit skipped one or more heights relative to the + /// worker's running state. The worker is a forward-only fold over the commit + /// stream, so it cannot chain across a gap. + // TODO(STR-3124): backfill the gap by replaying the intervening anchor + // states instead of erroring out, once the worker resumes from its own + // store on restart. + #[error("non-contiguous ASM commit: expected height {expected}, got {got}")] + NonContiguousBlock { expected: u64, got: u64 }, + + /// The underlying Moho-state store failed. Carries the backend's display so + /// the operator sees the real cause without us bucketing it. + #[error("moho state store: {0}")] + Storage(String), + + #[error("missing required dependency: {0}")] + MissingDependency(&'static str), +} diff --git a/crates/extensions/moho/worker/src/handle.rs b/crates/extensions/moho/worker/src/handle.rs new file mode 100644 index 00000000..66da5bb8 --- /dev/null +++ b/crates/extensions/moho/worker/src/handle.rs @@ -0,0 +1,25 @@ +//! Handle for interacting with the Moho worker service. + +use strata_service::ServiceMonitor; + +use crate::MohoWorkerStatus; + +/// Handle for observing the Moho worker service. +/// +/// The worker is purely subscription-driven — it takes no commands — so the +/// handle only exposes status monitoring. +#[derive(Debug)] +pub struct MohoWorkerHandle { + monitor: ServiceMonitor, +} + +impl MohoWorkerHandle { + pub(crate) fn new(monitor: ServiceMonitor) -> Self { + Self { monitor } + } + + /// Allows other services to listen to status updates. + pub fn monitor(&self) -> &ServiceMonitor { + &self.monitor + } +} diff --git a/crates/extensions/moho/worker/src/lib.rs b/crates/extensions/moho/worker/src/lib.rs new file mode 100644 index 00000000..4cd37213 --- /dev/null +++ b/crates/extensions/moho/worker/src/lib.rs @@ -0,0 +1,32 @@ +//! # strata-asm-moho-worker +//! +//! A subscription-driven worker that materializes per-block +//! [`MohoState`](moho_types::MohoState) from the Strata ASM. +//! +//! The worker subscribes to the ASM worker's per-block commit stream +//! ([`Subscription`](strata_asm_worker::Subscription)) and, +//! for each committed block, derives the Moho state from the ASM anchor state +//! the ASM worker already persisted, then stores it. It runs no chain view of +//! its own: it is a deterministic forward-only fold over whatever block sequence +//! the ASM worker commits. +//! +//! Storage is supplied by the caller through [`MohoWorkerContext`] — read access +//! to ASM anchor states ([`AsmStateProvider`]) plus persistence for the derived +//! Moho states ([`MohoStateStore`]) — mirroring how `strata-asm-worker` takes a +//! [`WorkerContext`](strata_asm_worker::WorkerContext). + +mod builder; +mod compute; +mod constants; +mod errors; +mod handle; +mod service; +mod state; +mod traits; + +pub use builder::MohoWorkerBuilder; +pub use errors::{MohoWorkerError, MohoWorkerResult}; +pub use handle::MohoWorkerHandle; +pub use service::{MohoWorkerService, MohoWorkerStatus}; +pub use state::MohoWorkerServiceState; +pub use traits::{AsmStateProvider, MohoStateStore, MohoWorkerContext}; diff --git a/crates/extensions/moho/worker/src/service.rs b/crates/extensions/moho/worker/src/service.rs new file mode 100644 index 00000000..770c04a8 --- /dev/null +++ b/crates/extensions/moho/worker/src/service.rs @@ -0,0 +1,62 @@ +//! Service-framework integration for the Moho worker. +//! +//! The worker is an [`AsyncService`] driven by the ASM worker's per-block +//! subscription (a [`Subscription`](strata_asm_worker::Subscription) +//! adapted into a [`StreamInput`](strata_service::StreamInput)). Each emitted +//! commitment is folded into a new [`MohoState`](moho_types::MohoState) and +//! persisted. + +use std::marker::PhantomData; + +use serde::{Deserialize, Serialize}; +use strata_identifiers::L1BlockCommitment; +use strata_service::{AsyncService, Response, Service}; + +use crate::{MohoWorkerContext, MohoWorkerServiceState}; + +/// Moho worker service implementation using the service framework. +#[derive(Debug)] +pub struct MohoWorkerService { + _phantom: PhantomData, +} + +impl Service for MohoWorkerService +where + W: MohoWorkerContext + Send + Sync + 'static, +{ + type State = MohoWorkerServiceState; + type Msg = L1BlockCommitment; + type Status = MohoWorkerStatus; + + fn get_status(state: &Self::State) -> Self::Status { + MohoWorkerStatus { + is_initialized: true, + cur_block: Some(state.cur_block()), + processed: state.processed(), + } + } +} + +impl AsyncService for MohoWorkerService +where + W: MohoWorkerContext + Send + Sync + 'static, +{ + async fn process_input( + state: &mut Self::State, + input: L1BlockCommitment, + ) -> anyhow::Result { + // The store is synchronous (sled), so the fold runs to completion + // without yielding. A processing error exits the worker — the commit + // stream cannot be skipped without leaving a gap. + state.process(input)?; + Ok(Response::Continue) + } +} + +/// Status information for the Moho worker service. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct MohoWorkerStatus { + pub is_initialized: bool, + pub cur_block: Option, + pub processed: u64, +} diff --git a/crates/extensions/moho/worker/src/state.rs b/crates/extensions/moho/worker/src/state.rs new file mode 100644 index 00000000..01171bc3 --- /dev/null +++ b/crates/extensions/moho/worker/src/state.rs @@ -0,0 +1,306 @@ +//! Service state for the Moho worker. + +use moho_types::MohoState; +use strata_identifiers::L1BlockCommitment; +use strata_predicate::PredicateKey; +use strata_service::ServiceState; +use tracing::{info, warn}; + +use crate::{MohoWorkerContext, MohoWorkerError, MohoWorkerResult, compute, constants}; + +/// In-memory state for the Moho worker. +/// +/// The worker is a deterministic forward-only fold over the ASM commit stream: +/// it holds the most recently derived [`MohoState`] and the block it is anchored +/// to, and each incoming commitment chains forward from that. There is no chain +/// view of its own — whatever block sequence the ASM worker commits (and emits) +/// is the sequence the Moho worker folds. +#[derive(Debug)] +pub struct MohoWorkerServiceState { + /// Context for reading ASM anchor states and persisting Moho states. + pub(crate) context: W, + + /// The most recently derived (or genesis-seeded) Moho state. + cur_moho: MohoState, + + /// The L1 block `cur_moho` is anchored to. The next commitment must be its + /// immediate successor in height. + cur_block: L1BlockCommitment, + + /// Number of commits folded since launch (excludes the genesis seed). + processed: u64, +} + +impl MohoWorkerServiceState { + /// Creates the service state, resuming from the latest stored Moho state or + /// seeding the genesis entry when the store is empty. + /// + /// Genesis is seeded from the ASM anchor state already committed for + /// `genesis_block`; `asm_predicate` becomes the genesis Moho predicate. + pub(crate) fn new( + context: W, + genesis_block: L1BlockCommitment, + asm_predicate: PredicateKey, + ) -> MohoWorkerResult { + let (cur_block, cur_moho) = match context.get_latest_moho_state()? { + Some((blk, moho)) => { + info!(%blk, "resuming Moho worker from stored state"); + (blk, moho) + } + None => { + let genesis_anchor = context.get_anchor_state(&genesis_block)?; + let moho = compute::construct_genesis_moho_state(asm_predicate, &genesis_anchor); + context.store_moho_state(&genesis_block, &moho)?; + info!(%genesis_block, "seeded genesis Moho state"); + (genesis_block, moho) + } + }; + + Ok(Self { + context, + cur_moho, + cur_block, + processed: 0, + }) + } + + /// The block the worker has most recently committed a Moho state for. + pub fn cur_block(&self) -> L1BlockCommitment { + self.cur_block + } + + /// Number of ASM commits folded since launch. + pub fn processed(&self) -> u64 { + self.processed + } + + /// Folds a single ASM commit into a new [`MohoState`] and persists it. + /// + /// The commit must be the immediate height-successor of the current block. + /// A stale or duplicate commit (height `<=` current) is ignored; a gap + /// (height `>` current + 1) is rejected — the worker cannot chain across + /// anchor states it never saw. + pub(crate) fn process(&mut self, block: L1BlockCommitment) -> MohoWorkerResult<()> { + let cur_height = self.cur_block.height(); + let got = block.height(); + + if got <= cur_height { + warn!(%block, cur = %self.cur_block, "ignoring stale or duplicate ASM commit"); + return Ok(()); + } + + let expected = cur_height + 1; + if got != expected { + return Err(MohoWorkerError::NonContiguousBlock { + expected: u64::from(expected), + got: u64::from(got), + }); + } + + let anchor_state = self.context.get_anchor_state(&block)?; + let logs = self.context.get_anchor_logs(&block)?; + let moho = compute::construct_next_moho_state(&self.cur_moho, &anchor_state, &logs); + self.context.store_moho_state(&block, &moho)?; + + self.cur_moho = moho; + self.cur_block = block; + self.processed += 1; + + info!(%block, "committed Moho state"); + Ok(()) + } +} + +impl ServiceState for MohoWorkerServiceState { + fn name(&self) -> &str { + constants::SERVICE_NAME + } +} + +#[cfg(test)] +mod tests { + use std::{cell::RefCell, collections::HashMap}; + + use moho_runtime_interface::MohoProgram; + use strata_asm_common::{AnchorState, AsmLogEntry}; + use strata_asm_params::AsmParams; + use strata_asm_proof_impl::moho_program::program::AsmStfProgram; + use strata_asm_spec::construct_genesis_state; + use strata_identifiers::{L1BlockCommitment, L1BlockId}; + use strata_predicate::PredicateKey; + use strata_test_utils_arb::ArbitraryGenerator; + + use super::*; + use crate::{AsmStateProvider, MohoStateStore}; + + /// In-memory context backing both concern traits. + #[derive(Debug, Default)] + struct MockContext { + anchors: RefCell>, + logs: RefCell>>, + moho: RefCell>, + latest: RefCell>, + } + + impl MockContext { + fn insert_anchor(&self, blk: L1BlockCommitment, state: AnchorState) { + self.anchors.borrow_mut().insert(blk, state); + } + } + + impl AsmStateProvider for MockContext { + fn get_anchor_state(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult { + self.anchors + .borrow() + .get(blockid) + .cloned() + .ok_or(MohoWorkerError::MissingAsmState(*blockid)) + } + + fn get_anchor_logs( + &self, + blockid: &L1BlockCommitment, + ) -> MohoWorkerResult> { + Ok(self.logs.borrow().get(blockid).cloned().unwrap_or_default()) + } + } + + impl MohoStateStore for MockContext { + fn get_latest_moho_state( + &self, + ) -> MohoWorkerResult> { + Ok(self.latest.borrow().clone()) + } + + fn store_moho_state( + &self, + blockid: &L1BlockCommitment, + state: &MohoState, + ) -> MohoWorkerResult<()> { + self.moho.borrow_mut().insert(*blockid, state.clone()); + let mut latest = self.latest.borrow_mut(); + if latest + .as_ref() + .is_none_or(|(b, _)| blockid.height() >= b.height()) + { + *latest = Some((*blockid, state.clone())); + } + Ok(()) + } + } + + /// Builds a genesis anchor state and its commitment from arbitrary params. + fn genesis_anchor() -> (L1BlockCommitment, AnchorState) { + let params: AsmParams = ArbitraryGenerator::new().generate(); + let anchor = construct_genesis_state(¶ms); + let commitment = anchor.chain_view.pow_state.last_verified_block; + (commitment, anchor) + } + + /// Reuses `anchor` as the next block's anchor state. The fold does not + /// validate the anchor against the block, so reusing it is fine for + /// exercising the chaining logic. + fn child(anchor: &AnchorState) -> AnchorState { + anchor.clone() + } + + fn commitment_after(prev: L1BlockCommitment) -> L1BlockCommitment { + L1BlockCommitment::new(prev.height() + 1, L1BlockId::default()) + } + + #[test] + fn seeds_genesis_when_store_empty() { + let (genesis_blk, anchor) = genesis_anchor(); + let ctx = MockContext::default(); + ctx.insert_anchor(genesis_blk, anchor.clone()); + + let state = + MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); + + assert_eq!(state.cur_block(), genesis_blk); + assert_eq!(state.processed(), 0); + // Genesis moho was persisted and its inner commitment matches the anchor. + let stored = state + .context + .moho + .borrow() + .get(&genesis_blk) + .cloned() + .unwrap(); + assert_eq!( + stored.inner_state(), + AsmStfProgram::compute_state_commitment(&anchor) + ); + } + + #[test] + fn resumes_from_latest_without_reseeding_genesis() { + let (genesis_blk, anchor) = genesis_anchor(); + let ctx = MockContext::default(); + ctx.insert_anchor(genesis_blk, anchor.clone()); + + // Pre-populate a "later" stored moho state to resume from. + let later_blk = commitment_after(genesis_blk); + let later_moho = + compute::construct_genesis_moho_state(PredicateKey::always_accept(), &anchor); + ctx.store_moho_state(&later_blk, &later_moho).unwrap(); + + let state = + MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); + + assert_eq!(state.cur_block(), later_blk); + } + + #[test] + fn folds_contiguous_commits_forward() { + let (genesis_blk, anchor) = genesis_anchor(); + let ctx = MockContext::default(); + ctx.insert_anchor(genesis_blk, anchor.clone()); + + let blk1 = commitment_after(genesis_blk); + let blk2 = commitment_after(blk1); + ctx.insert_anchor(blk1, child(&anchor)); + ctx.insert_anchor(blk2, child(&anchor)); + + let mut state = + MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); + + state.process(blk1).unwrap(); + state.process(blk2).unwrap(); + + assert_eq!(state.cur_block(), blk2); + assert_eq!(state.processed(), 2); + assert!(state.context.moho.borrow().contains_key(&blk1)); + assert!(state.context.moho.borrow().contains_key(&blk2)); + } + + #[test] + fn rejects_gap_in_commit_stream() { + let (genesis_blk, anchor) = genesis_anchor(); + let ctx = MockContext::default(); + ctx.insert_anchor(genesis_blk, anchor.clone()); + + let gap_blk = L1BlockCommitment::new(genesis_blk.height() + 2, L1BlockId::default()); + ctx.insert_anchor(gap_blk, child(&anchor)); + + let mut state = + MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); + + let err = state.process(gap_blk).unwrap_err(); + assert!(matches!(err, MohoWorkerError::NonContiguousBlock { .. })); + } + + #[test] + fn ignores_stale_commit() { + let (genesis_blk, anchor) = genesis_anchor(); + let ctx = MockContext::default(); + ctx.insert_anchor(genesis_blk, anchor.clone()); + + let mut state = + MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); + + // Re-emitting genesis (height == current) is a no-op, not an error. + state.process(genesis_blk).unwrap(); + assert_eq!(state.processed(), 0); + } +} diff --git a/crates/extensions/moho/worker/src/traits.rs b/crates/extensions/moho/worker/src/traits.rs new file mode 100644 index 00000000..fe782e9e --- /dev/null +++ b/crates/extensions/moho/worker/src/traits.rs @@ -0,0 +1,60 @@ +//! Storage traits the Moho worker interfaces through. +//! +//! The worker derives each [`MohoState`] from the ASM anchor state the ASM +//! worker already committed, then persists it. Those two concerns are split +//! into separate traits so an implementor can back them with whatever subsystem +//! it likes: +//! +//! - [`AsmStateProvider`] — reads the [`AnchorState`] and [`AsmLogEntry`]s the Moho state is +//! computed from. +//! - [`MohoStateStore`] — persists and loads the derived [`MohoState`]. +//! +//! [`MohoWorkerContext`] is the umbrella with a blanket impl, mirroring +//! `strata-asm-worker`'s [`WorkerContext`](strata_asm_worker::WorkerContext): +//! implement the two concern traits and get the context for free. + +use moho_types::MohoState; +use strata_asm_common::{AnchorState, AsmLogEntry}; +use strata_identifiers::L1BlockCommitment; + +use crate::MohoWorkerResult; + +/// Reads the ASM anchor states and logs the Moho worker derives from. +pub trait AsmStateProvider { + /// Fetches the [`AnchorState`] committed by the ASM worker for `blockid`. + /// + /// Errors with [`MissingAsmState`](crate::MohoWorkerError::MissingAsmState) + /// when no anchor state exists for the block. + fn get_anchor_state(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult; + + /// Fetches the [`AsmLogEntry`]s the ASM worker emitted for `blockid`. + /// + /// Committed alongside the anchor state, so this errors with + /// [`MissingAsmState`](crate::MohoWorkerError::MissingAsmState) when the + /// block's ASM commit is absent. An empty vec means the block had no logs. + fn get_anchor_logs(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult>; +} + +/// Persists and loads the derived per-block [`MohoState`]. +pub trait MohoStateStore { + /// Fetches the most recently committed [`MohoState`] and the block it is + /// anchored to, or `None` if the store is empty. Used to resume the + /// forward-only fold across restarts. + fn get_latest_moho_state(&self) -> MohoWorkerResult>; + + /// Persists the [`MohoState`] derived for `blockid`. + fn store_moho_state( + &self, + blockid: &L1BlockCommitment, + state: &MohoState, + ) -> MohoWorkerResult<()>; +} + +/// Context the Moho worker interacts with the outside world through. +/// +/// Umbrella over [`AsmStateProvider`] and [`MohoStateStore`]. The blanket impl +/// means any type implementing both automatically implements +/// `MohoWorkerContext`, so implementors never name it directly. +pub trait MohoWorkerContext: AsmStateProvider + MohoStateStore {} + +impl MohoWorkerContext for T where T: AsmStateProvider + MohoStateStore {} From a6b99fa8d4faac35e87611ee99a70ea444a13033 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 12:54:35 +0545 Subject: [PATCH 02/10] fix(moho): fold onto the resolved parent to follow L1 reorgs The fold assumed each commit was the immediate height-successor of the last one processed and chained off an in-memory cur_moho. That breaks under an L1 reorg: a commit building on an earlier fork point isn't the height-successor of the previously folded block, so it was wrongly dropped as stale or rejected as a gap. Instead resolve the commit's actual parent (new L1ProviderContext) and fold from the parent's committed Moho state (MohoStateStore::get_moho_state), so the worker chains along real ancestry across reorgs. NonContiguousBlock gives way to MissingMohoState (the residual missing-parent gap) and MissingParentBlock. --- crates/extensions/moho/worker/src/errors.rs | 17 +- crates/extensions/moho/worker/src/lib.rs | 14 +- crates/extensions/moho/worker/src/state.rs | 174 +++++++++++++------- crates/extensions/moho/worker/src/traits.rs | 44 +++-- 4 files changed, 172 insertions(+), 77 deletions(-) diff --git a/crates/extensions/moho/worker/src/errors.rs b/crates/extensions/moho/worker/src/errors.rs index 1c7ec264..f393364b 100644 --- a/crates/extensions/moho/worker/src/errors.rs +++ b/crates/extensions/moho/worker/src/errors.rs @@ -12,14 +12,21 @@ pub enum MohoWorkerError { #[error("missing ASM anchor state for block {0:?}")] MissingAsmState(L1BlockCommitment), - /// An incoming ASM commit skipped one or more heights relative to the - /// worker's running state. The worker is a forward-only fold over the commit - /// stream, so it cannot chain across a gap. + /// The Moho state for a block was not found in the store. Hit when + /// resolving the parent of an incoming commit: the fold chains forward from + /// the parent's committed Moho state, so the parent must already be present. + /// With commits arriving in order from the ASM worker, a miss means the + /// parent's commit was never folded — a gap the worker cannot bridge alone. // TODO(STR-3124): backfill the gap by replaying the intervening anchor // states instead of erroring out, once the worker resumes from its own // store on restart. - #[error("non-contiguous ASM commit: expected height {expected}, got {got}")] - NonContiguousBlock { expected: u64, got: u64 }, + #[error("missing Moho state for block {0:?}")] + MissingMohoState(L1BlockCommitment), + + /// The parent of an L1 block commitment could not be resolved — e.g. the L1 + /// block or its header was unavailable from the provider. + #[error("could not resolve parent of L1 block {0:?}")] + MissingParentBlock(L1BlockCommitment), /// The underlying Moho-state store failed. Carries the backend's display so /// the operator sees the real cause without us bucketing it. diff --git a/crates/extensions/moho/worker/src/lib.rs b/crates/extensions/moho/worker/src/lib.rs index 4cd37213..03d34dfe 100644 --- a/crates/extensions/moho/worker/src/lib.rs +++ b/crates/extensions/moho/worker/src/lib.rs @@ -6,13 +6,15 @@ //! The worker subscribes to the ASM worker's per-block commit stream //! ([`Subscription`](strata_asm_worker::Subscription)) and, //! for each committed block, derives the Moho state from the ASM anchor state -//! the ASM worker already persisted, then stores it. It runs no chain view of -//! its own: it is a deterministic forward-only fold over whatever block sequence -//! the ASM worker commits. +//! the ASM worker already persisted, chained onto the Moho state of the block's +//! parent, then stores it. It runs no chain view of its own: it folds each +//! commit onto its resolved parent, so it follows L1 reorgs rather than assuming +//! the commits arrive in unbroken height order. //! //! Storage is supplied by the caller through [`MohoWorkerContext`] — read access -//! to ASM anchor states ([`AsmStateProvider`]) plus persistence for the derived -//! Moho states ([`MohoStateStore`]) — mirroring how `strata-asm-worker` takes a +//! to ASM anchor states ([`AsmStateProvider`]), L1 block ancestry +//! ([`L1ProviderContext`]), and persistence for the derived Moho states +//! ([`MohoStateStore`]) — mirroring how `strata-asm-worker` takes a //! [`WorkerContext`](strata_asm_worker::WorkerContext). mod builder; @@ -29,4 +31,4 @@ pub use errors::{MohoWorkerError, MohoWorkerResult}; pub use handle::MohoWorkerHandle; pub use service::{MohoWorkerService, MohoWorkerStatus}; pub use state::MohoWorkerServiceState; -pub use traits::{AsmStateProvider, MohoStateStore, MohoWorkerContext}; +pub use traits::{AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerContext}; diff --git a/crates/extensions/moho/worker/src/state.rs b/crates/extensions/moho/worker/src/state.rs index 01171bc3..e07c0af1 100644 --- a/crates/extensions/moho/worker/src/state.rs +++ b/crates/extensions/moho/worker/src/state.rs @@ -1,30 +1,31 @@ //! Service state for the Moho worker. -use moho_types::MohoState; use strata_identifiers::L1BlockCommitment; use strata_predicate::PredicateKey; use strata_service::ServiceState; -use tracing::{info, warn}; +use tracing::info; -use crate::{MohoWorkerContext, MohoWorkerError, MohoWorkerResult, compute, constants}; +use crate::{MohoWorkerContext, MohoWorkerResult, compute, constants}; /// In-memory state for the Moho worker. /// -/// The worker is a deterministic forward-only fold over the ASM commit stream: -/// it holds the most recently derived [`MohoState`] and the block it is anchored -/// to, and each incoming commitment chains forward from that. There is no chain -/// view of its own — whatever block sequence the ASM worker commits (and emits) -/// is the sequence the Moho worker folds. +/// The worker folds each ASM commit into a [`MohoState`](moho_types::MohoState) +/// by resolving the commit's parent, loading the Moho state already committed +/// for that parent, and chaining forward onto the incoming block's anchor +/// state. Resolving the *actual* parent each time — rather than assuming height +/// contiguity — is what lets the fold follow L1 reorgs: a commit building on an +/// earlier fork point chains from that fork's Moho state, not from whichever +/// commit was processed last. It keeps no chain view of its own; the parent +/// linkage and the committed states in the store are the only inputs. #[derive(Debug)] pub struct MohoWorkerServiceState { - /// Context for reading ASM anchor states and persisting Moho states. + /// Context for reading ASM anchor states, resolving parents, and persisting + /// Moho states. pub(crate) context: W, - /// The most recently derived (or genesis-seeded) Moho state. - cur_moho: MohoState, - - /// The L1 block `cur_moho` is anchored to. The next commitment must be its - /// immediate successor in height. + /// The L1 block the worker most recently committed a Moho state for. Tracked + /// for status reporting only — the fold chains off each commit's stored + /// parent, not this field. cur_block: L1BlockCommitment, /// Number of commits folded since launch (excludes the genesis seed). @@ -42,23 +43,22 @@ impl MohoWorkerServiceState { genesis_block: L1BlockCommitment, asm_predicate: PredicateKey, ) -> MohoWorkerResult { - let (cur_block, cur_moho) = match context.get_latest_moho_state()? { - Some((blk, moho)) => { + let cur_block = match context.get_latest_moho_state()? { + Some((blk, _)) => { info!(%blk, "resuming Moho worker from stored state"); - (blk, moho) + blk } None => { let genesis_anchor = context.get_anchor_state(&genesis_block)?; let moho = compute::construct_genesis_moho_state(asm_predicate, &genesis_anchor); context.store_moho_state(&genesis_block, &moho)?; info!(%genesis_block, "seeded genesis Moho state"); - (genesis_block, moho) + genesis_block } }; Ok(Self { context, - cur_moho, cur_block, processed: 0, }) @@ -74,39 +74,27 @@ impl MohoWorkerServiceState { self.processed } - /// Folds a single ASM commit into a new [`MohoState`] and persists it. + /// Folds a single ASM commit into a new [`MohoState`](moho_types::MohoState) + /// and persists it. /// - /// The commit must be the immediate height-successor of the current block. - /// A stale or duplicate commit (height `<=` current) is ignored; a gap - /// (height `>` current + 1) is rejected — the worker cannot chain across - /// anchor states it never saw. + /// Resolves the commit's parent, loads the Moho state already committed for + /// that parent, and chains it forward onto this block's anchor state and + /// logs. Resolving the real parent (rather than assuming the commit is the + /// height-successor of the last one processed) is what lets the fold follow + /// L1 reorgs. pub(crate) fn process(&mut self, block: L1BlockCommitment) -> MohoWorkerResult<()> { - let cur_height = self.cur_block.height(); - let got = block.height(); - - if got <= cur_height { - warn!(%block, cur = %self.cur_block, "ignoring stale or duplicate ASM commit"); - return Ok(()); - } - - let expected = cur_height + 1; - if got != expected { - return Err(MohoWorkerError::NonContiguousBlock { - expected: u64::from(expected), - got: u64::from(got), - }); - } + let parent_block = self.context.get_parent_block(&block)?; + let parent_moho = self.context.get_moho_state(&parent_block)?; let anchor_state = self.context.get_anchor_state(&block)?; let logs = self.context.get_anchor_logs(&block)?; - let moho = compute::construct_next_moho_state(&self.cur_moho, &anchor_state, &logs); + let moho = compute::construct_next_moho_state(&parent_moho, &anchor_state, &logs); self.context.store_moho_state(&block, &moho)?; - self.cur_moho = moho; self.cur_block = block; self.processed += 1; - info!(%block, "committed Moho state"); + info!(%block, parent = %parent_block, "committed Moho state"); Ok(()) } } @@ -122,22 +110,24 @@ mod tests { use std::{cell::RefCell, collections::HashMap}; use moho_runtime_interface::MohoProgram; + use moho_types::MohoState; use strata_asm_common::{AnchorState, AsmLogEntry}; use strata_asm_params::AsmParams; use strata_asm_proof_impl::moho_program::program::AsmStfProgram; use strata_asm_spec::construct_genesis_state; - use strata_identifiers::{L1BlockCommitment, L1BlockId}; + use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; use strata_predicate::PredicateKey; use strata_test_utils_arb::ArbitraryGenerator; use super::*; - use crate::{AsmStateProvider, MohoStateStore}; + use crate::{AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerError}; - /// In-memory context backing both concern traits. + /// In-memory context backing the three concern traits. #[derive(Debug, Default)] struct MockContext { anchors: RefCell>, logs: RefCell>>, + parents: RefCell>, moho: RefCell>, latest: RefCell>, } @@ -146,6 +136,11 @@ mod tests { fn insert_anchor(&self, blk: L1BlockCommitment, state: AnchorState) { self.anchors.borrow_mut().insert(blk, state); } + + /// Registers `parent` as the parent of `blk` for parent resolution. + fn link_parent(&self, blk: L1BlockCommitment, parent: L1BlockCommitment) { + self.parents.borrow_mut().insert(blk, parent); + } } impl AsmStateProvider for MockContext { @@ -165,6 +160,19 @@ mod tests { } } + impl L1ProviderContext for MockContext { + fn get_parent_block( + &self, + block: &L1BlockCommitment, + ) -> MohoWorkerResult { + self.parents + .borrow() + .get(block) + .copied() + .ok_or(MohoWorkerError::MissingParentBlock(*block)) + } + } + impl MohoStateStore for MockContext { fn get_latest_moho_state( &self, @@ -172,6 +180,14 @@ mod tests { Ok(self.latest.borrow().clone()) } + fn get_moho_state(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult { + self.moho + .borrow() + .get(blockid) + .cloned() + .ok_or(MohoWorkerError::MissingMohoState(*blockid)) + } + fn store_moho_state( &self, blockid: &L1BlockCommitment, @@ -204,8 +220,14 @@ mod tests { anchor.clone() } + /// A commitment one height above `prev`, with a caller-chosen id so that + /// sibling blocks at the same height — a reorg — stay distinguishable. + fn commitment_after_with_id(prev: L1BlockCommitment, id: u8) -> L1BlockCommitment { + L1BlockCommitment::new(prev.height() + 1, L1BlockId::from(Buf32::from([id; 32]))) + } + fn commitment_after(prev: L1BlockCommitment) -> L1BlockCommitment { - L1BlockCommitment::new(prev.height() + 1, L1BlockId::default()) + commitment_after_with_id(prev, 0) } #[test] @@ -261,6 +283,8 @@ mod tests { let blk2 = commitment_after(blk1); ctx.insert_anchor(blk1, child(&anchor)); ctx.insert_anchor(blk2, child(&anchor)); + ctx.link_parent(blk1, genesis_blk); + ctx.link_parent(blk2, blk1); let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); @@ -275,32 +299,72 @@ mod tests { } #[test] - fn rejects_gap_in_commit_stream() { + fn folds_reorged_sibling_from_shared_parent() { + // Two siblings at the same height both build on genesis (a reorg). Each + // must fold from genesis's Moho state; the old height-successor logic + // would have dropped the second as a "stale" same-height commit. let (genesis_blk, anchor) = genesis_anchor(); let ctx = MockContext::default(); ctx.insert_anchor(genesis_blk, anchor.clone()); - let gap_blk = L1BlockCommitment::new(genesis_blk.height() + 2, L1BlockId::default()); - ctx.insert_anchor(gap_blk, child(&anchor)); + let blk_a = commitment_after_with_id(genesis_blk, 0xaa); + let blk_b = commitment_after_with_id(genesis_blk, 0xbb); + ctx.insert_anchor(blk_a, child(&anchor)); + ctx.insert_anchor(blk_b, child(&anchor)); + ctx.link_parent(blk_a, genesis_blk); + ctx.link_parent(blk_b, genesis_blk); let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); - let err = state.process(gap_blk).unwrap_err(); - assert!(matches!(err, MohoWorkerError::NonContiguousBlock { .. })); + state.process(blk_a).unwrap(); + state.process(blk_b).unwrap(); + + // The second sibling was folded, not ignored. + assert_eq!(state.processed(), 2); + let moho = state.context.moho.borrow(); + assert!(moho.contains_key(&blk_a)); + assert!(moho.contains_key(&blk_b)); + // Both fold from the shared genesis state onto the same anchor, so their + // inner commitments match. + let inner = AsmStfProgram::compute_state_commitment(&anchor); + assert_eq!(moho.get(&blk_a).unwrap().inner_state(), inner); + assert_eq!(moho.get(&blk_b).unwrap().inner_state(), inner); } #[test] - fn ignores_stale_commit() { + fn errors_when_parent_moho_missing() { let (genesis_blk, anchor) = genesis_anchor(); let ctx = MockContext::default(); ctx.insert_anchor(genesis_blk, anchor.clone()); + // `orphan`'s parent was never committed, so its Moho state is absent. + let missing_parent = commitment_after(genesis_blk); + let orphan = commitment_after(missing_parent); + ctx.insert_anchor(orphan, child(&anchor)); + ctx.link_parent(orphan, missing_parent); + let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); - // Re-emitting genesis (height == current) is a no-op, not an error. - state.process(genesis_blk).unwrap(); - assert_eq!(state.processed(), 0); + let err = state.process(orphan).unwrap_err(); + assert!(matches!(err, MohoWorkerError::MissingMohoState(_))); + } + + #[test] + fn errors_when_parent_unresolvable() { + let (genesis_blk, anchor) = genesis_anchor(); + let ctx = MockContext::default(); + ctx.insert_anchor(genesis_blk, anchor.clone()); + + // No parent link registered, so the provider cannot resolve the parent. + let blk = commitment_after(genesis_blk); + ctx.insert_anchor(blk, child(&anchor)); + + let mut state = + MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); + + let err = state.process(blk).unwrap_err(); + assert!(matches!(err, MohoWorkerError::MissingParentBlock(_))); } } diff --git a/crates/extensions/moho/worker/src/traits.rs b/crates/extensions/moho/worker/src/traits.rs index fe782e9e..7d6c31e8 100644 --- a/crates/extensions/moho/worker/src/traits.rs +++ b/crates/extensions/moho/worker/src/traits.rs @@ -1,17 +1,19 @@ //! Storage traits the Moho worker interfaces through. //! //! The worker derives each [`MohoState`] from the ASM anchor state the ASM -//! worker already committed, then persists it. Those two concerns are split -//! into separate traits so an implementor can back them with whatever subsystem -//! it likes: +//! worker already committed, chaining it onto the Moho state of the block's +//! parent, then persists it. Those concerns are split into separate traits so +//! an implementor can back them with whatever subsystem it likes: //! //! - [`AsmStateProvider`] — reads the [`AnchorState`] and [`AsmLogEntry`]s the Moho state is //! computed from. +//! - [`L1ProviderContext`] — resolves the parent of an L1 block commitment, so the fold can chain +//! onto the parent's Moho state across reorgs. //! - [`MohoStateStore`] — persists and loads the derived [`MohoState`]. //! //! [`MohoWorkerContext`] is the umbrella with a blanket impl, mirroring //! `strata-asm-worker`'s [`WorkerContext`](strata_asm_worker::WorkerContext): -//! implement the two concern traits and get the context for free. +//! implement the concern traits and get the context for free. use moho_types::MohoState; use strata_asm_common::{AnchorState, AsmLogEntry}; @@ -35,13 +37,32 @@ pub trait AsmStateProvider { fn get_anchor_logs(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult>; } +/// Resolves L1 block ancestry so the fold can chain onto the parent's state. +pub trait L1ProviderContext { + /// Fetches the parent of `block` — the commitment whose Moho state the fold + /// for `block` chains forward from. + /// + /// Resolving the real parent (rather than assuming the commit is the + /// height-successor of the last one processed) is what lets the worker + /// follow L1 reorgs. Errors with + /// [`MissingParentBlock`](crate::MohoWorkerError::MissingParentBlock) when + /// the parent cannot be resolved. + fn get_parent_block(&self, block: &L1BlockCommitment) -> MohoWorkerResult; +} + /// Persists and loads the derived per-block [`MohoState`]. pub trait MohoStateStore { /// Fetches the most recently committed [`MohoState`] and the block it is - /// anchored to, or `None` if the store is empty. Used to resume the - /// forward-only fold across restarts. + /// anchored to, or `None` if the store is empty. Used to resume across + /// restarts. fn get_latest_moho_state(&self) -> MohoWorkerResult>; + /// Fetches the [`MohoState`] committed for `blockid`. + /// + /// Errors with [`MissingMohoState`](crate::MohoWorkerError::MissingMohoState) + /// when no Moho state exists for the block. + fn get_moho_state(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult; + /// Persists the [`MohoState`] derived for `blockid`. fn store_moho_state( &self, @@ -52,9 +73,10 @@ pub trait MohoStateStore { /// Context the Moho worker interacts with the outside world through. /// -/// Umbrella over [`AsmStateProvider`] and [`MohoStateStore`]. The blanket impl -/// means any type implementing both automatically implements -/// `MohoWorkerContext`, so implementors never name it directly. -pub trait MohoWorkerContext: AsmStateProvider + MohoStateStore {} +/// Umbrella over [`AsmStateProvider`], [`L1ProviderContext`] and +/// [`MohoStateStore`]. The blanket impl means any type implementing all three +/// automatically implements `MohoWorkerContext`, so implementors never name it +/// directly. +pub trait MohoWorkerContext: AsmStateProvider + L1ProviderContext + MohoStateStore {} -impl MohoWorkerContext for T where T: AsmStateProvider + MohoStateStore {} +impl MohoWorkerContext for T where T: AsmStateProvider + L1ProviderContext + MohoStateStore {} From a2479ed653e62ef4fe269e087a2455284dc2c329 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 13:23:25 +0545 Subject: [PATCH 03/10] refactor(moho): hold Moho state in memory and split orchestration into the service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror strata-asm-worker's shape: keep the current MohoState in the service state and move the fold orchestration into a process_block free function in the service layer, leaving the state as data plus a small update_moho_state mutation. The in-memory state is load-bearing again — the common in-order commit folds straight from it with no store read — while reorg-safety is kept by re-anchoring from the parent's committed state when the incoming commit doesn't build on the block held in memory. Surface the current MohoState in the worker status (via moho-types' serde feature), matching AsmWorkerStatus, and drop the launch-relative processed counter, which reset on restart and added nothing over cur_block. --- Cargo.lock | 2 + crates/extensions/moho/worker/Cargo.toml | 2 +- crates/extensions/moho/worker/src/service.rs | 42 ++++++++- crates/extensions/moho/worker/src/state.rs | 99 +++++++++----------- 4 files changed, 84 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cb39d75c..08714f53 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4308,7 +4308,9 @@ name = "moho-types" version = "0.1.0" source = "git+https://github.com/alpenlabs/moho?tag=v0.1-alpha.8#9499bce0ed87d6d2d84bc694ade6867858808244" dependencies = [ + "const-hex", "hex", + "serde", "sha2 0.11.0", "ssz", "ssz_codegen", diff --git a/crates/extensions/moho/worker/Cargo.toml b/crates/extensions/moho/worker/Cargo.toml index c0ccedb2..6fb187b3 100644 --- a/crates/extensions/moho/worker/Cargo.toml +++ b/crates/extensions/moho/worker/Cargo.toml @@ -16,7 +16,7 @@ strata-service.workspace = true strata-tasks.workspace = true moho-runtime-interface.workspace = true -moho-types.workspace = true +moho-types = { workspace = true, features = ["serde"] } anyhow.workspace = true serde.workspace = true diff --git a/crates/extensions/moho/worker/src/service.rs b/crates/extensions/moho/worker/src/service.rs index 770c04a8..b20f1670 100644 --- a/crates/extensions/moho/worker/src/service.rs +++ b/crates/extensions/moho/worker/src/service.rs @@ -8,11 +8,13 @@ use std::marker::PhantomData; +use moho_types::MohoState; use serde::{Deserialize, Serialize}; use strata_identifiers::L1BlockCommitment; use strata_service::{AsyncService, Response, Service}; +use tracing::info; -use crate::{MohoWorkerContext, MohoWorkerServiceState}; +use crate::{MohoWorkerContext, MohoWorkerResult, MohoWorkerServiceState, compute}; /// Moho worker service implementation using the service framework. #[derive(Debug)] @@ -32,7 +34,7 @@ where MohoWorkerStatus { is_initialized: true, cur_block: Some(state.cur_block()), - processed: state.processed(), + cur_state: Some(state.cur_moho().clone()), } } } @@ -48,15 +50,47 @@ where // The store is synchronous (sled), so the fold runs to completion // without yielding. A processing error exits the worker — the commit // stream cannot be skipped without leaving a gap. - state.process(input)?; + process_block(state, input)?; Ok(Response::Continue) } } +/// Folds a single ASM commit into a new [`MohoState`] and persists it. +/// +/// Resolves the commit's parent and chains the Moho state forward onto this +/// block's anchor state and logs. The parent's Moho state comes from the +/// in-memory [`cur_moho`](MohoWorkerServiceState::cur_moho) when the commit +/// builds on the block already held — the in-order common case; otherwise (an L1 +/// reorg) it is re-anchored from the parent's committed state in the store. +/// Resolving the real parent rather than assuming height contiguity is what lets +/// the worker follow reorgs. +pub(crate) fn process_block( + state: &mut MohoWorkerServiceState, + block: L1BlockCommitment, +) -> MohoWorkerResult<()> { + let parent = state.context.get_parent_block(&block)?; + + let parent_moho = if state.cur_block() == parent { + state.cur_moho().clone() + } else { + state.context.get_moho_state(&parent)? + }; + + let anchor_state = state.context.get_anchor_state(&block)?; + let logs = state.context.get_anchor_logs(&block)?; + let moho = compute::construct_next_moho_state(&parent_moho, &anchor_state, &logs); + state.context.store_moho_state(&block, &moho)?; + + state.update_moho_state(moho, block); + + info!(%block, %parent, "committed Moho state"); + Ok(()) +} + /// Status information for the Moho worker service. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct MohoWorkerStatus { pub is_initialized: bool, pub cur_block: Option, - pub processed: u64, + pub cur_state: Option, } diff --git a/crates/extensions/moho/worker/src/state.rs b/crates/extensions/moho/worker/src/state.rs index e07c0af1..d7a1b9d6 100644 --- a/crates/extensions/moho/worker/src/state.rs +++ b/crates/extensions/moho/worker/src/state.rs @@ -1,5 +1,6 @@ //! Service state for the Moho worker. +use moho_types::MohoState; use strata_identifiers::L1BlockCommitment; use strata_predicate::PredicateKey; use strata_service::ServiceState; @@ -9,27 +10,29 @@ use crate::{MohoWorkerContext, MohoWorkerResult, compute, constants}; /// In-memory state for the Moho worker. /// -/// The worker folds each ASM commit into a [`MohoState`](moho_types::MohoState) -/// by resolving the commit's parent, loading the Moho state already committed -/// for that parent, and chaining forward onto the incoming block's anchor -/// state. Resolving the *actual* parent each time — rather than assuming height -/// contiguity — is what lets the fold follow L1 reorgs: a commit building on an -/// earlier fork point chains from that fork's Moho state, not from whichever -/// commit was processed last. It keeps no chain view of its own; the parent -/// linkage and the committed states in the store are the only inputs. +/// Holds the most recently folded [`MohoState`] and the block it is anchored to. +/// Each ASM commit is folded onto its parent's Moho state: in the common +/// in-order case the parent is the block already held here, so the fold reads +/// straight from memory; on an L1 reorg the incoming commit builds on a +/// different block, so the orchestration re-anchors from the parent's committed +/// state in the store. It keeps no chain view of its own. +/// +/// Mirrors `strata-asm-worker`'s `AsmWorkerServiceState`, which likewise holds +/// the current `AsmState` in memory and re-anchors on reorg. The fold +/// orchestration lives in the service layer's `process_block`; this type just +/// holds the data and the small `update_moho_state` mutation that advances it. #[derive(Debug)] pub struct MohoWorkerServiceState { /// Context for reading ASM anchor states, resolving parents, and persisting /// Moho states. pub(crate) context: W, - /// The L1 block the worker most recently committed a Moho state for. Tracked - /// for status reporting only — the fold chains off each commit's stored - /// parent, not this field. - cur_block: L1BlockCommitment, + /// The most recently folded (or genesis-seeded) Moho state. The fold chains + /// directly onto this when the next commit builds on `cur_block`. + cur_moho: MohoState, - /// Number of commits folded since launch (excludes the genesis seed). - processed: u64, + /// The L1 block `cur_moho` is anchored to. + cur_block: L1BlockCommitment, } impl MohoWorkerServiceState { @@ -43,24 +46,24 @@ impl MohoWorkerServiceState { genesis_block: L1BlockCommitment, asm_predicate: PredicateKey, ) -> MohoWorkerResult { - let cur_block = match context.get_latest_moho_state()? { - Some((blk, _)) => { + let (cur_block, cur_moho) = match context.get_latest_moho_state()? { + Some((blk, moho)) => { info!(%blk, "resuming Moho worker from stored state"); - blk + (blk, moho) } None => { let genesis_anchor = context.get_anchor_state(&genesis_block)?; let moho = compute::construct_genesis_moho_state(asm_predicate, &genesis_anchor); context.store_moho_state(&genesis_block, &moho)?; info!(%genesis_block, "seeded genesis Moho state"); - genesis_block + (genesis_block, moho) } }; Ok(Self { context, + cur_moho, cur_block, - processed: 0, }) } @@ -69,33 +72,16 @@ impl MohoWorkerServiceState { self.cur_block } - /// Number of ASM commits folded since launch. - pub fn processed(&self) -> u64 { - self.processed + /// The most recently folded (or genesis-seeded) Moho state. + pub fn cur_moho(&self) -> &MohoState { + &self.cur_moho } - /// Folds a single ASM commit into a new [`MohoState`](moho_types::MohoState) - /// and persists it. - /// - /// Resolves the commit's parent, loads the Moho state already committed for - /// that parent, and chains it forward onto this block's anchor state and - /// logs. Resolving the real parent (rather than assuming the commit is the - /// height-successor of the last one processed) is what lets the fold follow - /// L1 reorgs. - pub(crate) fn process(&mut self, block: L1BlockCommitment) -> MohoWorkerResult<()> { - let parent_block = self.context.get_parent_block(&block)?; - let parent_moho = self.context.get_moho_state(&parent_block)?; - - let anchor_state = self.context.get_anchor_state(&block)?; - let logs = self.context.get_anchor_logs(&block)?; - let moho = compute::construct_next_moho_state(&parent_moho, &anchor_state, &logs); - self.context.store_moho_state(&block, &moho)?; - - self.cur_block = block; - self.processed += 1; - - info!(%block, parent = %parent_block, "committed Moho state"); - Ok(()) + /// Advances the in-memory state to `moho` at `blk` after a successful fold. + /// Mirrors `strata-asm-worker`'s `update_anchor_state`. + pub(crate) fn update_moho_state(&mut self, moho: MohoState, blk: L1BlockCommitment) { + self.cur_moho = moho; + self.cur_block = blk; } } @@ -110,7 +96,6 @@ mod tests { use std::{cell::RefCell, collections::HashMap}; use moho_runtime_interface::MohoProgram; - use moho_types::MohoState; use strata_asm_common::{AnchorState, AsmLogEntry}; use strata_asm_params::AsmParams; use strata_asm_proof_impl::moho_program::program::AsmStfProgram; @@ -120,7 +105,10 @@ mod tests { use strata_test_utils_arb::ArbitraryGenerator; use super::*; - use crate::{AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerError}; + use crate::{ + AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerError, + service::process_block, + }; /// In-memory context backing the three concern traits. #[derive(Debug, Default)] @@ -240,7 +228,6 @@ mod tests { MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); assert_eq!(state.cur_block(), genesis_blk); - assert_eq!(state.processed(), 0); // Genesis moho was persisted and its inner commitment matches the anchor. let stored = state .context @@ -289,11 +276,10 @@ mod tests { let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); - state.process(blk1).unwrap(); - state.process(blk2).unwrap(); + process_block(&mut state, blk1).unwrap(); + process_block(&mut state, blk2).unwrap(); assert_eq!(state.cur_block(), blk2); - assert_eq!(state.processed(), 2); assert!(state.context.moho.borrow().contains_key(&blk1)); assert!(state.context.moho.borrow().contains_key(&blk2)); } @@ -317,12 +303,13 @@ mod tests { let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); - state.process(blk_a).unwrap(); - state.process(blk_b).unwrap(); + process_block(&mut state, blk_a).unwrap(); + // blk_b's parent (genesis) is no longer the in-memory cur_block (blk_a), + // so this exercises the store re-anchor path, not the fast path. + process_block(&mut state, blk_b).unwrap(); - // The second sibling was folded, not ignored. - assert_eq!(state.processed(), 2); let moho = state.context.moho.borrow(); + // The second sibling was folded, not ignored. assert!(moho.contains_key(&blk_a)); assert!(moho.contains_key(&blk_b)); // Both fold from the shared genesis state onto the same anchor, so their @@ -347,7 +334,7 @@ mod tests { let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); - let err = state.process(orphan).unwrap_err(); + let err = process_block(&mut state, orphan).unwrap_err(); assert!(matches!(err, MohoWorkerError::MissingMohoState(_))); } @@ -364,7 +351,7 @@ mod tests { let mut state = MohoWorkerServiceState::new(ctx, genesis_blk, PredicateKey::always_accept()).unwrap(); - let err = state.process(blk).unwrap_err(); + let err = process_block(&mut state, blk).unwrap_err(); assert!(matches!(err, MohoWorkerError::MissingParentBlock(_))); } } From c5fd3a8f5c724e42afc866024d0bad3186f1b03e Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 14:57:06 +0545 Subject: [PATCH 04/10] feat(moho): add dedicated moho-state storage crate Per-block MohoState persistence lived in strata-asm-proof-db next to proof artifacts, conflating materialized state with proofs. Give it its own home so the Moho worker can own its store independently of the proof DB. Adds get_latest so the worker can resume from its latest committed state across restarts. --- Cargo.lock | 14 + Cargo.toml | 2 + crates/extensions/moho/storage/Cargo.toml | 20 ++ crates/extensions/moho/storage/src/lib.rs | 13 + .../extensions/moho/storage/src/moho_state.rs | 33 +++ .../extensions/moho/storage/src/sled/mod.rs | 84 ++++++ .../moho/storage/src/sled/moho_state.rs | 254 ++++++++++++++++++ 7 files changed, 420 insertions(+) create mode 100644 crates/extensions/moho/storage/Cargo.toml create mode 100644 crates/extensions/moho/storage/src/lib.rs create mode 100644 crates/extensions/moho/storage/src/moho_state.rs create mode 100644 crates/extensions/moho/storage/src/sled/mod.rs create mode 100644 crates/extensions/moho/storage/src/sled/moho_state.rs diff --git a/Cargo.lock b/Cargo.lock index 08714f53..ec3afabd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7709,6 +7709,20 @@ dependencies = [ "tree_hash_derive", ] +[[package]] +name = "strata-asm-moho-storage" +version = "0.1.0" +dependencies = [ + "moho-types", + "proptest", + "sled", + "ssz", + "strata-identifiers", + "strata-predicate", + "tempfile", + "tokio", +] + [[package]] name = "strata-asm-moho-worker" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index fe4cfe53..f0c4cea7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ members = [ "crates/storage", # extensions + "crates/extensions/moho/storage", "crates/extensions/moho/worker", # tests @@ -64,6 +65,7 @@ strata-ams-test-utils = { path = "crates/test-utils-btcio" } strata-asm-common = { path = "crates/common" } strata-asm-logs = { path = "crates/logs" } strata-asm-manifest-types = { path = "crates/manifest-types" } +strata-asm-moho-storage = { path = "crates/extensions/moho/storage" } strata-asm-moho-worker = { path = "crates/extensions/moho/worker" } strata-asm-params = { path = "crates/params" } strata-asm-proof-db = { path = "crates/proof/db" } diff --git a/crates/extensions/moho/storage/Cargo.toml b/crates/extensions/moho/storage/Cargo.toml new file mode 100644 index 00000000..ca07b562 --- /dev/null +++ b/crates/extensions/moho/storage/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "strata-asm-moho-storage" +version = "0.1.0" +edition = "2024" + +[dependencies] +moho-types.workspace = true +strata-identifiers.workspace = true + +sled.workspace = true +ssz.workspace = true + +[dev-dependencies] +proptest.workspace = true +strata-predicate.workspace = true +tempfile.workspace = true +tokio.workspace = true + +[lints] +workspace = true diff --git a/crates/extensions/moho/storage/src/lib.rs b/crates/extensions/moho/storage/src/lib.rs new file mode 100644 index 00000000..21544629 --- /dev/null +++ b/crates/extensions/moho/storage/src/lib.rs @@ -0,0 +1,13 @@ +//! Persistence layer for Moho state snapshots. +//! +//! The Moho worker derives a [`moho_types::MohoState`] for each L1 block it +//! processes and persists it here, keyed by the block's +//! [`L1BlockCommitment`](strata_identifiers::L1BlockCommitment). +//! +//! - [`MohoStateDb`] — the storage trait, parameterised over an associated error type. +//! - [`SledMohoStateDb`] — a [sled](https://docs.rs/sled)-backed implementation. + +mod moho_state; +mod sled; + +pub use self::{moho_state::MohoStateDb, sled::SledMohoStateDb}; diff --git a/crates/extensions/moho/storage/src/moho_state.rs b/crates/extensions/moho/storage/src/moho_state.rs new file mode 100644 index 00000000..c64ba6dc --- /dev/null +++ b/crates/extensions/moho/storage/src/moho_state.rs @@ -0,0 +1,33 @@ +//! Storage trait for Moho state snapshots. +//! +//! Each entry records the [`MohoState`] that was computed after processing the +//! L1 block identified by the given [`L1BlockCommitment`]. + +use std::fmt::Debug; + +use moho_types::MohoState; +use strata_identifiers::L1BlockCommitment; + +/// Persistence interface for Moho state storage. +pub trait MohoStateDb { + /// The error type returned by database operations. + type Error: Debug; + + /// Stores the Moho state anchored at the given L1 block commitment. + fn store_moho_state( + &self, + l1ref: L1BlockCommitment, + state: MohoState, + ) -> impl Future> + Send; + + /// Retrieves the Moho state for the given L1 block commitment, if one exists. + fn get_moho_state( + &self, + l1ref: L1BlockCommitment, + ) -> impl Future, Self::Error>> + Send; + + /// Prunes all Moho state entries for blocks before the given height. + /// + /// Deletes all entries with height strictly less than `before_height`. + fn prune(&self, before_height: u32) -> impl Future> + Send; +} diff --git a/crates/extensions/moho/storage/src/sled/mod.rs b/crates/extensions/moho/storage/src/sled/mod.rs new file mode 100644 index 00000000..96d68fcd --- /dev/null +++ b/crates/extensions/moho/storage/src/sled/mod.rs @@ -0,0 +1,84 @@ +//! [Sled](https://docs.rs/sled)-backed implementation of [`super::MohoStateDb`]. +//! +//! State is stored in a single sled tree. Keys use big-endian height encoding so +//! that sled's lexicographic ordering matches block-height ordering, which is +//! required for the range scans `prune` relies on. + +use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; + +mod moho_state; + +pub use self::moho_state::SledMohoStateDb; + +// ── Key encoding ────────────────────────────────────────────────────── +// +// We use a custom big-endian encoding for block commitment keys instead of +// borsh/bincode because those serialize integers as little-endian. Big-endian +// encoding ensures that sled's lexicographic key ordering matches block-height +// ordering, which is required for range scans. + +/// Size of an encoded [`L1BlockCommitment`]: 4-byte BE height + 32-byte block id. +const ENCODED_L1_COMMITMENT_SIZE: usize = 4 + 32; + +/// Encodes an [`L1BlockCommitment`] as 36 bytes: `[height_be(4)][blkid(32)]`. +pub(crate) fn encode_block_commitment( + commitment: &L1BlockCommitment, +) -> [u8; ENCODED_L1_COMMITMENT_SIZE] { + let mut buf = [0u8; ENCODED_L1_COMMITMENT_SIZE]; + buf[0..4].copy_from_slice(&commitment.height().to_be_bytes()); + buf[4..36].copy_from_slice(commitment.blkid().as_ref()); + buf +} + +/// Decodes a 36-byte buffer back into an [`L1BlockCommitment`]. +pub(crate) fn decode_block_commitment(buf: &[u8]) -> L1BlockCommitment { + let height = u32::from_be_bytes(buf[0..4].try_into().expect("key is at least 4 bytes")); + let blkid: [u8; 32] = buf[4..36].try_into().expect("key is at least 36 bytes"); + L1BlockCommitment::new(height, L1BlockId::from(Buf32::from(blkid))) +} + +/// Alias: encodes a Moho key (same as a single block commitment). +pub(crate) fn encode_moho_key(l1ref: &L1BlockCommitment) -> [u8; ENCODED_L1_COMMITMENT_SIZE] { + encode_block_commitment(l1ref) +} + +/// Alias: decodes a Moho key (same as a single block commitment). +pub(crate) fn decode_moho_key(key: &[u8]) -> L1BlockCommitment { + decode_block_commitment(key) +} + +#[cfg(test)] +pub(crate) mod test_util { + use proptest::prelude::*; + use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; + + /// Generates an arbitrary L1BlockCommitment. + /// Heights must be < 500_000_000 (bitcoin LOCK_TIME_THRESHOLD). + pub(crate) fn arb_l1_block_commitment() -> impl Strategy { + (0u32..500_000_000u32, any::<[u8; 32]>()) + .prop_map(|(h, blkid)| L1BlockCommitment::new(h, L1BlockId::from(Buf32::from(blkid)))) + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::test_util::arb_l1_block_commitment; + + proptest! { + #[test] + fn block_commitment_key_roundtrip(commitment in arb_l1_block_commitment()) { + let encoded = super::encode_block_commitment(&commitment); + let decoded = super::decode_block_commitment(&encoded); + prop_assert_eq!(commitment, decoded); + } + + #[test] + fn moho_key_roundtrip(commitment in arb_l1_block_commitment()) { + let encoded = super::encode_moho_key(&commitment); + let decoded = super::decode_moho_key(&encoded); + prop_assert_eq!(commitment, decoded); + } + } +} diff --git a/crates/extensions/moho/storage/src/sled/moho_state.rs b/crates/extensions/moho/storage/src/sled/moho_state.rs new file mode 100644 index 00000000..3d7ef539 --- /dev/null +++ b/crates/extensions/moho/storage/src/sled/moho_state.rs @@ -0,0 +1,254 @@ +//! [`MohoStateDb`] implementation backed by sled. + +use moho_types::MohoState; +use ssz::{Decode, Encode}; +use strata_identifiers::L1BlockCommitment; + +use super::{decode_moho_key, encode_moho_key}; +use crate::MohoStateDb; + +/// Sled-backed store for [`MohoState`] snapshots keyed by [`L1BlockCommitment`]. +/// +/// Values are SSZ-encoded; keys use big-endian height encoding so lexicographic +/// range scans match block-height ordering. +#[derive(Debug, Clone)] +pub struct SledMohoStateDb { + moho_states: sled::Tree, +} + +impl SledMohoStateDb { + /// Opens the Moho-state tree on an already-open sled database. + /// + /// Callers open the [`sled::Db`] themselves so multiple handles can share + /// the same on-disk directory; sled does not allow opening the same path + /// twice in a process. + pub fn open(db: &sled::Db) -> Result { + Ok(Self { + moho_states: db.open_tree("moho_states")?, + }) + } + + /// Synchronous variant of [`MohoStateDb::store_moho_state`]. The Moho worker + /// interacts with storage through synchronous traits (`MohoStateStore`), and + /// it runs as an async service where a nested `Handle::block_on` would panic, + /// so the worker calls these sync methods directly rather than the async + /// trait below. + pub fn store(&self, l1ref: L1BlockCommitment, state: MohoState) -> Result<(), sled::Error> { + self.moho_states + .insert(encode_moho_key(&l1ref), state.as_ssz_bytes())?; + Ok(()) + } + + /// Synchronous variant of [`MohoStateDb::get_moho_state`]. See [`Self::store`]. + pub fn get(&self, l1ref: L1BlockCommitment) -> Result, sled::Error> { + Ok(self + .moho_states + .get(encode_moho_key(&l1ref))? + .map(|v| MohoState::from_ssz_bytes(&v).expect("stored state should be valid SSZ"))) + } + + /// Returns the highest-height stored Moho state and the block it is anchored + /// to, or `None` when the store is empty. + /// + /// Keys are big-endian `[height‖blkid]`, so the last entry is the + /// highest-height one (ties broken by block id). The Moho worker uses this to + /// resume from its latest committed state across restarts. + pub fn get_latest(&self) -> Result, sled::Error> { + let Some((key, value)) = self.moho_states.last()? else { + return Ok(None); + }; + let commitment = decode_moho_key(&key); + let state = MohoState::from_ssz_bytes(&value).expect("stored state should be valid SSZ"); + Ok(Some((commitment, state))) + } + + /// Synchronous variant of [`MohoStateDb::prune`]. See [`Self::store`]. + pub fn prune_before(&self, before_height: u32) -> Result<(), sled::Error> { + let upper: &[u8] = &before_height.to_be_bytes(); + + for entry in self.moho_states.range(..upper) { + let (key, _) = entry?; + self.moho_states.remove(&key)?; + } + + Ok(()) + } +} + +impl MohoStateDb for SledMohoStateDb { + type Error = sled::Error; + + async fn store_moho_state( + &self, + l1ref: L1BlockCommitment, + state: MohoState, + ) -> Result<(), Self::Error> { + self.store(l1ref, state) + } + + async fn get_moho_state( + &self, + l1ref: L1BlockCommitment, + ) -> Result, Self::Error> { + self.get(l1ref) + } + + async fn prune(&self, before_height: u32) -> Result<(), Self::Error> { + self.prune_before(before_height) + } +} + +#[cfg(test)] +mod tests { + use moho_types::{ExportState, InnerStateCommitment, MohoState}; + use proptest::{collection::vec, prelude::*}; + use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; + use strata_predicate::PredicateKey; + use tokio::runtime::Runtime; + + use super::*; + use crate::sled::test_util::*; + + /// Creates an isolated [`SledMohoStateDb`] backed by a temporary directory. + fn temp_moho_db() -> (SledMohoStateDb, tempfile::TempDir) { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let db = sled::open(dir.path()).expect("failed to open sled db"); + let moho_db = SledMohoStateDb::open(&db).expect("failed to open moho state tree"); + (moho_db, dir) + } + + /// Generates an arbitrary [`MohoState`]. + fn arb_moho_state() -> impl Strategy { + any::<[u8; 32]>().prop_map(|inner| { + MohoState::new( + InnerStateCommitment::from(inner), + PredicateKey::always_accept(), + ExportState::new(vec![]).unwrap(), + ) + }) + } + + fn moho_state(inner: u8) -> MohoState { + MohoState::new( + InnerStateCommitment::from([inner; 32]), + PredicateKey::always_accept(), + ExportState::new(vec![]).unwrap(), + ) + } + + #[test] + fn get_latest_on_empty_returns_none() { + let (db, _dir) = temp_moho_db(); + assert!(db.get_latest().unwrap().is_none()); + } + + #[test] + fn get_latest_returns_highest_height() { + let (db, _dir) = temp_moho_db(); + let low = L1BlockCommitment::new(7, L1BlockId::from(Buf32::from([0x11; 32]))); + let high = L1BlockCommitment::new(42, L1BlockId::from(Buf32::from([0x22; 32]))); + + // Store out of height order to prove ordering comes from the key, not + // insertion order. + db.store(high, moho_state(0xbb)).unwrap(); + db.store(low, moho_state(0xaa)).unwrap(); + + let (blk, state) = db.get_latest().unwrap().unwrap(); + assert_eq!(blk, high); + assert_eq!(state, moho_state(0xbb)); + } + + proptest! { + #![proptest_config(ProptestConfig::with_cases(50))] + + /// Property: a stored Moho state can be retrieved with the same commitment key. + #[test] + fn moho_state_roundtrip( + commitment in arb_l1_block_commitment(), + state in arb_moho_state(), + ) { + let (db, _dir) = temp_moho_db(); + + Runtime::new().unwrap().block_on(async { + db.store_moho_state(commitment, state.clone()).await.unwrap(); + + let retrieved = db.get_moho_state(commitment).await.unwrap(); + + prop_assert_eq!(Some(state), retrieved); + + Ok(()) + })?; + } + + /// Property: querying a commitment that was never stored returns `None`. + #[test] + fn get_missing_moho_state_returns_none( + commitment in arb_l1_block_commitment(), + ) { + let (db, _dir) = temp_moho_db(); + + Runtime::new().unwrap().block_on(async { + let result = db.get_moho_state(commitment).await.unwrap(); + + prop_assert_eq!(result, None); + + Ok(()) + })?; + } + + /// Property: prune removes entries with height < threshold and preserves + /// those with height >= threshold. + #[test] + fn prune_removes_entries_below_threshold( + threshold in 100u32..499_999_900u32, + below in vec( + (1u32..100u32, any::<[u8; 32]>(), arb_moho_state()), + 1..4, + ), + above in vec( + (0u32..100u32, any::<[u8; 32]>(), arb_moho_state()), + 1..4, + ), + ) { + let (db, _dir) = temp_moho_db(); + + Runtime::new().unwrap().block_on(async { + let below_entries: Vec<_> = below.into_iter().map(|(offset, blkid, state)| { + let c = L1BlockCommitment::new( + threshold - offset, + L1BlockId::from(Buf32::from(blkid)), + ); + (c, state) + }).collect(); + + let above_entries: Vec<_> = above.into_iter().map(|(offset, blkid, state)| { + let c = L1BlockCommitment::new( + threshold + offset, + L1BlockId::from(Buf32::from(blkid)), + ); + (c, state) + }).collect(); + + for (c, state) in &below_entries { + db.store_moho_state(*c, state.clone()).await.unwrap(); + } + for (c, state) in &above_entries { + db.store_moho_state(*c, state.clone()).await.unwrap(); + } + + db.prune(threshold).await.unwrap(); + + for (c, _) in &below_entries { + let result = db.get_moho_state(*c).await.unwrap(); + prop_assert_eq!(result, None, "state at height {} should be pruned", c.height()); + } + for (c, state) in &above_entries { + let result = db.get_moho_state(*c).await.unwrap(); + prop_assert_eq!(result, Some(state.clone()), "state at height {} should survive", c.height()); + } + + Ok(()) + })?; + } + } +} From 7a81aed6fb132847aa6059910db6d205685914c6 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 14:57:58 +0545 Subject: [PATCH 05/10] refactor(moho): run Moho as a standalone worker in the runner The ASM worker materialized MohoState inline on every anchor-state write (the AsmWorkerContext piggyback), tying Moho to the ASM worker's hot path. Spin the subscription-driven MohoWorker off onto its own service task instead: it folds each ASM commit onto its resolved parent and persists the derived state, following L1 reorgs without sitting in the ASM worker's write path. Persistence moves to the dedicated strata-asm-moho-storage store; the RPC server and proof orchestrator read from it unchanged. --- Cargo.lock | 3 +- bin/asm-runner/Cargo.toml | 3 +- bin/asm-runner/src/bootstrap.rs | 42 ++++++--- bin/asm-runner/src/main.rs | 1 + bin/asm-runner/src/moho_context.rs | 134 +++++++++++++++++++++++++++ bin/asm-runner/src/prover/input.rs | 3 +- bin/asm-runner/src/rpc_server.rs | 3 +- bin/asm-runner/src/worker_context.rs | 119 ++---------------------- 8 files changed, 182 insertions(+), 126 deletions(-) create mode 100644 bin/asm-runner/src/moho_context.rs diff --git a/Cargo.lock b/Cargo.lock index ec3afabd..45450b57 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8094,7 +8094,6 @@ dependencies = [ "k256", "moho-recursive-proof", "moho-runtime-impl", - "moho-runtime-interface", "moho-types", "serde", "serde_json", @@ -8104,6 +8103,8 @@ dependencies = [ "ssz", "strata-asm-common", "strata-asm-logs", + "strata-asm-moho-storage", + "strata-asm-moho-worker", "strata-asm-params", "strata-asm-proof-db", "strata-asm-proof-impl", diff --git a/bin/asm-runner/Cargo.toml b/bin/asm-runner/Cargo.toml index dda2c6d0..255c1d34 100644 --- a/bin/asm-runner/Cargo.toml +++ b/bin/asm-runner/Cargo.toml @@ -9,13 +9,14 @@ workspace = true [dependencies] moho-recursive-proof.workspace = true moho-runtime-impl.workspace = true -moho-runtime-interface.workspace = true moho-types.workspace = true strata-asm-rpc.workspace = true strata-btc-verification.workspace = true strata-asm-common.workspace = true strata-asm-logs.workspace = true +strata-asm-moho-storage.workspace = true +strata-asm-moho-worker.workspace = true strata-asm-params.workspace = true strata-asm-proof-db.workspace = true strata-asm-proof-impl.workspace = true diff --git a/bin/asm-runner/src/bootstrap.rs b/bin/asm-runner/src/bootstrap.rs index 43c8c5e4..a825d029 100644 --- a/bin/asm-runner/src/bootstrap.rs +++ b/bin/asm-runner/src/bootstrap.rs @@ -2,8 +2,10 @@ use std::sync::Arc; use anyhow::Result; use bitcoind_async_client::{Auth, Client}; +use strata_asm_moho_storage::SledMohoStateDb; +use strata_asm_moho_worker::MohoWorkerBuilder; use strata_asm_params::AsmParams; -use strata_asm_proof_db::{SledMohoStateDb, SledProofDb}; +use strata_asm_proof_db::SledProofDb; use strata_asm_spec::StrataAsmSpec; use strata_asm_worker::AsmWorkerBuilder; use strata_tasks::TaskExecutor; @@ -16,10 +18,11 @@ use tokio::{ use crate::{ block_watcher::drive_asm_from_bitcoin, config::{AsmRpcConfig, BitcoinConfig}, + moho_context::MohoWorkerContextImpl, prover::{InputBuilder, ProofBackend, ProofOrchestrator}, rpc_server::{AsmProofRpcDeps, run_rpc_server}, storage::create_storage, - worker_context::{AsmWorkerContext, MohoStorage}, + worker_context::AsmWorkerContext, }; pub(crate) async fn bootstrap( config: AsmRpcConfig, @@ -33,8 +36,7 @@ pub(crate) async fn bootstrap( let bitcoin_client = Arc::new(connect_bitcoin(&config.bitcoin).await?); // 3. If the orchestrator is configured, open proof storage and build the proof backend up front - // so the worker can receive the moho-state db and the asm predicate. The worker owns - // moho-state writes (including the genesis seed) — see [`MohoStorage`]. + // so the Moho worker and orchestrator can receive the moho-state db and the asm predicate. let runtime_handle = Handle::current(); let orch_prep = if let Some(orch_config) = config.orchestrator { let sled_db = sled::open(&orch_config.proof_db_path)?; @@ -46,13 +48,9 @@ pub(crate) async fn bootstrap( None }; - // 4. Create the worker context, wiring moho storage when available. - let moho_storage = orch_prep.as_ref().map(|(_, _, db, backend)| MohoStorage { - db: db.clone(), - asm_predicate: backend.asm_predicate.clone(), - }); + // 4. Create the ASM worker context. Moho state is no longer materialized here; a dedicated Moho + // worker derives it from each ASM commit (step 7). let export_entries_for_worker = orch_prep.as_ref().map(|_| export_entries_db.clone()); - let genesis_height = params.anchor.block.height() as u64; // The worker aligns the DB-side ASM manifest MMR with L1 heights during // startup (`ManifestMmrStore::prefill_manifest_mmr`), so no prefill is @@ -64,8 +62,6 @@ pub(crate) async fn bootstrap( state_db.clone(), mmr_db.clone(), export_entries_for_worker, - moho_storage, - genesis_height, ); // 5. Launch ASM worker @@ -100,6 +96,28 @@ pub(crate) async fn bootstrap( moho_predicate, } = backend; + // Spin the Moho worker off onto its own service task, driven by the ASM + // worker's per-block commit stream. It derives each block's MohoState + // from the anchor state the ASM worker committed and persists it to the + // same moho-state db the orchestrator and RPC read. Subscribe before the + // block watcher is spawned (step 8): the subscription has no replay, so a + // later subscriber would miss already-committed blocks. The genesis Moho + // state is seeded from the ASM genesis anchor during launch. + let moho_context = MohoWorkerContextImpl::new( + runtime_handle.clone(), + bitcoin_client.clone(), + &config.bitcoin.retry_config, + state_db.clone(), + moho_state_db.clone(), + ); + let _moho_worker = MohoWorkerBuilder::new() + .with_context(moho_context) + .with_subscription(asm_worker.subscribe_blocks()) + .with_genesis_block(params.anchor.block) + .with_asm_predicate(asm_predicate.clone()) + .launch(&executor) + .await?; + let input_builder = InputBuilder::new( state_db.clone(), bitcoin_client.clone(), diff --git a/bin/asm-runner/src/main.rs b/bin/asm-runner/src/main.rs index f88ddfa8..e3e67a20 100644 --- a/bin/asm-runner/src/main.rs +++ b/bin/asm-runner/src/main.rs @@ -6,6 +6,7 @@ mod block_watcher; mod bootstrap; mod config; +mod moho_context; mod prover; mod retry; mod rpc_server; diff --git a/bin/asm-runner/src/moho_context.rs b/bin/asm-runner/src/moho_context.rs new file mode 100644 index 00000000..46699225 --- /dev/null +++ b/bin/asm-runner/src/moho_context.rs @@ -0,0 +1,134 @@ +//! Moho worker-context implementation for the ASM runner. +//! +//! [`MohoWorkerContextImpl`] backs the three concern traits the Moho worker +//! interfaces through ([`AsmStateProvider`], [`L1ProviderContext`], +//! [`MohoStateStore`]). It reads the ASM anchor states and logs the ASM worker +//! already committed (via [`AsmStateDb`]), resolves L1 parents from the Bitcoin +//! node, and persists derived Moho states via [`SledMohoStateDb`]. +//! +//! Unlike the ASM worker — which runs on its own thread and can block on Bitcoin +//! RPC directly — the Moho worker runs as an async service. The +//! [`MohoWorkerContext`](strata_asm_moho_worker::MohoWorkerContext) traits are +//! synchronous, so parent resolution bridges to the async client via +//! [`block_in_place`](task::block_in_place); see [`Self::get_parent_block`]. + +use std::sync::Arc; + +use asm_storage::AsmStateDb; +use bitcoin::BlockHash; +use bitcoind_async_client::{Client, error::ClientError, traits::Reader}; +use moho_types::MohoState; +use strata_asm_common::{AnchorState, AsmLogEntry}; +use strata_asm_moho_storage::SledMohoStateDb; +use strata_asm_moho_worker::{ + AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerError, MohoWorkerResult, +}; +use strata_asm_worker::AsmState; +use strata_btc_types::{BlockHashExt, L1BlockIdBitcoinExt}; +use strata_identifiers::L1BlockCommitment; +use tokio::{runtime::Handle, task}; + +use crate::retry::{ExponentialBackoff, RetryConfig, retry_with_backoff_async}; + +/// Storage and L1 access the Moho worker derives per-block Moho states from. +pub(crate) struct MohoWorkerContextImpl { + runtime_handle: Handle, + bitcoin_client: Arc, + /// Backoff schedule for Bitcoin RPC calls. + rpc_backoff: ExponentialBackoff, + /// Maximum retry attempts per Bitcoin RPC call. + rpc_max_retries: u16, + /// ASM anchor states and logs the Moho state is derived from, committed by + /// the ASM worker. + state_db: Arc, + /// Persistence for the derived per-block Moho states. + moho_state_db: SledMohoStateDb, +} + +impl MohoWorkerContextImpl { + pub(crate) fn new( + runtime_handle: Handle, + bitcoin_client: Arc, + retry: &RetryConfig, + state_db: Arc, + moho_state_db: SledMohoStateDb, + ) -> Self { + Self { + runtime_handle, + bitcoin_client, + rpc_backoff: retry.backoff(), + rpc_max_retries: retry.max_retries, + state_db, + moho_state_db, + } + } + + /// Reads the ASM state the ASM worker committed for `blockid`, mapping a + /// miss to [`MohoWorkerError::MissingAsmState`]. + fn anchor(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult { + self.state_db + .get(blockid) + .map_err(|e| MohoWorkerError::Storage(e.to_string()))? + .ok_or(MohoWorkerError::MissingAsmState(*blockid)) + } +} + +impl AsmStateProvider for MohoWorkerContextImpl { + fn get_anchor_state(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult { + Ok(self.anchor(blockid)?.state().clone()) + } + + fn get_anchor_logs(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult> { + Ok(self.anchor(blockid)?.logs().clone()) + } +} + +impl L1ProviderContext for MohoWorkerContextImpl { + fn get_parent_block(&self, block: &L1BlockCommitment) -> MohoWorkerResult { + let block_hash: BlockHash = block.blkid().to_block_hash(); + let client = &self.bitcoin_client; + + // The context traits are synchronous but the Bitcoin RPC is async, and + // the Moho worker runs as an async service — a nested `Handle::block_on` + // would panic. `block_in_place` releases the current worker thread for + // the blocking call so the runtime keeps making progress; it requires + // the multi-threaded runtime the runner builds. + let header = task::block_in_place(|| { + self.runtime_handle.block_on(retry_with_backoff_async( + "btc_get_block_header", + self.rpc_max_retries, + &self.rpc_backoff, + || async { client.get_block_header(&block_hash).await }, + )) + }) + .map_err(|_: ClientError| MohoWorkerError::MissingParentBlock(*block))?; + + let parent_id = header.prev_blockhash.to_l1_block_id(); + Ok(L1BlockCommitment::new(block.height() - 1, parent_id)) + } +} + +impl MohoStateStore for MohoWorkerContextImpl { + fn get_latest_moho_state(&self) -> MohoWorkerResult> { + self.moho_state_db + .get_latest() + .map_err(|e| MohoWorkerError::Storage(e.to_string())) + } + + fn get_moho_state(&self, blockid: &L1BlockCommitment) -> MohoWorkerResult { + self.moho_state_db + .get(*blockid) + .map_err(|e| MohoWorkerError::Storage(e.to_string()))? + .ok_or(MohoWorkerError::MissingMohoState(*blockid)) + } + + fn store_moho_state( + &self, + blockid: &L1BlockCommitment, + state: &MohoState, + ) -> MohoWorkerResult<()> { + self.moho_state_db + .store(*blockid, state.clone()) + .map_err(|e| MohoWorkerError::Storage(e.to_string())) + } +} diff --git a/bin/asm-runner/src/prover/input.rs b/bin/asm-runner/src/prover/input.rs index 7d6e289c..a2d1092b 100644 --- a/bin/asm-runner/src/prover/input.rs +++ b/bin/asm-runner/src/prover/input.rs @@ -11,7 +11,8 @@ use moho_recursive_proof::{MohoRecursiveInput, MohoRecursiveOutput}; use moho_runtime_impl::RuntimeInput; use moho_types::{MohoState, RecursiveMohoProof, StepMohoAttestation, StepMohoProof}; use ssz::{Decode, Encode}; -use strata_asm_proof_db::{MohoStateDb, ProofDb, SledMohoStateDb, SledProofDb}; +use strata_asm_moho_storage::{MohoStateDb, SledMohoStateDb}; +use strata_asm_proof_db::{ProofDb, SledProofDb}; use strata_asm_proof_impl::moho_program::input::AsmStepInput; use strata_asm_proof_types::L1Range; use strata_btc_types::{BlockHashExt, L1BlockIdBitcoinExt}; diff --git a/bin/asm-runner/src/rpc_server.rs b/bin/asm-runner/src/rpc_server.rs index d5103f12..2a2584f6 100644 --- a/bin/asm-runner/src/rpc_server.rs +++ b/bin/asm-runner/src/rpc_server.rs @@ -13,7 +13,8 @@ use jsonrpsee::{ types::{ErrorObject, ErrorObjectOwned}, }; use ssz::{Decode, Encode}; -use strata_asm_proof_db::{ProofDb, SledMohoStateDb, SledProofDb}; +use strata_asm_moho_storage::SledMohoStateDb; +use strata_asm_proof_db::{ProofDb, SledProofDb}; use strata_asm_proof_types::{AsmProof, L1Range, MohoProof}; use strata_asm_proto_bridge_v1::{AssignmentEntry, BridgeV1State, DepositEntry}; use strata_asm_proto_bridge_v1_txs::BRIDGE_V1_SUBPROTOCOL_ID; diff --git a/bin/asm-runner/src/worker_context.rs b/bin/asm-runner/src/worker_context.rs index dac16030..c8cc134f 100644 --- a/bin/asm-runner/src/worker_context.rs +++ b/bin/asm-runner/src/worker_context.rs @@ -3,56 +3,30 @@ //! Implements the four [`WorkerContext`](strata_asm_worker::WorkerContext) //! concern traits ([`L1DataProvider`], [`AnchorStateStore`], //! [`ManifestMmrStore`], [`AuxDataStore`]) for [`AsmWorkerContext`]. -//! -//! # Moho extension -//! -//! When [`MohoStorage`] is configured, we piggyback on the ASM worker: every -//! anchor-state write in [`AnchorStateStore::store_anchor_state`] also -//! materializes and persists the derived [`MohoState`] for the same -//! [`L1BlockCommitment`]. The two databases advance together under a single -//! call — Moho does not run its own driver, does not subscribe to L1, and -//! does not manage its own chain view. Whatever block sequence the ASM worker -//! decides to apply (including any future reorg handling it gains) is the -//! sequence Moho sees, for free. use std::sync::Arc; use asm_storage::{AsmStateDb, ExportEntriesDb, MmrDb}; use bitcoin::{Block, BlockHash, Network}; use bitcoind_async_client::{Client, error::ClientError, traits::Reader}; -use moho_runtime_interface::MohoProgram; -use moho_types::{ExportState, MohoState}; -use strata_asm_common::{AnchorState, AsmManifest, AsmManifestHash, AuxData}; +use strata_asm_common::{AsmManifest, AsmManifestHash, AuxData}; use strata_asm_logs::NewExportEntry; -use strata_asm_proof_db::SledMohoStateDb; -use strata_asm_proof_impl::moho_program::program::{ - AsmStfProgram, advance_export_state_with_logs, extract_next_predicate_from_logs, -}; use strata_asm_worker::{ AnchorStateStore, AsmState, AuxDataStore, L1DataProvider, ManifestMmrStore, WorkerError, WorkerResult, }; -use strata_btc_types::{BitcoinTxid, BlockHashExt, L1BlockIdBitcoinExt, RawBitcoinTx}; +use strata_btc_types::{BitcoinTxid, L1BlockIdBitcoinExt, RawBitcoinTx}; use strata_identifiers::{L1BlockCommitment, L1BlockId}; use strata_merkle::MerkleProofB32; -use strata_predicate::PredicateKey; use tokio::runtime::Handle; use crate::retry::{ExponentialBackoff, RetryConfig, retry_with_backoff_async}; -/// Dependencies the worker needs to materialize per-block [`MohoState`] -/// alongside each anchor state. `asm_predicate` is used only to seed the -/// genesis entry; every subsequent block is chain-forward from the parent. -pub(crate) struct MohoStorage { - pub db: SledMohoStateDb, - pub asm_predicate: PredicateKey, -} - /// ASM [`WorkerContext`](strata_asm_worker::WorkerContext) implementation. /// /// Fetches L1 blocks from a Bitcoin node and persists state via local sled -/// storage. When [`MohoStorage`] is supplied, each anchor-state write also -/// materializes the derived [`MohoState`] for the same block. +/// storage. Moho state is derived separately by the Moho worker; see +/// [`moho_context`](crate::moho_context). pub(crate) struct AsmWorkerContext { runtime_handle: Handle, bitcoin_client: Arc, @@ -63,16 +37,9 @@ pub(crate) struct AsmWorkerContext { state_db: Arc, mmr_db: Arc, export_entries_db: Option, - moho_storage: Option, - /// L1 height of the chain genesis (anchor) block. - genesis_height: u64, } impl AsmWorkerContext { - #[expect( - clippy::too_many_arguments, - reason = "constructor wires every dependency the worker holds; one call site" - )] pub(crate) fn new( runtime_handle: Handle, bitcoin_client: Arc, @@ -80,8 +47,6 @@ impl AsmWorkerContext { state_db: Arc, mmr_db: Arc, export_entries_db: Option, - moho_storage: Option, - genesis_height: u64, ) -> Self { Self { runtime_handle, @@ -91,51 +56,8 @@ impl AsmWorkerContext { state_db, mmr_db, export_entries_db, - moho_storage, - genesis_height, } } - - /// Materialize and persist the derived [`MohoState`] for this anchor state. - /// No-op when [`MohoStorage`] is not configured. - /// - /// Genesis is identified by the block commitment's height matching the - /// configured `genesis_height`. For non-genesis blocks we read the parent's - /// `MohoState` and chain forward. - fn compute_and_store_moho_state( - &self, - blockid: &L1BlockCommitment, - asm_state: &AsmState, - ) -> WorkerResult<()> { - let Some(moho) = &self.moho_storage else { - return Ok(()); - }; - - let genesis_height = self.genesis_height; - - let moho_state = if blockid.height() as u64 == genesis_height { - construct_genesis_moho_state(moho.asm_predicate.clone(), asm_state.state()) - } else { - let block = self.get_l1_block(blockid.blkid())?; - let parent = L1BlockCommitment::new( - blockid.height() - 1, - block.header.prev_blockhash.to_l1_block_id(), - ); - - let prev_moho = moho - .db - .get(parent) - .map_err(|_| WorkerError::DbError)? - .ok_or(WorkerError::DbError)?; // TODO(STR-3124): use appropriate error types after fixing the piggybanking on ASM worker - construct_next_moho_state(&prev_moho, asm_state) - }; - - moho.db - .store(*blockid, moho_state) - .map_err(|_| WorkerError::DbError)?; - - Ok(()) - } } impl L1DataProvider for AsmWorkerContext { @@ -202,16 +124,15 @@ impl AnchorStateStore for AsmWorkerContext { blockid: &L1BlockCommitment, state: &AsmState, ) -> WorkerResult<()> { - // Write order matters: moho and export_entries first, then anchor. The worker tracks + // Write order matters: export_entries first, then anchor. The worker tracks // progress via the anchor db (see get_latest_asm_state), so the anchor write is the // effective commit point for this block. If we crash before it, progress has not // advanced, so on restart the worker reprocesses this block and overwrites the // orphaned entries with the same values. Reversing the order would risk advancing - // progress past a block whose moho or export_entries state was never persisted. - self.compute_and_store_moho_state(blockid, state)?; - - // Index each `NewExportEntry` alongside the MohoState's compact MMR so - // the RPC can regenerate inclusion proofs later. + // progress past a block whose export_entries state was never persisted. + // + // Index each `NewExportEntry` so the RPC can later regenerate inclusion proofs + // against the MohoState compact MMR the Moho worker maintains. if let Some(ref export_entries_db) = self.export_entries_db { for log in state.logs() { if let Ok(export) = log.try_into_log::() { @@ -289,25 +210,3 @@ impl AuxDataStore for AsmWorkerContext { .ok_or(WorkerError::MissingAuxData(*blockid)) } } - -/// Seed the genesis [`MohoState`]: no prior state to chain forward from, so we -/// use the configured `asm_predicate` and an empty export state. -fn construct_genesis_moho_state( - asm_predicate: PredicateKey, - genesis_anchor_state: &AnchorState, -) -> MohoState { - let inner = AsmStfProgram::compute_state_commitment(genesis_anchor_state); - let export_state = ExportState::new(vec![]).expect("empty export state is always valid"); - MohoState::new(inner, asm_predicate, export_state) -} - -/// Chain-forward the [`MohoState`]: let STF logs drive predicate and export -/// state updates, and recompute the inner commitment from the new anchor state. -fn construct_next_moho_state(prev_moho: &MohoState, state: &AsmState) -> MohoState { - let next_predicate = extract_next_predicate_from_logs(state.logs()) - .unwrap_or_else(|| prev_moho.next_predicate().clone()); - let next_export_state = - advance_export_state_with_logs(prev_moho.export_state().clone(), state.logs()); - let inner = AsmStfProgram::compute_state_commitment(state.state()); - MohoState::new(inner, next_predicate, next_export_state) -} From 94f126c1944f6fcd041f836ef915fcb6204c219b Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 14:58:32 +0545 Subject: [PATCH 06/10] refactor(proof-db): drop the moho-state store now owned by the moho worker MohoState persistence moved to the dedicated strata-asm-moho-storage crate, so strata-asm-proof-db no longer needs its own copy. Remove the MohoStateDb trait and SledMohoStateDb (the moho-proof code stays) along with the deps they alone pulled in. Both stores key the same "moho_states" tree with identical encoding, so existing databases carry over unchanged. --- Cargo.lock | 3 - crates/proof/db/Cargo.toml | 3 - crates/proof/db/src/lib.rs | 19 +-- crates/proof/db/src/moho_state.rs | 33 ---- crates/proof/db/src/sled/mod.rs | 12 +- crates/proof/db/src/sled/moho_state.rs | 208 ------------------------- 6 files changed, 10 insertions(+), 268 deletions(-) delete mode 100644 crates/proof/db/src/moho_state.rs delete mode 100644 crates/proof/db/src/sled/moho_state.rs diff --git a/Cargo.lock b/Cargo.lock index 45450b57..9f3ea628 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7770,13 +7770,10 @@ name = "strata-asm-proof-db" version = "0.1.0" dependencies = [ "borsh", - "moho-types", "proptest", "sled", - "ssz", "strata-asm-proof-types", "strata-identifiers", - "strata-predicate", "tempfile", "tokio", "zkaleido", diff --git a/crates/proof/db/Cargo.toml b/crates/proof/db/Cargo.toml index 87decc6d..4162ecc0 100644 --- a/crates/proof/db/Cargo.toml +++ b/crates/proof/db/Cargo.toml @@ -4,18 +4,15 @@ version = "0.1.0" edition = "2024" [dependencies] -moho-types.workspace = true strata-asm-proof-types.workspace = true strata-identifiers.workspace = true borsh.workspace = true sled.workspace = true -ssz.workspace = true zkaleido.workspace = true [dev-dependencies] proptest.workspace = true -strata-predicate.workspace = true tempfile.workspace = true tokio.workspace = true diff --git a/crates/proof/db/src/lib.rs b/crates/proof/db/src/lib.rs index c7906d60..628db58f 100644 --- a/crates/proof/db/src/lib.rs +++ b/crates/proof/db/src/lib.rs @@ -13,27 +13,20 @@ //! - [`RemoteProofStatusDb`] — tracks the execution status of in-flight remote proof jobs until //! their results are retrieved and stored locally. //! -//! A fourth trait, [`MohoStateDb`], persists per-block [`moho_types::MohoState`] -//! snapshots derived by the worker. It is deliberately kept separate from -//! [`ProofDb`] because the underlying data is materialised state, not a proof -//! artifact. -//! -//! Sled-backed implementations are provided: [`SledProofDb`] for proofs and -//! [`SledMohoStateDb`] for Moho-state snapshots. To back both with a single -//! sled directory, open the `sled::Db` yourself and pass it to -//! `SledProofDb::from_db` and `SledMohoStateDb::from_db` — sled does not -//! allow the same path to be opened twice in a process. +//! A sled-backed implementation, [`SledProofDb`], is provided. Per-block +//! `MohoState` snapshots are persisted separately by `strata-asm-moho-storage`; +//! both can share one sled directory by opening the `sled::Db` yourself and +//! passing it to each — sled does not allow the same path to be opened twice in +//! a process. -mod moho_state; mod proof_db; mod remote_mapping; mod remote_status; mod sled; pub use self::{ - moho_state::MohoStateDb, proof_db::ProofDb, remote_mapping::RemoteProofMappingDb, remote_status::RemoteProofStatusDb, - sled::{RemoteProofMappingError, RemoteProofStatusError, SledMohoStateDb, SledProofDb}, + sled::{RemoteProofMappingError, RemoteProofStatusError, SledProofDb}, }; diff --git a/crates/proof/db/src/moho_state.rs b/crates/proof/db/src/moho_state.rs deleted file mode 100644 index c64ba6dc..00000000 --- a/crates/proof/db/src/moho_state.rs +++ /dev/null @@ -1,33 +0,0 @@ -//! Storage trait for Moho state snapshots. -//! -//! Each entry records the [`MohoState`] that was computed after processing the -//! L1 block identified by the given [`L1BlockCommitment`]. - -use std::fmt::Debug; - -use moho_types::MohoState; -use strata_identifiers::L1BlockCommitment; - -/// Persistence interface for Moho state storage. -pub trait MohoStateDb { - /// The error type returned by database operations. - type Error: Debug; - - /// Stores the Moho state anchored at the given L1 block commitment. - fn store_moho_state( - &self, - l1ref: L1BlockCommitment, - state: MohoState, - ) -> impl Future> + Send; - - /// Retrieves the Moho state for the given L1 block commitment, if one exists. - fn get_moho_state( - &self, - l1ref: L1BlockCommitment, - ) -> impl Future, Self::Error>> + Send; - - /// Prunes all Moho state entries for blocks before the given height. - /// - /// Deletes all entries with height strictly less than `before_height`. - fn prune(&self, before_height: u32) -> impl Future> + Send; -} diff --git a/crates/proof/db/src/sled/mod.rs b/crates/proof/db/src/sled/mod.rs index 099c4e2d..18ed4b62 100644 --- a/crates/proof/db/src/sled/mod.rs +++ b/crates/proof/db/src/sled/mod.rs @@ -8,15 +8,11 @@ use strata_asm_proof_types::L1Range; use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; -mod moho_state; mod proof_db; mod remote_mapping; mod remote_status; -pub use self::{ - moho_state::SledMohoStateDb, remote_mapping::RemoteProofMappingError, - remote_status::RemoteProofStatusError, -}; +pub use self::{remote_mapping::RemoteProofMappingError, remote_status::RemoteProofStatusError}; /// Sled-backed proof database. /// @@ -41,9 +37,9 @@ pub struct SledProofDb { impl SledProofDb { /// Opens the proof trees on an already-open sled database. /// - /// Callers open the [`sled::Db`] themselves so multiple handles — e.g. - /// [`SledMohoStateDb`] — can share the same on-disk directory; sled does - /// not allow opening the same path twice in a process. + /// Callers open the [`sled::Db`] themselves so multiple handles — e.g. the + /// `strata-asm-moho-storage` state store — can share the same on-disk + /// directory; sled does not allow opening the same path twice in a process. pub fn open(db: &sled::Db) -> Result { Ok(Self { asm_proofs: db.open_tree("asm_proofs")?, diff --git a/crates/proof/db/src/sled/moho_state.rs b/crates/proof/db/src/sled/moho_state.rs deleted file mode 100644 index d6de7e63..00000000 --- a/crates/proof/db/src/sled/moho_state.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! [`MohoStateDb`] implementation backed by sled. - -use moho_types::MohoState; -use ssz::{Decode, Encode}; -use strata_identifiers::L1BlockCommitment; - -use super::encode_moho_key; -use crate::MohoStateDb; - -/// Sled-backed store for [`MohoState`] snapshots keyed by [`L1BlockCommitment`]. -/// -/// Values are SSZ-encoded; keys use the same big-endian height encoding as the -/// proof trees so lexicographic range scans match block-height ordering. -#[derive(Debug, Clone)] -pub struct SledMohoStateDb { - moho_states: sled::Tree, -} - -impl SledMohoStateDb { - /// Opens the Moho-state tree on an already-open sled database. - /// - /// Callers open the [`sled::Db`] themselves so multiple handles — e.g. - /// [`super::SledProofDb`] — can share the same on-disk directory; sled - /// does not allow opening the same path twice in a process. - pub fn open(db: &sled::Db) -> Result { - Ok(Self { - moho_states: db.open_tree("moho_states")?, - }) - } - - /// Synchronous variant of [`MohoStateDb::store_moho_state`]. The ASM - /// worker runs on a sync thread (via `ServiceBuilder::launch_sync`) and - /// its genesis-seed path is invoked from an async bootstrap task, where - /// `Handle::block_on` would panic. Calling this directly avoids that. - pub fn store(&self, l1ref: L1BlockCommitment, state: MohoState) -> Result<(), sled::Error> { - self.moho_states - .insert(encode_moho_key(&l1ref), state.as_ssz_bytes())?; - Ok(()) - } - - /// Synchronous variant of [`MohoStateDb::get_moho_state`]. See [`Self::store`]. - pub fn get(&self, l1ref: L1BlockCommitment) -> Result, sled::Error> { - Ok(self - .moho_states - .get(encode_moho_key(&l1ref))? - .map(|v| MohoState::from_ssz_bytes(&v).expect("stored state should be valid SSZ"))) - } - - /// Synchronous variant of [`MohoStateDb::prune`]. See [`Self::store`]. - pub fn prune_before(&self, before_height: u32) -> Result<(), sled::Error> { - let upper: &[u8] = &before_height.to_be_bytes(); - - for entry in self.moho_states.range(..upper) { - let (key, _) = entry?; - self.moho_states.remove(&key)?; - } - - Ok(()) - } -} - -impl MohoStateDb for SledMohoStateDb { - type Error = sled::Error; - - async fn store_moho_state( - &self, - l1ref: L1BlockCommitment, - state: MohoState, - ) -> Result<(), Self::Error> { - self.store(l1ref, state) - } - - async fn get_moho_state( - &self, - l1ref: L1BlockCommitment, - ) -> Result, Self::Error> { - self.get(l1ref) - } - - async fn prune(&self, before_height: u32) -> Result<(), Self::Error> { - self.prune_before(before_height) - } -} - -#[cfg(test)] -mod tests { - use moho_types::{ExportState, InnerStateCommitment, MohoState}; - use proptest::{collection::vec, prelude::*}; - use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; - use strata_predicate::PredicateKey; - use tokio::runtime::Runtime; - - use super::*; - use crate::sled::test_util::*; - - /// Creates an isolated [`SledMohoStateDb`] backed by a temporary directory. - fn temp_moho_db() -> (SledMohoStateDb, tempfile::TempDir) { - let dir = tempfile::tempdir().expect("failed to create temp dir"); - let db = sled::open(dir.path()).expect("failed to open sled db"); - let moho_db = SledMohoStateDb::open(&db).expect("failed to open moho state tree"); - (moho_db, dir) - } - - /// Generates an arbitrary [`MohoState`]. - fn arb_moho_state() -> impl Strategy { - any::<[u8; 32]>().prop_map(|inner| { - MohoState::new( - InnerStateCommitment::from(inner), - PredicateKey::always_accept(), - ExportState::new(vec![]).unwrap(), - ) - }) - } - - proptest! { - #![proptest_config(ProptestConfig::with_cases(50))] - - /// Property: a stored Moho state can be retrieved with the same commitment key. - #[test] - fn moho_state_roundtrip( - commitment in arb_l1_block_commitment(), - state in arb_moho_state(), - ) { - let (db, _dir) = temp_moho_db(); - - Runtime::new().unwrap().block_on(async { - db.store_moho_state(commitment, state.clone()).await.unwrap(); - - let retrieved = db.get_moho_state(commitment).await.unwrap(); - - prop_assert_eq!(Some(state), retrieved); - - Ok(()) - })?; - } - - /// Property: querying a commitment that was never stored returns `None`. - #[test] - fn get_missing_moho_state_returns_none( - commitment in arb_l1_block_commitment(), - ) { - let (db, _dir) = temp_moho_db(); - - Runtime::new().unwrap().block_on(async { - let result = db.get_moho_state(commitment).await.unwrap(); - - prop_assert_eq!(result, None); - - Ok(()) - })?; - } - - /// Property: prune removes entries with height < threshold and preserves - /// those with height >= threshold. - #[test] - fn prune_removes_entries_below_threshold( - threshold in 100u32..499_999_900u32, - below in vec( - (1u32..100u32, any::<[u8; 32]>(), arb_moho_state()), - 1..4, - ), - above in vec( - (0u32..100u32, any::<[u8; 32]>(), arb_moho_state()), - 1..4, - ), - ) { - let (db, _dir) = temp_moho_db(); - - Runtime::new().unwrap().block_on(async { - let below_entries: Vec<_> = below.into_iter().map(|(offset, blkid, state)| { - let c = L1BlockCommitment::new( - threshold - offset, - L1BlockId::from(Buf32::from(blkid)), - ); - (c, state) - }).collect(); - - let above_entries: Vec<_> = above.into_iter().map(|(offset, blkid, state)| { - let c = L1BlockCommitment::new( - threshold + offset, - L1BlockId::from(Buf32::from(blkid)), - ); - (c, state) - }).collect(); - - for (c, state) in &below_entries { - db.store_moho_state(*c, state.clone()).await.unwrap(); - } - for (c, state) in &above_entries { - db.store_moho_state(*c, state.clone()).await.unwrap(); - } - - db.prune(threshold).await.unwrap(); - - for (c, _) in &below_entries { - let result = db.get_moho_state(*c).await.unwrap(); - prop_assert_eq!(result, None, "state at height {} should be pruned", c.height()); - } - for (c, state) in &above_entries { - let result = db.get_moho_state(*c).await.unwrap(); - prop_assert_eq!(result, Some(state.clone()), "state at height {} should survive", c.height()); - } - - Ok(()) - })?; - } - } -} From 3319ed5cceaf6cf3ab075bf326d6c40b66a719df Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 15:24:30 +0545 Subject: [PATCH 07/10] refactor(moho): fold the export-entries index into the moho worker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The export-entries index mirrors the leaves of each MohoState's ExportState MMR: both derive from the same NewExportEntry logs and must advance from the same iteration, or the per-container indices drift from the MMR that commits to them and inclusion proofs break. MohoState derivation moved to the Moho worker, but the index was still written by AsmWorkerContext on the ASM worker — splitting one unit across two tasks. Move ExportEntriesDb from asm-storage into strata-asm-moho-storage and the indexing into the worker's per-block fold via a new ExportEntryStore concern, written before the Moho-state commit point so a reprocessed block re-appends idempotently. AsmWorkerContext::store_anchor_state now just writes the anchor. --- Cargo.lock | 6 ++- bin/asm-runner/Cargo.toml | 1 - bin/asm-runner/src/bootstrap.rs | 20 +++++----- bin/asm-runner/src/moho_context.rs | 24 ++++++++++- bin/asm-runner/src/rpc_server.rs | 4 +- bin/asm-runner/src/storage.rs | 3 +- bin/asm-runner/src/worker_context.rs | 33 ++------------- crates/extensions/moho/storage/Cargo.toml | 3 ++ .../moho}/storage/src/export_entries.rs | 0 crates/extensions/moho/storage/src/lib.rs | 13 ++++-- crates/extensions/moho/worker/Cargo.toml | 1 + crates/extensions/moho/worker/src/compute.rs | 14 +++++++ crates/extensions/moho/worker/src/lib.rs | 16 +++++--- crates/extensions/moho/worker/src/service.rs | 15 ++++++- crates/extensions/moho/worker/src/state.rs | 19 ++++++++- crates/extensions/moho/worker/src/traits.rs | 40 ++++++++++++++++--- crates/storage/Cargo.toml | 1 - crates/storage/src/lib.rs | 9 +++-- 18 files changed, 150 insertions(+), 72 deletions(-) rename crates/{ => extensions/moho}/storage/src/export_entries.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index 9f3ea628..8b08f200 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -769,7 +769,6 @@ dependencies = [ "anyhow", "borsh", "sled", - "ssz", "strata-asm-common", "strata-asm-worker", "strata-identifiers", @@ -7713,11 +7712,14 @@ dependencies = [ name = "strata-asm-moho-storage" version = "0.1.0" dependencies = [ + "anyhow", "moho-types", "proptest", "sled", "ssz", "strata-identifiers", + "strata-merkle", + "strata-merkle-node-store", "strata-predicate", "tempfile", "tokio", @@ -7732,6 +7734,7 @@ dependencies = [ "moho-types", "serde", "strata-asm-common", + "strata-asm-logs", "strata-asm-params", "strata-asm-proof-impl", "strata-asm-spec", @@ -8099,7 +8102,6 @@ dependencies = [ "sp1-verifier", "ssz", "strata-asm-common", - "strata-asm-logs", "strata-asm-moho-storage", "strata-asm-moho-worker", "strata-asm-params", diff --git a/bin/asm-runner/Cargo.toml b/bin/asm-runner/Cargo.toml index 255c1d34..8c62c9ec 100644 --- a/bin/asm-runner/Cargo.toml +++ b/bin/asm-runner/Cargo.toml @@ -14,7 +14,6 @@ strata-asm-rpc.workspace = true strata-btc-verification.workspace = true strata-asm-common.workspace = true -strata-asm-logs.workspace = true strata-asm-moho-storage.workspace = true strata-asm-moho-worker.workspace = true strata-asm-params.workspace = true diff --git a/bin/asm-runner/src/bootstrap.rs b/bin/asm-runner/src/bootstrap.rs index a825d029..b273cab9 100644 --- a/bin/asm-runner/src/bootstrap.rs +++ b/bin/asm-runner/src/bootstrap.rs @@ -48,10 +48,9 @@ pub(crate) async fn bootstrap( None }; - // 4. Create the ASM worker context. Moho state is no longer materialized here; a dedicated Moho - // worker derives it from each ASM commit (step 7). - let export_entries_for_worker = orch_prep.as_ref().map(|_| export_entries_db.clone()); - + // 4. Create the ASM worker context. Moho state and the export-entries index are no longer + // materialized here; a dedicated Moho worker derives both from each ASM commit (step 7). + // // The worker aligns the DB-side ASM manifest MMR with L1 heights during // startup (`ManifestMmrStore::prefill_manifest_mmr`), so no prefill is // needed here. @@ -61,7 +60,6 @@ pub(crate) async fn bootstrap( &config.bitcoin.retry_config, state_db.clone(), mmr_db.clone(), - export_entries_for_worker, ); // 5. Launch ASM worker @@ -98,17 +96,19 @@ pub(crate) async fn bootstrap( // Spin the Moho worker off onto its own service task, driven by the ASM // worker's per-block commit stream. It derives each block's MohoState - // from the anchor state the ASM worker committed and persists it to the - // same moho-state db the orchestrator and RPC read. Subscribe before the - // block watcher is spawned (step 8): the subscription has no replay, so a - // later subscriber would miss already-committed blocks. The genesis Moho - // state is seeded from the ASM genesis anchor during launch. + // (and the export-entry leaves its ExportState MMR commits to) from the + // anchor state the ASM worker committed, and persists both to the same + // stores the orchestrator and RPC read. Subscribe before the block + // watcher is spawned (step 8): the subscription has no replay, so a later + // subscriber would miss already-committed blocks. The genesis Moho state + // is seeded from the ASM genesis anchor during launch. let moho_context = MohoWorkerContextImpl::new( runtime_handle.clone(), bitcoin_client.clone(), &config.bitcoin.retry_config, state_db.clone(), moho_state_db.clone(), + export_entries_db.clone(), ); let _moho_worker = MohoWorkerBuilder::new() .with_context(moho_context) diff --git a/bin/asm-runner/src/moho_context.rs b/bin/asm-runner/src/moho_context.rs index 46699225..a612e413 100644 --- a/bin/asm-runner/src/moho_context.rs +++ b/bin/asm-runner/src/moho_context.rs @@ -19,9 +19,10 @@ use bitcoin::BlockHash; use bitcoind_async_client::{Client, error::ClientError, traits::Reader}; use moho_types::MohoState; use strata_asm_common::{AnchorState, AsmLogEntry}; -use strata_asm_moho_storage::SledMohoStateDb; +use strata_asm_moho_storage::{ExportEntriesDb, SledMohoStateDb}; use strata_asm_moho_worker::{ - AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerError, MohoWorkerResult, + AsmStateProvider, ExportEntryStore, L1ProviderContext, MohoStateStore, MohoWorkerError, + MohoWorkerResult, }; use strata_asm_worker::AsmState; use strata_btc_types::{BlockHashExt, L1BlockIdBitcoinExt}; @@ -43,6 +44,9 @@ pub(crate) struct MohoWorkerContextImpl { state_db: Arc, /// Persistence for the derived per-block Moho states. moho_state_db: SledMohoStateDb, + /// Persistence for the per-container export-entry leaves the Moho state's + /// `ExportState` MMR commits to. + export_entries_db: ExportEntriesDb, } impl MohoWorkerContextImpl { @@ -52,6 +56,7 @@ impl MohoWorkerContextImpl { retry: &RetryConfig, state_db: Arc, moho_state_db: SledMohoStateDb, + export_entries_db: ExportEntriesDb, ) -> Self { Self { runtime_handle, @@ -60,6 +65,7 @@ impl MohoWorkerContextImpl { rpc_max_retries: retry.max_retries, state_db, moho_state_db, + export_entries_db, } } @@ -132,3 +138,17 @@ impl MohoStateStore for MohoWorkerContextImpl { .map_err(|e| MohoWorkerError::Storage(e.to_string())) } } + +impl ExportEntryStore for MohoWorkerContextImpl { + fn append_export_entry( + &self, + container_id: u8, + height: u32, + entry: [u8; 32], + ) -> MohoWorkerResult<()> { + self.export_entries_db + .append(container_id, height, entry) + .map(|_index| ()) + .map_err(|e| MohoWorkerError::Storage(e.to_string())) + } +} diff --git a/bin/asm-runner/src/rpc_server.rs b/bin/asm-runner/src/rpc_server.rs index 2a2584f6..b4de6268 100644 --- a/bin/asm-runner/src/rpc_server.rs +++ b/bin/asm-runner/src/rpc_server.rs @@ -3,7 +3,7 @@ use std::{fmt::Display, sync::Arc, time::Instant}; use anyhow::Result; -use asm_storage::{AsmStateDb, ExportEntriesDb}; +use asm_storage::AsmStateDb; use async_trait::async_trait; use bitcoin::BlockHash; use bitcoind_async_client::{Client, traits::Reader}; @@ -13,7 +13,7 @@ use jsonrpsee::{ types::{ErrorObject, ErrorObjectOwned}, }; use ssz::{Decode, Encode}; -use strata_asm_moho_storage::SledMohoStateDb; +use strata_asm_moho_storage::{ExportEntriesDb, SledMohoStateDb}; use strata_asm_proof_db::{ProofDb, SledProofDb}; use strata_asm_proof_types::{AsmProof, L1Range, MohoProof}; use strata_asm_proto_bridge_v1::{AssignmentEntry, BridgeV1State, DepositEntry}; diff --git a/bin/asm-runner/src/storage.rs b/bin/asm-runner/src/storage.rs index d1b2b154..d0dcd640 100644 --- a/bin/asm-runner/src/storage.rs +++ b/bin/asm-runner/src/storage.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use anyhow::Result; -use asm_storage::{AsmStateDb, ExportEntriesDb, MmrDb}; +use asm_storage::{AsmStateDb, MmrDb}; +use strata_asm_moho_storage::ExportEntriesDb; use crate::config::DatabaseConfig; diff --git a/bin/asm-runner/src/worker_context.rs b/bin/asm-runner/src/worker_context.rs index c8cc134f..ae4a10ae 100644 --- a/bin/asm-runner/src/worker_context.rs +++ b/bin/asm-runner/src/worker_context.rs @@ -6,11 +6,10 @@ use std::sync::Arc; -use asm_storage::{AsmStateDb, ExportEntriesDb, MmrDb}; +use asm_storage::{AsmStateDb, MmrDb}; use bitcoin::{Block, BlockHash, Network}; use bitcoind_async_client::{Client, error::ClientError, traits::Reader}; use strata_asm_common::{AsmManifest, AsmManifestHash, AuxData}; -use strata_asm_logs::NewExportEntry; use strata_asm_worker::{ AnchorStateStore, AsmState, AuxDataStore, L1DataProvider, ManifestMmrStore, WorkerError, WorkerResult, @@ -25,8 +24,8 @@ use crate::retry::{ExponentialBackoff, RetryConfig, retry_with_backoff_async}; /// ASM [`WorkerContext`](strata_asm_worker::WorkerContext) implementation. /// /// Fetches L1 blocks from a Bitcoin node and persists state via local sled -/// storage. Moho state is derived separately by the Moho worker; see -/// [`moho_context`](crate::moho_context). +/// storage. Moho state and the export-entries index are derived separately by +/// the Moho worker; see [`moho_context`](crate::moho_context). pub(crate) struct AsmWorkerContext { runtime_handle: Handle, bitcoin_client: Arc, @@ -36,7 +35,6 @@ pub(crate) struct AsmWorkerContext { rpc_max_retries: u16, state_db: Arc, mmr_db: Arc, - export_entries_db: Option, } impl AsmWorkerContext { @@ -46,7 +44,6 @@ impl AsmWorkerContext { retry: &RetryConfig, state_db: Arc, mmr_db: Arc, - export_entries_db: Option, ) -> Self { Self { runtime_handle, @@ -55,7 +52,6 @@ impl AsmWorkerContext { rpc_max_retries: retry.max_retries, state_db, mmr_db, - export_entries_db, } } } @@ -124,29 +120,6 @@ impl AnchorStateStore for AsmWorkerContext { blockid: &L1BlockCommitment, state: &AsmState, ) -> WorkerResult<()> { - // Write order matters: export_entries first, then anchor. The worker tracks - // progress via the anchor db (see get_latest_asm_state), so the anchor write is the - // effective commit point for this block. If we crash before it, progress has not - // advanced, so on restart the worker reprocesses this block and overwrites the - // orphaned entries with the same values. Reversing the order would risk advancing - // progress past a block whose export_entries state was never persisted. - // - // Index each `NewExportEntry` so the RPC can later regenerate inclusion proofs - // against the MohoState compact MMR the Moho worker maintains. - if let Some(ref export_entries_db) = self.export_entries_db { - for log in state.logs() { - if let Ok(export) = log.try_into_log::() { - export_entries_db - .append( - export.container_id(), - blockid.height(), - *export.entry_data(), - ) - .map_err(|_| WorkerError::DbError)?; - } - } - } - self.state_db .put(blockid, state) .map_err(|_| WorkerError::DbError)?; diff --git a/crates/extensions/moho/storage/Cargo.toml b/crates/extensions/moho/storage/Cargo.toml index ca07b562..3493a967 100644 --- a/crates/extensions/moho/storage/Cargo.toml +++ b/crates/extensions/moho/storage/Cargo.toml @@ -6,7 +6,10 @@ edition = "2024" [dependencies] moho-types.workspace = true strata-identifiers.workspace = true +strata-merkle = { workspace = true, features = ["ssz"] } +strata-merkle-node-store.workspace = true +anyhow.workspace = true sled.workspace = true ssz.workspace = true diff --git a/crates/storage/src/export_entries.rs b/crates/extensions/moho/storage/src/export_entries.rs similarity index 100% rename from crates/storage/src/export_entries.rs rename to crates/extensions/moho/storage/src/export_entries.rs diff --git a/crates/extensions/moho/storage/src/lib.rs b/crates/extensions/moho/storage/src/lib.rs index 21544629..6db34fa4 100644 --- a/crates/extensions/moho/storage/src/lib.rs +++ b/crates/extensions/moho/storage/src/lib.rs @@ -1,13 +1,18 @@ -//! Persistence layer for Moho state snapshots. +//! Persistence layer for the Moho worker. //! //! The Moho worker derives a [`moho_types::MohoState`] for each L1 block it //! processes and persists it here, keyed by the block's -//! [`L1BlockCommitment`](strata_identifiers::L1BlockCommitment). +//! [`L1BlockCommitment`](strata_identifiers::L1BlockCommitment). Alongside it +//! the worker mirrors the per-container export-entry leaves of the state's +//! `ExportState` MMR so the RPC can rebuild inclusion proofs on demand. //! -//! - [`MohoStateDb`] — the storage trait, parameterised over an associated error type. +//! - [`MohoStateDb`] — the Moho-state storage trait, parameterised over an associated error type. //! - [`SledMohoStateDb`] — a [sled](https://docs.rs/sled)-backed implementation. +//! - [`ExportEntriesDb`] — the per-container export-entry index mirroring the `ExportState` MMR +//! leaves. +mod export_entries; mod moho_state; mod sled; -pub use self::{moho_state::MohoStateDb, sled::SledMohoStateDb}; +pub use self::{export_entries::ExportEntriesDb, moho_state::MohoStateDb, sled::SledMohoStateDb}; diff --git a/crates/extensions/moho/worker/Cargo.toml b/crates/extensions/moho/worker/Cargo.toml index 6fb187b3..26c990a9 100644 --- a/crates/extensions/moho/worker/Cargo.toml +++ b/crates/extensions/moho/worker/Cargo.toml @@ -8,6 +8,7 @@ workspace = true [dependencies] strata-asm-common.workspace = true +strata-asm-logs.workspace = true strata-asm-proof-impl.workspace = true strata-asm-worker.workspace = true strata-identifiers.workspace = true diff --git a/crates/extensions/moho/worker/src/compute.rs b/crates/extensions/moho/worker/src/compute.rs index 653d1c82..c8091653 100644 --- a/crates/extensions/moho/worker/src/compute.rs +++ b/crates/extensions/moho/worker/src/compute.rs @@ -9,6 +9,7 @@ use moho_runtime_interface::MohoProgram; use moho_types::{ExportState, MohoState}; use strata_asm_common::{AnchorState, AsmLogEntry}; +use strata_asm_logs::NewExportEntry; use strata_asm_proof_impl::moho_program::program::{ AsmStfProgram, advance_export_state_with_logs, extract_next_predicate_from_logs, }; @@ -40,3 +41,16 @@ pub(crate) fn construct_next_moho_state( let inner = AsmStfProgram::compute_state_commitment(anchor_state); MohoState::new(inner, next_predicate, next_export_state) } + +/// Extracts the `(container_id, entry)` leaves a block's [`NewExportEntry`] logs +/// append to the `ExportState` MMR, in log order. +/// +/// These are the same leaves [`advance_export_state_with_logs`] folds into the +/// state's compact per-container MMR; the worker persists them so the RPC can +/// rebuild inclusion proofs the compact MMR no longer carries. +pub(crate) fn export_entries_from_logs(logs: &[AsmLogEntry]) -> Vec<(u8, [u8; 32])> { + logs.iter() + .filter_map(|log| log.try_into_log::().ok()) + .map(|entry| (entry.container_id(), *entry.entry_data())) + .collect() +} diff --git a/crates/extensions/moho/worker/src/lib.rs b/crates/extensions/moho/worker/src/lib.rs index 03d34dfe..1e833698 100644 --- a/crates/extensions/moho/worker/src/lib.rs +++ b/crates/extensions/moho/worker/src/lib.rs @@ -7,14 +7,16 @@ //! ([`Subscription`](strata_asm_worker::Subscription)) and, //! for each committed block, derives the Moho state from the ASM anchor state //! the ASM worker already persisted, chained onto the Moho state of the block's -//! parent, then stores it. It runs no chain view of its own: it folds each -//! commit onto its resolved parent, so it follows L1 reorgs rather than assuming -//! the commits arrive in unbroken height order. +//! parent, then stores it — together with the per-container export-entry leaves +//! the state's `ExportState` MMR commits to. It runs no chain view of its own: +//! it folds each commit onto its resolved parent, so it follows L1 reorgs rather +//! than assuming the commits arrive in unbroken height order. //! //! Storage is supplied by the caller through [`MohoWorkerContext`] — read access //! to ASM anchor states ([`AsmStateProvider`]), L1 block ancestry -//! ([`L1ProviderContext`]), and persistence for the derived Moho states -//! ([`MohoStateStore`]) — mirroring how `strata-asm-worker` takes a +//! ([`L1ProviderContext`]), persistence for the derived Moho states +//! ([`MohoStateStore`]), and persistence for the export-entry leaves +//! ([`ExportEntryStore`]) — mirroring how `strata-asm-worker` takes a //! [`WorkerContext`](strata_asm_worker::WorkerContext). mod builder; @@ -31,4 +33,6 @@ pub use errors::{MohoWorkerError, MohoWorkerResult}; pub use handle::MohoWorkerHandle; pub use service::{MohoWorkerService, MohoWorkerStatus}; pub use state::MohoWorkerServiceState; -pub use traits::{AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerContext}; +pub use traits::{ + AsmStateProvider, ExportEntryStore, L1ProviderContext, MohoStateStore, MohoWorkerContext, +}; diff --git a/crates/extensions/moho/worker/src/service.rs b/crates/extensions/moho/worker/src/service.rs index b20f1670..37b3a2b3 100644 --- a/crates/extensions/moho/worker/src/service.rs +++ b/crates/extensions/moho/worker/src/service.rs @@ -55,7 +55,8 @@ where } } -/// Folds a single ASM commit into a new [`MohoState`] and persists it. +/// Folds a single ASM commit into a new [`MohoState`] and persists it, along +/// with the export-entry leaves its `ExportState` MMR commits to. /// /// Resolves the commit's parent and chains the Moho state forward onto this /// block's anchor state and logs. The parent's Moho state comes from the @@ -79,6 +80,18 @@ pub(crate) fn process_block( let anchor_state = state.context.get_anchor_state(&block)?; let logs = state.context.get_anchor_logs(&block)?; let moho = compute::construct_next_moho_state(&parent_moho, &anchor_state, &logs); + + // Persist the export-entry leaves before the Moho state. The worker tracks + // progress via the Moho store (`get_latest_moho_state`), so `store_moho_state` + // is this block's commit point: a crash before it leaves progress unadvanced + // and the block is reprocessed on restart, re-appending the same + // (idempotent) leaves. Writing them after the commit point would risk a gap + // between the leaves and the `ExportState` MMR that commits to them. + for (container_id, entry) in compute::export_entries_from_logs(&logs) { + state + .context + .append_export_entry(container_id, block.height(), entry)?; + } state.context.store_moho_state(&block, &moho)?; state.update_moho_state(moho, block); diff --git a/crates/extensions/moho/worker/src/state.rs b/crates/extensions/moho/worker/src/state.rs index d7a1b9d6..d368a506 100644 --- a/crates/extensions/moho/worker/src/state.rs +++ b/crates/extensions/moho/worker/src/state.rs @@ -106,11 +106,11 @@ mod tests { use super::*; use crate::{ - AsmStateProvider, L1ProviderContext, MohoStateStore, MohoWorkerError, + AsmStateProvider, ExportEntryStore, L1ProviderContext, MohoStateStore, MohoWorkerError, service::process_block, }; - /// In-memory context backing the three concern traits. + /// In-memory context backing the four concern traits. #[derive(Debug, Default)] struct MockContext { anchors: RefCell>, @@ -118,6 +118,7 @@ mod tests { parents: RefCell>, moho: RefCell>, latest: RefCell>, + export_entries: RefCell>, } impl MockContext { @@ -193,6 +194,20 @@ mod tests { } } + impl ExportEntryStore for MockContext { + fn append_export_entry( + &self, + container_id: u8, + height: u32, + entry: [u8; 32], + ) -> MohoWorkerResult<()> { + self.export_entries + .borrow_mut() + .push((container_id, height, entry)); + Ok(()) + } + } + /// Builds a genesis anchor state and its commitment from arbitrary params. fn genesis_anchor() -> (L1BlockCommitment, AnchorState) { let params: AsmParams = ArbitraryGenerator::new().generate(); diff --git a/crates/extensions/moho/worker/src/traits.rs b/crates/extensions/moho/worker/src/traits.rs index 7d6c31e8..69dcdbf1 100644 --- a/crates/extensions/moho/worker/src/traits.rs +++ b/crates/extensions/moho/worker/src/traits.rs @@ -10,6 +10,8 @@ //! - [`L1ProviderContext`] — resolves the parent of an L1 block commitment, so the fold can chain //! onto the parent's Moho state across reorgs. //! - [`MohoStateStore`] — persists and loads the derived [`MohoState`]. +//! - [`ExportEntryStore`] — persists the per-container export-entry leaves the state's +//! `ExportState` MMR commits to, so inclusion proofs can be rebuilt later. //! //! [`MohoWorkerContext`] is the umbrella with a blanket impl, mirroring //! `strata-asm-worker`'s [`WorkerContext`](strata_asm_worker::WorkerContext): @@ -71,12 +73,38 @@ pub trait MohoStateStore { ) -> MohoWorkerResult<()>; } +/// Persists the per-container export-entry leaves the derived state commits to. +/// +/// [`MohoState`] keeps only each container's compact `ExportState` MMR (its +/// peaks), so the original leaves cannot be recovered from it. The worker +/// mirrors them here as it folds each block — from the same `NewExportEntry` +/// logs that advance the MMR — so the RPC can rebuild inclusion proofs. +pub trait ExportEntryStore { + /// Appends one export-entry leaf for `container_id` inserted at `height`. + /// + /// Must be idempotent in `(container_id, entry)`: the worker reprocesses a + /// block whose fold did not reach its commit point, so the same leaf can be + /// appended more than once and must not be duplicated. + fn append_export_entry( + &self, + container_id: u8, + height: u32, + entry: [u8; 32], + ) -> MohoWorkerResult<()>; +} + /// Context the Moho worker interacts with the outside world through. /// -/// Umbrella over [`AsmStateProvider`], [`L1ProviderContext`] and -/// [`MohoStateStore`]. The blanket impl means any type implementing all three -/// automatically implements `MohoWorkerContext`, so implementors never name it -/// directly. -pub trait MohoWorkerContext: AsmStateProvider + L1ProviderContext + MohoStateStore {} +/// Umbrella over [`AsmStateProvider`], [`L1ProviderContext`], [`MohoStateStore`] +/// and [`ExportEntryStore`]. The blanket impl means any type implementing all +/// four automatically implements `MohoWorkerContext`, so implementors never name +/// it directly. +pub trait MohoWorkerContext: + AsmStateProvider + L1ProviderContext + MohoStateStore + ExportEntryStore +{ +} -impl MohoWorkerContext for T where T: AsmStateProvider + L1ProviderContext + MohoStateStore {} +impl MohoWorkerContext for T where + T: AsmStateProvider + L1ProviderContext + MohoStateStore + ExportEntryStore +{ +} diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 41304de7..a3c607b6 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -18,5 +18,4 @@ borsh.workspace = true sled.workspace = true [dev-dependencies] -ssz.workspace = true tempfile.workspace = true diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index a8b510fa..a3a95a71 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -3,15 +3,16 @@ //! Replaces alpen's `strata-state`, `strata-storage`, and `strata-db-store-sled` //! with a self-contained implementation that has zero alpen dependencies. //! -//! Three storage backends: +//! Two storage backends: //! - [`AsmStateDb`] — anchor states + aux data, keyed by L1 block commitment //! - [`MmrDb`] — manifest hash MMR (append, prove, query) -//! - [`ExportEntriesDb`] — per-container export entries, indexed for proof generation +//! +//! Per-container export entries moved to `strata-asm-moho-storage`, persisted +//! by the Moho worker alongside the `MohoState` whose `ExportState` MMR they +//! mirror. -mod export_entries; mod mmr; mod state; -pub use export_entries::ExportEntriesDb; pub use mmr::MmrDb; pub use state::AsmStateDb; From abb140c7f4f7096214c8325f818c783a81a33a66 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 16:40:46 +0545 Subject: [PATCH 08/10] refactor(moho): split export-entries store into trait and sled impl The export-entries store landed as a single sled-specific file, unlike the moho-state store which separates a backend-agnostic trait from its sled implementation. Mirror that layout: lift an async `ExportEntriesDb` trait to the top level and move the concrete store (renamed `SledExportEntriesDb`) under `sled/`. The sled type keeps its synchronous inherent methods for the worker/RPC call sites and implements the async trait for symmetry with `MohoStateDb`. Consumers in asm-runner are renamed to the concrete type. --- bin/asm-runner/src/moho_context.rs | 6 +- bin/asm-runner/src/rpc_server.rs | 18 +- bin/asm-runner/src/storage.rs | 6 +- .../moho/storage/src/export_entries.rs | 401 ++-------------- crates/extensions/moho/storage/src/lib.rs | 16 +- .../moho/storage/src/sled/export_entries.rs | 429 ++++++++++++++++++ .../extensions/moho/storage/src/sled/mod.rs | 3 +- 7 files changed, 499 insertions(+), 380 deletions(-) create mode 100644 crates/extensions/moho/storage/src/sled/export_entries.rs diff --git a/bin/asm-runner/src/moho_context.rs b/bin/asm-runner/src/moho_context.rs index a612e413..4083564d 100644 --- a/bin/asm-runner/src/moho_context.rs +++ b/bin/asm-runner/src/moho_context.rs @@ -19,7 +19,7 @@ use bitcoin::BlockHash; use bitcoind_async_client::{Client, error::ClientError, traits::Reader}; use moho_types::MohoState; use strata_asm_common::{AnchorState, AsmLogEntry}; -use strata_asm_moho_storage::{ExportEntriesDb, SledMohoStateDb}; +use strata_asm_moho_storage::{SledExportEntriesDb, SledMohoStateDb}; use strata_asm_moho_worker::{ AsmStateProvider, ExportEntryStore, L1ProviderContext, MohoStateStore, MohoWorkerError, MohoWorkerResult, @@ -46,7 +46,7 @@ pub(crate) struct MohoWorkerContextImpl { moho_state_db: SledMohoStateDb, /// Persistence for the per-container export-entry leaves the Moho state's /// `ExportState` MMR commits to. - export_entries_db: ExportEntriesDb, + export_entries_db: SledExportEntriesDb, } impl MohoWorkerContextImpl { @@ -56,7 +56,7 @@ impl MohoWorkerContextImpl { retry: &RetryConfig, state_db: Arc, moho_state_db: SledMohoStateDb, - export_entries_db: ExportEntriesDb, + export_entries_db: SledExportEntriesDb, ) -> Self { Self { runtime_handle, diff --git a/bin/asm-runner/src/rpc_server.rs b/bin/asm-runner/src/rpc_server.rs index b4de6268..3f364bc3 100644 --- a/bin/asm-runner/src/rpc_server.rs +++ b/bin/asm-runner/src/rpc_server.rs @@ -13,7 +13,7 @@ use jsonrpsee::{ types::{ErrorObject, ErrorObjectOwned}, }; use ssz::{Decode, Encode}; -use strata_asm_moho_storage::{ExportEntriesDb, SledMohoStateDb}; +use strata_asm_moho_storage::{SledExportEntriesDb, SledMohoStateDb}; use strata_asm_proof_db::{ProofDb, SledProofDb}; use strata_asm_proof_types::{AsmProof, L1Range, MohoProof}; use strata_asm_proto_bridge_v1::{AssignmentEntry, BridgeV1State, DepositEntry}; @@ -168,7 +168,7 @@ impl AsmStateApiServer for AsmRpcServer { pub(crate) struct AsmProofRpcDeps { pub proof_db: SledProofDb, pub moho_state_db: SledMohoStateDb, - pub export_entries_db: ExportEntriesDb, + pub export_entries_db: SledExportEntriesDb, } /// RPC handlers serving ASM and Moho proofs plus the per-block Moho state they're built on. @@ -176,7 +176,7 @@ pub(crate) struct AsmProofRpcServer { bitcoin_client: Arc, proof_db: SledProofDb, moho_state_db: SledMohoStateDb, - export_entries_db: ExportEntriesDb, + export_entries_db: SledExportEntriesDb, } impl AsmProofRpcServer { @@ -264,7 +264,7 @@ enum MmrProofError { /// for bad input or storage failures. fn build_export_entry_mmr_proof( moho_state_db: &SledMohoStateDb, - export_entries_db: &ExportEntriesDb, + export_entries_db: &SledExportEntriesDb, commitment: L1BlockCommitment, container_id: u8, leaf: &[u8], @@ -350,7 +350,7 @@ pub(crate) async fn run_rpc_server( mod tests { //! Tests for [`build_export_entry_mmr_proof`] against real sled storage. //! Mirrors the worker's invariant: each `NewExportEntry` hits both `ExportState` and - //! `ExportEntriesDb` in order. + //! `SledExportEntriesDb` in order. use moho_types::{ExportState, InnerStateCommitment, MohoState}; use ssz::Decode; use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; @@ -366,13 +366,13 @@ mod tests { fn temp_dbs() -> ( sled::Db, SledMohoStateDb, - ExportEntriesDb, + SledExportEntriesDb, tempfile::TempDir, ) { let dir = tempfile::tempdir().unwrap(); let sled_db = sled::open(dir.path()).unwrap(); let moho_state_db = SledMohoStateDb::open(&sled_db).unwrap(); - let export_entries_db = ExportEntriesDb::open(&sled_db).unwrap(); + let export_entries_db = SledExportEntriesDb::open(&sled_db).unwrap(); (sled_db, moho_state_db, export_entries_db, dir) } @@ -385,10 +385,10 @@ mod tests { } /// Same dual-write the worker does per block: each entry hits both the - /// `ExportState` MMR and the `ExportEntriesDb` leaf log. + /// `ExportState` MMR and the `SledExportEntriesDb` leaf log. fn apply_block( moho: &SledMohoStateDb, - idx: &ExportEntriesDb, + idx: &SledExportEntriesDb, prev: MohoState, at: L1BlockCommitment, entries: &[(u8, [u8; 32])], diff --git a/bin/asm-runner/src/storage.rs b/bin/asm-runner/src/storage.rs index d0dcd640..3e6474c0 100644 --- a/bin/asm-runner/src/storage.rs +++ b/bin/asm-runner/src/storage.rs @@ -4,17 +4,17 @@ use std::sync::Arc; use anyhow::Result; use asm_storage::{AsmStateDb, MmrDb}; -use strata_asm_moho_storage::ExportEntriesDb; +use strata_asm_moho_storage::SledExportEntriesDb; use crate::config::DatabaseConfig; /// Create storage backends for the ASM runner. pub(crate) fn create_storage( config: &DatabaseConfig, -) -> Result<(Arc, Arc, ExportEntriesDb)> { +) -> Result<(Arc, Arc, SledExportEntriesDb)> { let db = sled::open(&config.path)?; let state_db = Arc::new(AsmStateDb::open(&db)?); let mmr_db = Arc::new(MmrDb::open(&db)?); - let export_entries_db = ExportEntriesDb::open(&db)?; + let export_entries_db = SledExportEntriesDb::open(&db)?; Ok((state_db, mmr_db, export_entries_db)) } diff --git a/crates/extensions/moho/storage/src/export_entries.rs b/crates/extensions/moho/storage/src/export_entries.rs index 4aca4602..92f11d89 100644 --- a/crates/extensions/moho/storage/src/export_entries.rs +++ b/crates/extensions/moho/storage/src/export_entries.rs @@ -1,377 +1,60 @@ -//! Sled-backed index of per-container export entries. +//! Storage trait for per-container export-entry indexes. //! -//! `MohoState` keeps only each container's compact MMR (peaks), so the -//! original 32-byte leaves can't be recovered from it. We mirror them here so -//! the RPC can rebuild inclusion proofs on demand. -//! -//! Backed by [`strata_merkle_node_store`]: every MMR node is persisted, so a -//! proof is `O(log n)` with no leaf replay. Containers share one node tree, -//! namespaced by `container_id`. Alongside the nodes we keep two small indexes -//! the MMR itself does not carry: the insertion height per leaf, and a reverse -//! `hash → index` map for lookups and append idempotency. - -use anyhow::{Context, Result}; -use strata_merkle::{MerkleProofB32, Sha256Hasher}; -use strata_merkle_node_store::{MmrNodeStore, NodePos, StoredMmr}; - -/// Decodes a stored 32-byte node value into a hash. -/// -/// The store only ever writes 32-byte values, so a wrong length is disk -/// corruption rather than a recoverable condition. -fn decode_node(value: sled::IVec) -> [u8; 32] { - value - .as_ref() - .try_into() - .expect("mmr node value must be 32 bytes") -} - -/// One container's view onto the shared node tree, namespacing every key with -/// `container_id` so each container is an independent MMR. -#[derive(Debug)] -struct ContainerNodes<'a> { - tree: &'a sled::Tree, - container_id: u8, -} - -impl ContainerNodes<'_> { - /// `container_id || NodePos::to_key()`. - fn key(&self, pos: NodePos) -> [u8; 10] { - let mut key = [0u8; 10]; - key[0] = self.container_id; - key[1..].copy_from_slice(&pos.to_key()); - key - } -} +//! [`MohoState`](moho_types::MohoState) keeps only each container's compact MMR +//! (its peaks), so the original 32-byte leaves can't be recovered from it. An +//! export-entries store mirrors those leaves so the RPC can rebuild inclusion +//! proofs on demand. Containers are namespaced by `container_id`; each behaves +//! as an independent MMR over its entry hashes. -impl MmrNodeStore for ContainerNodes<'_> { - type Hash = [u8; 32]; - type Error = sled::Error; +use std::fmt::Debug; - fn get_node(&self, pos: NodePos) -> Result, sled::Error> { - Ok(self.tree.get(self.key(pos))?.map(decode_node)) - } +use strata_merkle::MerkleProofB32; - fn put_node(&self, pos: NodePos, value: [u8; 32]) -> Result<(), sled::Error> { - self.tree.insert(self.key(pos), value.as_slice())?; - Ok(()) - } +/// Persistence interface for the per-container export-entry index. +pub trait ExportEntriesDb { + /// The error type returned by database operations. + type Error: Debug; - fn commit(&self, writes: &[(NodePos, [u8; 32])]) -> Result<(), sled::Error> { - let mut batch = sled::Batch::default(); - for (pos, value) in writes { - let key = self.key(*pos); - batch.insert(key.as_slice(), value.as_slice()); - } - self.tree.apply_batch(batch) - } -} - -/// Per-container export-entry store: a namespaced MMR node tree plus a -/// `(container_id, index) → height` map and a reverse -/// `(container_id, hash) → index` map. -#[derive(Debug, Clone)] -pub struct ExportEntriesDb { - nodes: sled::Tree, - heights: sled::Tree, - index_by_hash: sled::Tree, -} - -impl ExportEntriesDb { - /// Opens or creates the export entries trees in the given sled instance. - pub fn open(db: &sled::Db) -> Result { - Ok(Self { - nodes: db.open_tree("export_entry_nodes")?, - heights: db.open_tree("export_entry_heights")?, - index_by_hash: db.open_tree("export_entries_by_hash")?, - }) - } - - /// The MMR view for `container_id`. - fn container(&self, container_id: u8) -> ContainerNodes<'_> { - ContainerNodes { - tree: &self.nodes, - container_id, - } - } - - /// Reads the insertion height stored for `(container_id, mmr_index)`. - fn height_at(&self, container_id: u8, mmr_index: u64) -> Result> { - match self.heights.get(encode_key(container_id, mmr_index))? { - Some(bytes) => Ok(Some(u32::from_be_bytes( - bytes.as_ref().try_into().context("invalid height bytes")?, - ))), - None => Ok(None), - } - } - - /// Appends an entry for `container_id` and returns its `mmr_index`. + /// Appends an entry for `container_id` and resolves to its `mmr_index`. /// - /// Idempotent: a duplicate `(container_id, entry)` returns the original + /// Idempotent: a duplicate `(container_id, entry)` resolves to the original /// index unchanged, so block replays after restart are a no-op. Assumes /// `(container_id, entry_hash)` is unique within a correct chain. - pub fn append(&self, container_id: u8, height: u32, entry: [u8; 32]) -> Result { - let hash_key = encode_hash_key(container_id, &entry); - if let Some(existing) = self.index_by_hash.get(hash_key)? { - return decode_idx(existing.as_ref()); - } - - // Append the leaf (and its recomputed ancestors) to the node store, - // then record its height and reverse index. The reverse index is the - // dedup gate, so it is written last: a crash before it leaves the - // block uncommitted and the worker reprocesses it on restart. - let index = StoredMmr::::append_leaf(&self.container(container_id), entry)?; - self.heights - .insert(encode_key(container_id, index), &height.to_be_bytes())?; - self.index_by_hash.insert(hash_key, &index.to_be_bytes())?; - Ok(index) - } + fn append_entry( + &self, + container_id: u8, + height: u32, + entry: [u8; 32], + ) -> impl Future> + Send; - /// Returns the number of entries currently stored for `container_id`. - pub fn num_entries(&self, container_id: u8) -> Result { - Ok(StoredMmr::::leaf_count( - &self.container(container_id), - )?) - } + /// Resolves to the number of entries currently stored for `container_id`. + fn entry_count( + &self, + container_id: u8, + ) -> impl Future> + Send; - /// Reverse lookup: returns `(mmr_index, insertion_height)` for `hash` + /// Reverse lookup: resolves to `(mmr_index, insertion_height)` for `hash` /// under `container_id`, or `None` if absent. - pub fn find_index(&self, container_id: u8, hash: &[u8; 32]) -> Result> { - let hash_key = encode_hash_key(container_id, hash); - let Some(idx_bytes) = self.index_by_hash.get(hash_key)? else { - return Ok(None); - }; - let mmr_index = decode_idx(idx_bytes.as_ref())?; - let height = self - .height_at(container_id, mmr_index)? - .context("secondary index points at missing primary entry")?; - Ok(Some((mmr_index, height))) - } + fn find_entry_index( + &self, + container_id: u8, + hash: [u8; 32], + ) -> impl Future, Self::Error>> + Send; - /// Fetches `(insertion_height, entry_hash)` at `(container_id, mmr_index)`. - pub fn get(&self, container_id: u8, mmr_index: u64) -> Result> { - let Some(hash) = - StoredMmr::::get_leaf(&self.container(container_id), mmr_index)? - else { - return Ok(None); - }; - let height = self - .height_at(container_id, mmr_index)? - .context("leaf present but its height is missing")?; - Ok(Some((height, hash))) - } + /// Resolves to `(insertion_height, entry_hash)` at `(container_id, mmr_index)`, + /// or `None` if absent. + fn get_entry( + &self, + container_id: u8, + mmr_index: u64, + ) -> impl Future, Self::Error>> + Send; - /// Generates an inclusion proof for `mmr_index` against the container's - /// MMR at size `at_leaf_count`. - /// - /// `O(log n)`: walks the stored sibling path rather than replaying leaves. - /// The store yields a generic [`MerkleProof`](strata_merkle::MerkleProof); - /// it is repacked as a [`MerkleProofB32`] so the store's public API and the - /// accumulators it verifies against are unchanged. - pub fn generate_proof( + /// Generates an inclusion proof for `mmr_index` against the container's MMR + /// at size `at_leaf_count`. + fn generate_entry_proof( &self, container_id: u8, mmr_index: u64, at_leaf_count: u64, - ) -> Result { - let proof = StoredMmr::::generate_proof_at_size( - &self.container(container_id), - mmr_index, - at_leaf_count, - )?; - Ok(MerkleProofB32::from_generic(&proof)) - } -} - -fn encode_key(container_id: u8, mmr_index: u64) -> [u8; 9] { - let mut key = [0u8; 9]; - key[0] = container_id; - key[1..].copy_from_slice(&mmr_index.to_be_bytes()); - key -} - -fn encode_hash_key(container_id: u8, hash: &[u8; 32]) -> [u8; 33] { - let mut key = [0u8; 33]; - key[0] = container_id; - key[1..].copy_from_slice(hash); - key -} - -fn decode_idx(bytes: &[u8]) -> Result { - Ok(u64::from_be_bytes( - bytes.try_into().context("invalid mmr_index bytes")?, - )) -} - -#[cfg(test)] -mod tests { - use ssz::{Decode, Encode}; - use strata_merkle::{Mmr, Mmr64B32, MmrState, Sha256Hasher}; - - use super::*; - - fn test_db() -> sled::Db { - let dir = tempfile::tempdir().unwrap(); - sled::open(dir.path()).unwrap() - } - - /// A distinct, non-zero entry hash for `seed`. The non-zero marker matters: - /// the compact-peaks MMR these proofs verify against treats an all-zero - /// hash as an empty-peak sentinel, so `[0; 32]` is not a representable leaf. - fn hash(seed: u8) -> [u8; 32] { - let mut bytes = [seed; 32]; - bytes[31] = 0xAB; - bytes - } - - #[test] - fn append_assigns_monotonic_indices_per_container() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - - assert_eq!(store.append(1, 10, hash(0xa1)).unwrap(), 0); - assert_eq!(store.append(1, 11, hash(0xa2)).unwrap(), 1); - assert_eq!(store.append(2, 11, hash(0xb1)).unwrap(), 0); - assert_eq!(store.append(1, 12, hash(0xa3)).unwrap(), 2); - assert_eq!(store.append(2, 12, hash(0xb2)).unwrap(), 1); - } - - #[test] - fn num_entries_matches_appends() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - - assert_eq!(store.num_entries(7).unwrap(), 0); - for i in 0..5u8 { - store.append(7, 100 + i as u32, hash(i)).unwrap(); - } - assert_eq!(store.num_entries(7).unwrap(), 5); - assert_eq!(store.num_entries(8).unwrap(), 0); - } - - #[test] - fn get_returns_none_for_unknown() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - store.append(1, 42, hash(0xaa)).unwrap(); - - assert!(store.get(1, 1).unwrap().is_none()); - assert!(store.get(2, 0).unwrap().is_none()); - } - - #[test] - fn get_returns_height_and_hash() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - store.append(3, 999, hash(0xcc)).unwrap(); - - let (height, got) = store.get(3, 0).unwrap().unwrap(); - assert_eq!(height, 999); - assert_eq!(got, hash(0xcc)); - } - - #[test] - fn find_index_returns_match_with_height() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - store.append(1, 10, hash(0xa0)).unwrap(); - store.append(1, 11, hash(0xa1)).unwrap(); - store.append(1, 12, hash(0xa2)).unwrap(); - store.append(2, 10, hash(0xa1)).unwrap(); // same hash, different container - - assert_eq!(store.find_index(1, &hash(0xa1)).unwrap(), Some((1, 11))); - assert_eq!(store.find_index(2, &hash(0xa1)).unwrap(), Some((0, 10))); - assert_eq!(store.find_index(1, &hash(0xff)).unwrap(), None); - assert_eq!(store.find_index(3, &hash(0xa1)).unwrap(), None); - } - - #[test] - fn append_is_idempotent_on_duplicate_hash() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - - let idx0 = store.append(1, 10, hash(0xa0)).unwrap(); - let idx1 = store.append(1, 11, hash(0xa1)).unwrap(); - - // Replay the same entry — should return the original index, - // not bump num_entries, and not overwrite the original (height, hash). - let replay_idx = store.append(1, 999, hash(0xa0)).unwrap(); - assert_eq!(replay_idx, idx0); - assert_eq!(store.num_entries(1).unwrap(), 2); - assert_eq!(store.get(1, idx0).unwrap().unwrap(), (10, hash(0xa0))); - assert_eq!(store.get(1, idx1).unwrap().unwrap(), (11, hash(0xa1))); - } - - /// Reference compact-peaks MMR built by replaying the first `size` leaves - /// of `container_id`, matching the accumulators that proofs verify against. - fn rebuild_compact_mmr(store: &ExportEntriesDb, container_id: u8, size: u64) -> Mmr64B32 { - let mut compact = Mmr64B32::new_empty(); - for i in 0..size { - let (_h, hash) = store.get(container_id, i).unwrap().unwrap(); - Mmr::::add_leaf(&mut compact, hash).unwrap(); - } - compact - } - - #[test] - fn generate_and_verify_proof_single_leaf() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - let h = hash(0x01); - store.append(4, 100, h).unwrap(); - - let proof = store.generate_proof(4, 0, 1).unwrap(); - let compact = rebuild_compact_mmr(&store, 4, 1); - assert!(compact.verify(&proof, &h)); - } - - #[test] - fn generate_proofs_for_all_leaves() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - for i in 0u8..8 { - store.append(5, 1000 + i as u32, hash(i)).unwrap(); - } - - let compact = rebuild_compact_mmr(&store, 5, 8); - for i in 0u64..8 { - let proof = store - .generate_proof(5, i, 8) - .unwrap_or_else(|e| panic!("proof generation failed for leaf {i}: {e}")); - assert!(compact.verify(&proof, &hash(i as u8))); - } - } - - #[test] - fn proof_at_earlier_size_is_valid() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - - for i in 0u8..4 { - store.append(6, 100 + i as u32, hash(i)).unwrap(); - } - let compact_at_4 = rebuild_compact_mmr(&store, 6, 4); - - for i in 4u8..8 { - store.append(6, 100 + i as u32, hash(i)).unwrap(); - } - - let proof = store.generate_proof(6, 2, 4).unwrap(); - assert!(compact_at_4.verify(&proof, &hash(2))); - } - - #[test] - fn proof_ssz_roundtrip_verifies() { - let db = test_db(); - let store = ExportEntriesDb::open(&db).unwrap(); - for i in 0u8..5 { - store.append(9, 200 + i as u32, hash(i)).unwrap(); - } - - let proof = store.generate_proof(9, 3, 5).unwrap(); - let bytes = proof.as_ssz_bytes(); - let decoded = MerkleProofB32::from_ssz_bytes(&bytes).unwrap(); - - let compact = rebuild_compact_mmr(&store, 9, 5); - assert!(compact.verify(&decoded, &hash(3))); - } + ) -> impl Future> + Send; } diff --git a/crates/extensions/moho/storage/src/lib.rs b/crates/extensions/moho/storage/src/lib.rs index 6db34fa4..1f31d17a 100644 --- a/crates/extensions/moho/storage/src/lib.rs +++ b/crates/extensions/moho/storage/src/lib.rs @@ -6,13 +6,19 @@ //! the worker mirrors the per-container export-entry leaves of the state's //! `ExportState` MMR so the RPC can rebuild inclusion proofs on demand. //! -//! - [`MohoStateDb`] — the Moho-state storage trait, parameterised over an associated error type. -//! - [`SledMohoStateDb`] — a [sled](https://docs.rs/sled)-backed implementation. -//! - [`ExportEntriesDb`] — the per-container export-entry index mirroring the `ExportState` MMR -//! leaves. +//! Each store is split into a backend-agnostic trait and a sled-backed +//! implementation: +//! +//! - [`MohoStateDb`] / [`SledMohoStateDb`] — the Moho-state store, keyed by L1 block commitment. +//! - [`ExportEntriesDb`] / [`SledExportEntriesDb`] — the per-container export-entry index mirroring +//! the `ExportState` MMR leaves. mod export_entries; mod moho_state; mod sled; -pub use self::{export_entries::ExportEntriesDb, moho_state::MohoStateDb, sled::SledMohoStateDb}; +pub use self::{ + export_entries::ExportEntriesDb, + moho_state::MohoStateDb, + sled::{SledExportEntriesDb, SledMohoStateDb}, +}; diff --git a/crates/extensions/moho/storage/src/sled/export_entries.rs b/crates/extensions/moho/storage/src/sled/export_entries.rs new file mode 100644 index 00000000..f7ea126e --- /dev/null +++ b/crates/extensions/moho/storage/src/sled/export_entries.rs @@ -0,0 +1,429 @@ +//! [`ExportEntriesDb`](crate::ExportEntriesDb) implementation backed by sled. +//! +//! Backed by [`strata_merkle_node_store`]: every MMR node is persisted, so a +//! proof is `O(log n)` with no leaf replay. Containers share one node tree, +//! namespaced by `container_id`. Alongside the nodes we keep two small indexes +//! the MMR itself does not carry: the insertion height per leaf, and a reverse +//! `hash → index` map for lookups and append idempotency. + +use anyhow::{Context, Result}; +use strata_merkle::{MerkleProofB32, Sha256Hasher}; +use strata_merkle_node_store::{MmrNodeStore, NodePos, StoredMmr}; + +use crate::ExportEntriesDb; + +/// Decodes a stored 32-byte node value into a hash. +/// +/// The store only ever writes 32-byte values, so a wrong length is disk +/// corruption rather than a recoverable condition. +fn decode_node(value: sled::IVec) -> [u8; 32] { + value + .as_ref() + .try_into() + .expect("mmr node value must be 32 bytes") +} + +/// One container's view onto the shared node tree, namespacing every key with +/// `container_id` so each container is an independent MMR. +#[derive(Debug)] +struct ContainerNodes<'a> { + tree: &'a sled::Tree, + container_id: u8, +} + +impl ContainerNodes<'_> { + /// `container_id || NodePos::to_key()`. + fn key(&self, pos: NodePos) -> [u8; 10] { + let mut key = [0u8; 10]; + key[0] = self.container_id; + key[1..].copy_from_slice(&pos.to_key()); + key + } +} + +impl MmrNodeStore for ContainerNodes<'_> { + type Hash = [u8; 32]; + type Error = sled::Error; + + fn get_node(&self, pos: NodePos) -> Result, sled::Error> { + Ok(self.tree.get(self.key(pos))?.map(decode_node)) + } + + fn put_node(&self, pos: NodePos, value: [u8; 32]) -> Result<(), sled::Error> { + self.tree.insert(self.key(pos), value.as_slice())?; + Ok(()) + } + + fn commit(&self, writes: &[(NodePos, [u8; 32])]) -> Result<(), sled::Error> { + let mut batch = sled::Batch::default(); + for (pos, value) in writes { + let key = self.key(*pos); + batch.insert(key.as_slice(), value.as_slice()); + } + self.tree.apply_batch(batch) + } +} + +/// Sled-backed per-container export-entry store: a namespaced MMR node tree plus +/// a `(container_id, index) → height` map and a reverse +/// `(container_id, hash) → index` map. +#[derive(Debug, Clone)] +pub struct SledExportEntriesDb { + nodes: sled::Tree, + heights: sled::Tree, + index_by_hash: sled::Tree, +} + +impl SledExportEntriesDb { + /// Opens or creates the export entries trees in the given sled instance. + pub fn open(db: &sled::Db) -> Result { + Ok(Self { + nodes: db.open_tree("export_entry_nodes")?, + heights: db.open_tree("export_entry_heights")?, + index_by_hash: db.open_tree("export_entries_by_hash")?, + }) + } + + /// The MMR view for `container_id`. + fn container(&self, container_id: u8) -> ContainerNodes<'_> { + ContainerNodes { + tree: &self.nodes, + container_id, + } + } + + /// Reads the insertion height stored for `(container_id, mmr_index)`. + fn height_at(&self, container_id: u8, mmr_index: u64) -> Result> { + match self.heights.get(encode_key(container_id, mmr_index))? { + Some(bytes) => Ok(Some(u32::from_be_bytes( + bytes.as_ref().try_into().context("invalid height bytes")?, + ))), + None => Ok(None), + } + } + + /// Synchronous variant of [`ExportEntriesDb::append_entry`]. + /// + /// The Moho worker appends entries from its synchronous `ExportEntryStore` + /// impl while running as an async service, so it calls these sync methods + /// directly rather than the async trait below. + pub fn append(&self, container_id: u8, height: u32, entry: [u8; 32]) -> Result { + let hash_key = encode_hash_key(container_id, &entry); + if let Some(existing) = self.index_by_hash.get(hash_key)? { + return decode_idx(existing.as_ref()); + } + + // Append the leaf (and its recomputed ancestors) to the node store, + // then record its height and reverse index. The reverse index is the + // dedup gate, so it is written last: a crash before it leaves the + // block uncommitted and the worker reprocesses it on restart. + let index = StoredMmr::::append_leaf(&self.container(container_id), entry)?; + self.heights + .insert(encode_key(container_id, index), &height.to_be_bytes())?; + self.index_by_hash.insert(hash_key, &index.to_be_bytes())?; + Ok(index) + } + + /// Synchronous variant of [`ExportEntriesDb::entry_count`]. See [`Self::append`]. + pub fn num_entries(&self, container_id: u8) -> Result { + Ok(StoredMmr::::leaf_count( + &self.container(container_id), + )?) + } + + /// Synchronous variant of [`ExportEntriesDb::find_entry_index`]. See [`Self::append`]. + pub fn find_index(&self, container_id: u8, hash: &[u8; 32]) -> Result> { + let hash_key = encode_hash_key(container_id, hash); + let Some(idx_bytes) = self.index_by_hash.get(hash_key)? else { + return Ok(None); + }; + let mmr_index = decode_idx(idx_bytes.as_ref())?; + let height = self + .height_at(container_id, mmr_index)? + .context("secondary index points at missing primary entry")?; + Ok(Some((mmr_index, height))) + } + + /// Synchronous variant of [`ExportEntriesDb::get_entry`]. See [`Self::append`]. + pub fn get(&self, container_id: u8, mmr_index: u64) -> Result> { + let Some(hash) = + StoredMmr::::get_leaf(&self.container(container_id), mmr_index)? + else { + return Ok(None); + }; + let height = self + .height_at(container_id, mmr_index)? + .context("leaf present but its height is missing")?; + Ok(Some((height, hash))) + } + + /// Synchronous variant of [`ExportEntriesDb::generate_entry_proof`]. See [`Self::append`]. + /// + /// `O(log n)`: walks the stored sibling path rather than replaying leaves. + /// The store yields a generic [`MerkleProof`](strata_merkle::MerkleProof); + /// it is repacked as a [`MerkleProofB32`] so the store's public API and the + /// accumulators it verifies against are unchanged. + pub fn generate_proof( + &self, + container_id: u8, + mmr_index: u64, + at_leaf_count: u64, + ) -> Result { + let proof = StoredMmr::::generate_proof_at_size( + &self.container(container_id), + mmr_index, + at_leaf_count, + )?; + Ok(MerkleProofB32::from_generic(&proof)) + } +} + +impl ExportEntriesDb for SledExportEntriesDb { + type Error = anyhow::Error; + + async fn append_entry(&self, container_id: u8, height: u32, entry: [u8; 32]) -> Result { + self.append(container_id, height, entry) + } + + async fn entry_count(&self, container_id: u8) -> Result { + self.num_entries(container_id) + } + + async fn find_entry_index( + &self, + container_id: u8, + hash: [u8; 32], + ) -> Result> { + self.find_index(container_id, &hash) + } + + async fn get_entry(&self, container_id: u8, mmr_index: u64) -> Result> { + self.get(container_id, mmr_index) + } + + async fn generate_entry_proof( + &self, + container_id: u8, + mmr_index: u64, + at_leaf_count: u64, + ) -> Result { + self.generate_proof(container_id, mmr_index, at_leaf_count) + } +} + +fn encode_key(container_id: u8, mmr_index: u64) -> [u8; 9] { + let mut key = [0u8; 9]; + key[0] = container_id; + key[1..].copy_from_slice(&mmr_index.to_be_bytes()); + key +} + +fn encode_hash_key(container_id: u8, hash: &[u8; 32]) -> [u8; 33] { + let mut key = [0u8; 33]; + key[0] = container_id; + key[1..].copy_from_slice(hash); + key +} + +fn decode_idx(bytes: &[u8]) -> Result { + Ok(u64::from_be_bytes( + bytes.try_into().context("invalid mmr_index bytes")?, + )) +} + +#[cfg(test)] +mod tests { + use ssz::{Decode, Encode}; + use strata_merkle::{Mmr, Mmr64B32, MmrState, Sha256Hasher}; + use tokio::runtime::Runtime; + + use super::*; + + fn test_db() -> sled::Db { + let dir = tempfile::tempdir().unwrap(); + sled::open(dir.path()).unwrap() + } + + /// A distinct, non-zero entry hash for `seed`. The non-zero marker matters: + /// the compact-peaks MMR these proofs verify against treats an all-zero + /// hash as an empty-peak sentinel, so `[0; 32]` is not a representable leaf. + fn hash(seed: u8) -> [u8; 32] { + let mut bytes = [seed; 32]; + bytes[31] = 0xAB; + bytes + } + + #[test] + fn append_assigns_monotonic_indices_per_container() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + + assert_eq!(store.append(1, 10, hash(0xa1)).unwrap(), 0); + assert_eq!(store.append(1, 11, hash(0xa2)).unwrap(), 1); + assert_eq!(store.append(2, 11, hash(0xb1)).unwrap(), 0); + assert_eq!(store.append(1, 12, hash(0xa3)).unwrap(), 2); + assert_eq!(store.append(2, 12, hash(0xb2)).unwrap(), 1); + } + + #[test] + fn num_entries_matches_appends() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + + assert_eq!(store.num_entries(7).unwrap(), 0); + for i in 0..5u8 { + store.append(7, 100 + i as u32, hash(i)).unwrap(); + } + assert_eq!(store.num_entries(7).unwrap(), 5); + assert_eq!(store.num_entries(8).unwrap(), 0); + } + + #[test] + fn get_returns_none_for_unknown() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + store.append(1, 42, hash(0xaa)).unwrap(); + + assert!(store.get(1, 1).unwrap().is_none()); + assert!(store.get(2, 0).unwrap().is_none()); + } + + #[test] + fn get_returns_height_and_hash() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + store.append(3, 999, hash(0xcc)).unwrap(); + + let (height, got) = store.get(3, 0).unwrap().unwrap(); + assert_eq!(height, 999); + assert_eq!(got, hash(0xcc)); + } + + #[test] + fn find_index_returns_match_with_height() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + store.append(1, 10, hash(0xa0)).unwrap(); + store.append(1, 11, hash(0xa1)).unwrap(); + store.append(1, 12, hash(0xa2)).unwrap(); + store.append(2, 10, hash(0xa1)).unwrap(); // same hash, different container + + assert_eq!(store.find_index(1, &hash(0xa1)).unwrap(), Some((1, 11))); + assert_eq!(store.find_index(2, &hash(0xa1)).unwrap(), Some((0, 10))); + assert_eq!(store.find_index(1, &hash(0xff)).unwrap(), None); + assert_eq!(store.find_index(3, &hash(0xa1)).unwrap(), None); + } + + #[test] + fn append_is_idempotent_on_duplicate_hash() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + + let idx0 = store.append(1, 10, hash(0xa0)).unwrap(); + let idx1 = store.append(1, 11, hash(0xa1)).unwrap(); + + // Replay the same entry — should return the original index, + // not bump num_entries, and not overwrite the original (height, hash). + let replay_idx = store.append(1, 999, hash(0xa0)).unwrap(); + assert_eq!(replay_idx, idx0); + assert_eq!(store.num_entries(1).unwrap(), 2); + assert_eq!(store.get(1, idx0).unwrap().unwrap(), (10, hash(0xa0))); + assert_eq!(store.get(1, idx1).unwrap().unwrap(), (11, hash(0xa1))); + } + + /// Reference compact-peaks MMR built by replaying the first `size` leaves + /// of `container_id`, matching the accumulators that proofs verify against. + fn rebuild_compact_mmr(store: &SledExportEntriesDb, container_id: u8, size: u64) -> Mmr64B32 { + let mut compact = Mmr64B32::new_empty(); + for i in 0..size { + let (_h, hash) = store.get(container_id, i).unwrap().unwrap(); + Mmr::::add_leaf(&mut compact, hash).unwrap(); + } + compact + } + + #[test] + fn generate_and_verify_proof_single_leaf() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + let h = hash(0x01); + store.append(4, 100, h).unwrap(); + + let proof = store.generate_proof(4, 0, 1).unwrap(); + let compact = rebuild_compact_mmr(&store, 4, 1); + assert!(compact.verify(&proof, &h)); + } + + #[test] + fn generate_proofs_for_all_leaves() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + for i in 0u8..8 { + store.append(5, 1000 + i as u32, hash(i)).unwrap(); + } + + let compact = rebuild_compact_mmr(&store, 5, 8); + for i in 0u64..8 { + let proof = store + .generate_proof(5, i, 8) + .unwrap_or_else(|e| panic!("proof generation failed for leaf {i}: {e}")); + assert!(compact.verify(&proof, &hash(i as u8))); + } + } + + #[test] + fn proof_at_earlier_size_is_valid() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + + for i in 0u8..4 { + store.append(6, 100 + i as u32, hash(i)).unwrap(); + } + let compact_at_4 = rebuild_compact_mmr(&store, 6, 4); + + for i in 4u8..8 { + store.append(6, 100 + i as u32, hash(i)).unwrap(); + } + + let proof = store.generate_proof(6, 2, 4).unwrap(); + assert!(compact_at_4.verify(&proof, &hash(2))); + } + + #[test] + fn proof_ssz_roundtrip_verifies() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + for i in 0u8..5 { + store.append(9, 200 + i as u32, hash(i)).unwrap(); + } + + let proof = store.generate_proof(9, 3, 5).unwrap(); + let bytes = proof.as_ssz_bytes(); + let decoded = MerkleProofB32::from_ssz_bytes(&bytes).unwrap(); + + let compact = rebuild_compact_mmr(&store, 9, 5); + assert!(compact.verify(&decoded, &hash(3))); + } + + /// Exercises the async [`ExportEntriesDb`] trait surface, proving the + /// methods delegate to their synchronous counterparts. + #[test] + fn async_trait_delegates_to_sync() { + let db = test_db(); + let store = SledExportEntriesDb::open(&db).unwrap(); + + Runtime::new().unwrap().block_on(async { + assert_eq!(store.append_entry(1, 10, hash(0xa1)).await.unwrap(), 0); + assert_eq!(store.entry_count(1).await.unwrap(), 1); + assert_eq!( + store.find_entry_index(1, hash(0xa1)).await.unwrap(), + Some((0, 10)) + ); + assert_eq!(store.get_entry(1, 0).await.unwrap(), Some((10, hash(0xa1)))); + + let proof = store.generate_entry_proof(1, 0, 1).await.unwrap(); + let compact = rebuild_compact_mmr(&store, 1, 1); + assert!(compact.verify(&proof, &hash(0xa1))); + }); + } +} diff --git a/crates/extensions/moho/storage/src/sled/mod.rs b/crates/extensions/moho/storage/src/sled/mod.rs index 96d68fcd..138a41b1 100644 --- a/crates/extensions/moho/storage/src/sled/mod.rs +++ b/crates/extensions/moho/storage/src/sled/mod.rs @@ -6,9 +6,10 @@ use strata_identifiers::{Buf32, L1BlockCommitment, L1BlockId}; +mod export_entries; mod moho_state; -pub use self::moho_state::SledMohoStateDb; +pub use self::{export_entries::SledExportEntriesDb, moho_state::SledMohoStateDb}; // ── Key encoding ────────────────────────────────────────────────────── // From f828156e13cee7966d8196edf8ca1083b8521236 Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 16:57:11 +0545 Subject: [PATCH 09/10] docs(moho): flag reorg handling for export entries as TODO A block can emit multiple ExportEntry leaves, so the append path can't reuse the manifest MMR's reorg strategy. Record the follow-up (STR-3723) at the append site rather than leaving the gap undocumented. --- crates/extensions/moho/worker/src/service.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/extensions/moho/worker/src/service.rs b/crates/extensions/moho/worker/src/service.rs index 37b3a2b3..cbf9a5d8 100644 --- a/crates/extensions/moho/worker/src/service.rs +++ b/crates/extensions/moho/worker/src/service.rs @@ -87,6 +87,8 @@ pub(crate) fn process_block( // and the block is reprocessed on restart, re-appending the same // (idempotent) leaves. Writing them after the commit point would risk a gap // between the leaves and the `ExportState` MMR that commits to them. + // TODO(STR-3723): unlike manifest MMR we need to handle the reorg differently since there might + // be multiple ExportEntry in a single block. for (container_id, entry) in compute::export_entries_from_logs(&logs) { state .context From bd6ded221e1f4a6ee561e98910d7515f71f53baa Mon Sep 17 00:00:00 2001 From: Prajwol Gyawali Date: Sun, 7 Jun 2026 17:49:21 +0545 Subject: [PATCH 10/10] fix(asm-runner): repair broken intra-doc link in moho_context `Self` does not resolve in a module-level doc comment, so the rustdoc intra-doc link failed under `-D warnings` and broke the Generate docs CI job. Reference the concrete `MohoWorkerContextImpl` type instead. --- bin/asm-runner/src/moho_context.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/asm-runner/src/moho_context.rs b/bin/asm-runner/src/moho_context.rs index 4083564d..59a3afa6 100644 --- a/bin/asm-runner/src/moho_context.rs +++ b/bin/asm-runner/src/moho_context.rs @@ -10,7 +10,8 @@ //! RPC directly — the Moho worker runs as an async service. The //! [`MohoWorkerContext`](strata_asm_moho_worker::MohoWorkerContext) traits are //! synchronous, so parent resolution bridges to the async client via -//! [`block_in_place`](task::block_in_place); see [`Self::get_parent_block`]. +//! [`block_in_place`](task::block_in_place); see +//! [`MohoWorkerContextImpl::get_parent_block`]. use std::sync::Arc;