Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/increment/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 6 additions & 0 deletions apps/transfers/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ();
Expand Down
136 changes: 136 additions & 0 deletions docs/plans/2026-01-14-app-flush-trait-design.md
Original file line number Diff line number Diff line change
@@ -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<S, Stf, ApiU>(
block: Block,
storage: &S,
stf: Arc<Stf>,
api_update: Arc<ApiU>,
) -> anyhow::Result<()>
where
S: Store,
S::App: AppFlush, // NEW
Stf: AppTransition<S::App, S::DbData>,
ApiU: ApiTransition<S::App, S::Api, S::DbData>,
{
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
20 changes: 14 additions & 6 deletions node/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use crate::{
DataStorage, DataStorageRef, FullMemState, FullMemStateRef, ProofStorageConstraints,
ReadStorage, Storage, StorageType, Store,
},
transitions::{ApiTransition, AppTransition},
transitions::{ApiTransition, AppFlush, AppTransition},
};

#[macro_export]
Expand Down Expand Up @@ -360,7 +360,7 @@ where
Vec<u8>: From<P>,
F: AddHandlers<App, Api, SignedProof<P>, DbData>,
Init: InitDb,
App: Default + Send + 'static,
App: AppFlush + Default + Send + 'static,
Api: Default + Send + 'static,
DbData: Default + DbDataConstraints,
{
Expand Down Expand Up @@ -388,6 +388,7 @@ pub async fn run_signing_node<R, P, Stf, ApiU, F, Init>(
) -> anyhow::Result<()>
where
R: Runner,
R::App: AppFlush,
Stf: AppTransition<R::App, R::DbData>,
ApiU: ApiTransition<R::App, R::Api, R::DbData>,
P: Proof<R::App, R::DbData>,
Expand Down Expand Up @@ -468,7 +469,7 @@ where
Vec<u8>: From<P>,
F: AddHandlers<App, Api, ZkProof, DbData>,
Init: InitDb,
App: Default + Send + 'static,
App: AppFlush + Default + Send + 'static,
Api: Default + Send + 'static,
DbData: Default + DbDataConstraints,
{
Expand Down Expand Up @@ -497,6 +498,7 @@ pub async fn run_zk_node<R, P, Stf, ApiU, F, Init>(
) -> anyhow::Result<()>
where
R: Runner,
R::App: AppFlush,
Stf: AppTransition<R::App, R::DbData>,
ApiU: ApiTransition<R::App, R::Api, R::DbData>,
P: Proof<R::App, R::DbData>,
Expand Down Expand Up @@ -614,6 +616,7 @@ async fn observer_state_transition_with_storage<S, Stf, ApiU>(
) -> anyhow::Result<()>
where
S: Store,
S::App: AppFlush,
Stf: AppTransition<S::App, S::DbData>,
ApiU: ApiTransition<S::App, S::Api, S::DbData>,
{
Expand All @@ -636,6 +639,7 @@ where
&block,
DataStorage::Memory(&mut FullMemState::new(apply.app, apply.api)),
)?;
apply.app.flush();
Ok(())
}),
}
Expand Down Expand Up @@ -737,7 +741,7 @@ where
Vec<u8>: From<SignedProof<P>>,
for<'a> <P as TryFrom<&'a [u8]>>::Error: Into<anyhow::Error>,
P: Proof<App, DbData>,
App: Send + 'static,
App: AppFlush + Send + 'static,
Api: Send + 'static,
DbData: DbDataConstraints,
{
Expand Down Expand Up @@ -779,7 +783,7 @@ async fn run_optimistic<App, Api, DbData, AppProof, Stf, ApiU>(
where
Stf: AppTransition<App, DbData>,
ApiU: ApiTransition<App, Api, DbData>,
App: Send + 'static,
App: AppFlush + Send + 'static,
Api: Send + 'static,
DbData: DbDataConstraints,
{
Expand All @@ -797,6 +801,7 @@ async fn run_signing_observer_stream<App, Api, DbData, P, Stf, ApiU>(
where
Stf: AppTransition<App, DbData>,
ApiU: ApiTransition<App, Api, DbData>,
App: AppFlush,
DbData: DbDataConstraints,
{
run_observer_stream(node, oracle, signer, proof_heights, stf, api_update).await
Expand All @@ -813,7 +818,7 @@ async fn run_zk_observer<App, Api, DbData, Stf, ApiU>(
where
Stf: AppTransition<App, DbData>,
ApiU: ApiTransition<App, Api, DbData>,
App: Send + 'static,
App: AppFlush + Send + 'static,
Api: Send + 'static,
DbData: DbDataConstraints,
{
Expand Down Expand Up @@ -856,6 +861,7 @@ async fn run_zk_observer_stream<App, Api, DbData, Stf, ApiU>(
where
Stf: AppTransition<App, DbData>,
ApiU: ApiTransition<App, Api, DbData>,
App: AppFlush,
DbData: DbDataConstraints,
{
run_observer_stream(node, oracle, signer, proof_heights, stf, api_update).await
Expand All @@ -872,6 +878,7 @@ async fn run_observer_stream<App, Api, DbData, AppProof, Stf, ApiU>(
where
Stf: AppTransition<App, DbData>,
ApiU: ApiTransition<App, Api, DbData>,
App: AppFlush,
DbData: DbDataConstraints,
{
let proof_heights_stream = futures::stream::unfold(proof_heights, |mut rx| async {
Expand Down Expand Up @@ -919,6 +926,7 @@ async fn run_optimistic_stream<App, Api, DbData, AppProof, Stf, ApiU>(
where
Stf: AppTransition<App, DbData>,
ApiU: ApiTransition<App, Api, DbData>,
App: AppFlush,
DbData: DbDataConstraints,
{
let (last_parent_height, last_parent_hash) = node
Expand Down
9 changes: 9 additions & 0 deletions node/src/transitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Loading