From d6c73da584887260ea165685f1737a94f28584c3 Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 14 Jan 2026 10:04:11 +1100 Subject: [PATCH 1/2] docs: add AppFlush trait design document Design for fixing observer nodes not flushing pending state to the merkle trie. Adds AppFlush trait to allow framework to flush app state for observers without requiring proof generation. --- .../2026-01-14-app-flush-trait-design.md | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 docs/plans/2026-01-14-app-flush-trait-design.md 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 From b96dc591c1c6f60adcc5eb27078e4d63049861dd Mon Sep 17 00:00:00 2001 From: freesig Date: Wed, 14 Jan 2026 11:03:38 +1100 Subject: [PATCH 2/2] feat(node): add AppFlush trait for observer state flushing Observers don't generate proofs, so flush() was never called after state transitions. This caused API inconsistencies where get_balance worked (reads from pending writes) but get_burn_proof returned null (only reads from flushed trie). - Add AppFlush trait to transitions.rs - Add App: AppFlush bounds to observer/optimistic functions - Call flush() after api_update in observer_state_transition_with_storage - Implement AppFlush for increment and transfers apps (no-op) --- apps/increment/src/app.rs | 6 ++++++ apps/transfers/src/app.rs | 6 ++++++ node/src/lib.rs | 20 ++++++++++++++------ node/src/transitions.rs | 9 +++++++++ 4 files changed, 35 insertions(+), 6 deletions(-) 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/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); +}