diff --git a/apps/increment/src/app.rs b/apps/increment/src/app.rs index cf7568f..6553353 100644 --- a/apps/increment/src/app.rs +++ b/apps/increment/src/app.rs @@ -89,6 +89,12 @@ impl Default for App { } } +impl void_app_node::transitions::AppFlush for App { + fn flush(&mut self) { + // No pending state - SparseMerkleTree writes directly + } +} + impl ProofConversions for Proof { type App = App; type DbData = MerkleData; diff --git a/apps/transfers/src/app.rs b/apps/transfers/src/app.rs index 2423e8b..9d8de0a 100644 --- a/apps/transfers/src/app.rs +++ b/apps/transfers/src/app.rs @@ -288,6 +288,12 @@ impl Default for App { } } +impl void_app_node::transitions::AppFlush for App { + fn flush(&mut self) { + // No pending state - GenericSparseMerkleTree writes directly + } +} + impl ProofConversions for Proof { type App = App; type DbData = (); diff --git a/docs/plans/2026-01-14-app-flush-trait-design.md b/docs/plans/2026-01-14-app-flush-trait-design.md new file mode 100644 index 0000000..3656c45 --- /dev/null +++ b/docs/plans/2026-01-14-app-flush-trait-design.md @@ -0,0 +1,136 @@ +# AppFlush Trait Design + +## Problem + +Observer nodes don't flush pending state to the merkle trie because `flush()` is only called during proof generation in `ProofConversions::try_from_storage()`. Publishers call this after each block, but observers don't generate proofs so `flush()` is never invoked. + +This causes API inconsistencies where `get_balance` returns correct data (reads from `recording.writes` first) but `get_burn_proof` returns null (only reads from the trie). + +## Solution + +Add an `AppFlush` trait that allows the framework to flush app state for observers without requiring proof generation. + +## Design + +### 1. Trait Definition + +In `node/src/transitions.rs`: + +```rust +/// Trait for flushing pending state to persistent storage. +/// +/// This is called by observer nodes after state transitions to ensure +/// pending writes are committed to the underlying storage (e.g., merkle trie). +/// Publisher nodes don't need this because flush happens during proof generation. +pub trait AppFlush { + fn flush(&mut self); +} +``` + +### 2. Framework Changes + +In `node/src/lib.rs`, modify `observer_state_transition_with_storage` to: +1. Add `S::App: AppFlush` trait bound +2. Call `apply.app.flush()` after `api_update` in the Memory branch + +```rust +async fn observer_state_transition_with_storage( + block: Block, + storage: &S, + stf: Arc, + api_update: Arc, +) -> anyhow::Result<()> +where + S: Store, + S::App: AppFlush, // NEW + Stf: AppTransition, + ApiU: ApiTransition, +{ + match storage.storage() { + Storage::Db(db) => { + // Unchanged - Db commits directly + db.apply(move |tx| { + stf.apply(&block, DataStorage::Db(tx))?; + tx.update_latest_block(block.height, void_toolkit::hash::Hash::hash(&block))?; + api_update.apply(&block, DataStorage::Db(tx))?; + Ok(()) + }) + .await + } + Storage::Memory(mem) => mem.apply(|apply| { + stf.apply(&block, DataStorage::Memory(apply.app))?; + apply + .latest_header + .update_latest_block(block.height, void_toolkit::hash::Hash::hash(&block))?; + api_update.apply( + &block, + DataStorage::Memory(&mut FullMemState::new(apply.app, apply.api)), + )?; + apply.app.flush(); // NEW + Ok(()) + }), + } +} +``` + +### 3. Propagate Trait Bounds + +Add `App: AppFlush` bound to all functions in the call chain: + +| Function | Line | Type | +|----------|------|------| +| `observer_state_transition_with_storage` | 609 | internal | +| `run_observer_stream` | 864 | internal | +| `run_optimistic_stream` | 912 | internal | +| `run_zk_observer_stream` | 848 | internal | +| `run_signing_observer_stream` | 789 | internal | +| `run_optimistic` | 772 | internal | +| `run_zk_observer` | 805 | internal | +| `run_signing_node_with_options` | 344 | public | +| `run_zk_node_with_options` | 451 | public | + +Publisher functions (`run_signing_publisher`, `run_zk_publisher`) do NOT need this bound. + +### 4. Re-export Trait + +In `node/src/lib.rs`, ensure the trait is exported: + +```rust +pub use transitions::{AppFlush, AppTransition, ApiTransition}; +``` + +### 5. Application Implementations + +**Increment app** (`apps/increment/src/app.rs`): +```rust +impl void_app_node::transitions::AppFlush for App { + fn flush(&mut self) { + // No pending state - SparseMerkleTree writes directly + } +} +``` + +**Transfers app** (`apps/transfers/src/app.rs`): +```rust +impl void_app_node::transitions::AppFlush for App { + fn flush(&mut self) { + // No pending state - GenericSparseMerkleTree writes directly + } +} +``` + +## Files to Modify + +| File | Change | +|------|--------| +| `node/src/transitions.rs` | Add `AppFlush` trait definition | +| `node/src/lib.rs` | Re-export trait, add `App: AppFlush` bounds to ~9 functions | +| `apps/increment/src/app.rs` | Implement `AppFlush` (no-op) | +| `apps/transfers/src/app.rs` | Implement `AppFlush` (no-op) | + +## Notes + +- Apps with pending state buffers (like orderbook) implement real flush logic +- Apps with direct-write storage (like increment, transfers) implement no-op flush +- The Db storage path doesn't need flushing (commits directly via SQL transactions) +- Only the Memory branch calls flush diff --git a/node/src/lib.rs b/node/src/lib.rs index 64d4288..5694398 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -20,7 +20,7 @@ use crate::{ DataStorage, DataStorageRef, FullMemState, FullMemStateRef, ProofStorageConstraints, ReadStorage, Storage, StorageType, Store, }, - transitions::{ApiTransition, AppTransition}, + transitions::{ApiTransition, AppFlush, AppTransition}, }; #[macro_export] @@ -360,7 +360,7 @@ where Vec: From

, F: AddHandlers, DbData>, Init: InitDb, - App: Default + Send + 'static, + App: AppFlush + Default + Send + 'static, Api: Default + Send + 'static, DbData: Default + DbDataConstraints, { @@ -388,6 +388,7 @@ pub async fn run_signing_node( ) -> anyhow::Result<()> where R: Runner, + R::App: AppFlush, Stf: AppTransition, ApiU: ApiTransition, P: Proof, @@ -468,7 +469,7 @@ where Vec: From

, F: AddHandlers, Init: InitDb, - App: Default + Send + 'static, + App: AppFlush + Default + Send + 'static, Api: Default + Send + 'static, DbData: Default + DbDataConstraints, { @@ -497,6 +498,7 @@ pub async fn run_zk_node( ) -> anyhow::Result<()> where R: Runner, + R::App: AppFlush, Stf: AppTransition, ApiU: ApiTransition, P: Proof, @@ -614,6 +616,7 @@ async fn observer_state_transition_with_storage( ) -> anyhow::Result<()> where S: Store, + S::App: AppFlush, Stf: AppTransition, ApiU: ApiTransition, { @@ -636,6 +639,7 @@ where &block, DataStorage::Memory(&mut FullMemState::new(apply.app, apply.api)), )?; + apply.app.flush(); Ok(()) }), } @@ -737,7 +741,7 @@ where Vec: From>, for<'a>

>::Error: Into, P: Proof, - App: Send + 'static, + App: AppFlush + Send + 'static, Api: Send + 'static, DbData: DbDataConstraints, { @@ -779,7 +783,7 @@ async fn run_optimistic( where Stf: AppTransition, ApiU: ApiTransition, - App: Send + 'static, + App: AppFlush + Send + 'static, Api: Send + 'static, DbData: DbDataConstraints, { @@ -797,6 +801,7 @@ async fn run_signing_observer_stream( where Stf: AppTransition, ApiU: ApiTransition, + App: AppFlush, DbData: DbDataConstraints, { run_observer_stream(node, oracle, signer, proof_heights, stf, api_update).await @@ -813,7 +818,7 @@ async fn run_zk_observer( where Stf: AppTransition, ApiU: ApiTransition, - App: Send + 'static, + App: AppFlush + Send + 'static, Api: Send + 'static, DbData: DbDataConstraints, { @@ -856,6 +861,7 @@ async fn run_zk_observer_stream( where Stf: AppTransition, ApiU: ApiTransition, + App: AppFlush, DbData: DbDataConstraints, { run_observer_stream(node, oracle, signer, proof_heights, stf, api_update).await @@ -872,6 +878,7 @@ async fn run_observer_stream( where Stf: AppTransition, ApiU: ApiTransition, + App: AppFlush, DbData: DbDataConstraints, { let proof_heights_stream = futures::stream::unfold(proof_heights, |mut rx| async { @@ -919,6 +926,7 @@ async fn run_optimistic_stream( where Stf: AppTransition, ApiU: ApiTransition, + App: AppFlush, DbData: DbDataConstraints, { let (last_parent_height, last_parent_hash) = node diff --git a/node/src/transitions.rs b/node/src/transitions.rs index a4a7939..736dc9c 100644 --- a/node/src/transitions.rs +++ b/node/src/transitions.rs @@ -43,3 +43,12 @@ where (self)(block, storage) } } + +/// Trait for flushing pending state to persistent storage. +/// +/// This is called by observer nodes after state transitions to ensure +/// pending writes are committed to the underlying storage (e.g., merkle trie). +/// Publisher nodes don't need this because flush happens during proof generation. +pub trait AppFlush { + fn flush(&mut self); +}