Observer get-burn-proof API Returns Null Despite Burn Being Processed
Summary
The /get-burn-proof/{owner}/{nonce} API endpoint returns null on the observer node even when:
- The balance has been reduced (indicating the burn was processed)
- The GPU publisher has generated the ZK proof
- Network traffic shows the observer received data from the publisher
Root Cause
The get_burn_proof function only reads from the merkle trie, while get_balance reads from uncommitted state first.
Technical Details
The observer processes state transitions which write to recording.writes (a pending buffer). These writes are only moved to the merkle trie when flush() is called. However, flush() is only called inside Witness::try_from_storage() (part of the ProofConversions trait), which appears to not be invoked for observers.
Inconsistent Read Patterns
| Function |
Checks recording.writes? |
Checks tree? |
Location |
get_balance |
Yes |
Yes |
app.rs:442 |
is_burned |
Yes |
Yes |
app.rs:557 |
get_burn_proof |
No |
Yes |
state_reads.rs:139 |
Code Flow
get_balance (works correctly):
// app.rs:442-450
if let Some(delta) = self.recording.writes.get(&balance_key) {
// Returns from uncommitted state
}
// Falls back to trie
get_burn_proof (broken):
// state_reads.rs:139
let value = match mem.app.tree.get(&key) { // ONLY reads from trie
Ok(Some(v)) => v,
Ok(None) => return Ok(None), // Returns None if not flushed
...
};
Why flush() Isn't Called on Observers
The flush() method is defined in app.rs:585 and is only called from Witness::try_from_storage() at app.rs:662:
fn try_from_storage(store) -> Result<Self> {
match store {
DataStorage::Memory(app) => {
// Generate proof from app's trie
let proof = MultiProofBuilder::new(app.tree.storage(), app.tree.root_hash())
.add_keys(app.recording.keys())
.build()?;
// Flush recording
app.flush(); // <-- Only place flush() is called
Ok(Witness { proof })
}
}
}
This method is part of the ProofConversions trait and is called by the void-app-node framework. For publishers, this is called after each block to generate the ZK proof witness. For observers, if the framework doesn't call this (because observers verify proofs rather than generate them), then flush() is never invoked.
Impact
- Users cannot retrieve burn proofs to complete withdrawals on-chain
- The API returns
null instead of an error, making debugging difficult
- Balance queries work correctly (misleading users into thinking the withdrawal succeeded)
Evidence
- Live endpoint returns null:
https://orderbook-api.bigbangblock.builders/get-burn-proof/00a568abd68d9cb4b5acbd87d7bd04fd95d6bcb4e6/5
- Tests confirm the pattern:
app/tests.rs:163-167 shows that is_burned returns true before flush (from recording), and the trie query only works after flush
- ALB configuration confirms routing:
alb.tf:182-200 routes /get-burn-proof/*/* to the observer target group
Potential Solutions
Option 1: Fix in void-app-node (Proper Fix)
Ensure the framework calls flush() (or try_from_storage) for observers after each block, not just for publishers.
Pros: Fixes the root cause
Cons: Requires changes to external framework
Option 2: Add flush() to api_update
Add a flush() call to the api_update function since it runs after state_transition_function and has mutable access to state.
Pros: Application-level fix
Cons: May interfere with proof generation if framework expects unflushed state; needs careful coordination with void-app-node
Option 3: Make get_burn_proof Check recording.writes
Modify get_burn_proof to check recording.writes first and return a meaningful error if the burn is pending but not flushed.
// Check if burn exists in trie
let value = match mem.app.tree.get(&key) {
Ok(Some(v)) => v,
Ok(None) => {
// Check if pending in recording.writes
if mem.app.recording.writes.contains_key(&key) {
return Err(anyhow::anyhow!(
"Burn exists but proof not yet available - state pending flush"
));
}
return Ok(None);
}
...
};
Pros: Provides better error feedback; no changes to flush timing
Cons: Doesn't fix the underlying issue; users must retry
Option 4: Separate Read Path for Observers
Create an observer-specific API endpoint or mode that reads from recording.writes and returns burn data without a merkle proof (for informational purposes only).
Pros: Works around the issue
Cons: Two different APIs; non-standard behavior
void-app-node Framework Analysis
Publisher vs Observer State Transition Functions
The framework has two separate functions for state transitions in void-examples/node/src/lib.rs:
Publisher: state_transition_with_storage (lines 569-607)
async fn state_transition_with_storage<S, P, Stf, ApiU>(
block: Block,
storage: &S,
stf: Arc<Stf>,
api_update: Arc<ApiU>,
) -> anyhow::Result<(Block, Height<P>)>
{
// ...
Storage::Memory(mem) => mem.apply(|apply| {
stf.apply(&block, DataStorage::Memory(apply.app))?;
apply.latest_header.update_latest_block(...)?;
let proof = P::try_from(DataStorage::Memory(apply.app))?; // <-- CALLS try_from which flushes
api_update.apply(&block, ...)?;
Ok((block, Height::new(height, proof)))
}),
}
Observer: observer_state_transition_with_storage (lines 609-642)
async fn observer_state_transition_with_storage<S, Stf, ApiU>(
block: Block,
storage: &S,
stf: Arc<Stf>,
api_update: Arc<ApiU>,
) -> anyhow::Result<()> // Note: returns () not (Block, Height<P>)
{
// ...
Storage::Memory(mem) => mem.apply(|apply| {
stf.apply(&block, DataStorage::Memory(apply.app))?;
apply.latest_header.update_latest_block(...)?;
api_update.apply(&block, ...)?;
Ok(()) // <-- NO proof generation, NO flush!
}),
}
Key Difference: Observer function does not have P as a type parameter and never calls P::try_from(), which means ProofConversions::try_from_storage() is never invoked, and therefore flush() is never called.
Why Observers Don't Generate Proofs
This is intentional - observers verify proofs received from publishers rather than generating their own. The proof type P is not even available in the observer function signature.
Recommended Fix in void-app-node
Option A: Add AppFlush Trait (Cleanest)
Add a new trait that allows flushing without proof generation:
// In void-app-node/src/transitions.rs
pub trait AppFlush {
fn flush(&mut self);
}
Then modify observer_state_transition_with_storage:
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: require App to implement AppFlush
Stf: AppTransition<S::App, S::DbData>,
ApiU: ApiTransition<S::App, S::Api, S::DbData>,
{
match storage.storage() {
// ... Db branch unchanged ...
Storage::Memory(mem) => mem.apply(|apply| {
stf.apply(&block, DataStorage::Memory(apply.app))?;
apply.latest_header.update_latest_block(...)?;
api_update.apply(&block, ...)?;
apply.app.flush(); // NEW: flush after api_update
Ok(())
}),
}
}
Application side (orderbook):
// In crates/orderbook/src/app.rs
impl void_app_node::transitions::AppFlush for App {
fn flush(&mut self) {
App::flush(self); // Reuse existing flush method
}
}
Pros:
- Clean separation of concerns
- No proof generation overhead
- Application controls flush implementation
- Framework explicitly supports the pattern
Cons:
- Requires adding trait bound to observer function
- Applications must implement the trait
Option B: Add flush_storage to ProofConversions Trait
Extend the existing trait with an optional flush method:
// In void-app-node/src/proof.rs
pub trait ProofConversions {
type App;
type DbData;
// Existing methods...
fn digest(&self) -> [u8; 32];
fn into_bytes(self) -> Vec<u8>;
fn try_from_bytes(bytes: &[u8]) -> anyhow::Result<Self> where Self: Sized;
fn try_from_storage(store: DataStorage<'_, '_, Self::App, Self::DbData>) -> anyhow::Result<Self> where Self: Sized;
// NEW: Flush storage without generating proof
fn flush_storage(store: DataStorage<'_, '_, Self::App, Self::DbData>) {
// Default implementation calls try_from_storage and discards result
let _ = Self::try_from_storage(store);
}
}
Then modify observer_state_transition_with_storage to call P::flush_storage() where P is passed as a type parameter.
Pros:
- Uses existing trait infrastructure
- Applications can override with optimized implementation
Cons:
- Default implementation wastes CPU on proof generation
- Requires adding
P type parameter to observer function
Option C: Post-Block Flush Hook
Add a hook in the observer stream processing:
// In run_observer_stream() at line 864-910
oracle_stream
.block_height_parent(last_parent_height, last_parent_hash)
.blocks_await_proofs(proof_heights_stream)
.and_then(|block| {
observer_state_transition_with_storage(block, &node.storage, stf.clone(), api_update.clone())
})
.and_then(|_| {
// NEW: Flush after each block
node.storage.flush_app()
})
.push_notification(node.state_notification.clone())
.try_for_each(|_| std::future::ready(Ok(())))
.await?;
This requires adding a flush_app() method to the Storage trait.
Recommended Approach
- Immediate fix: Implement Option A (AppFlush trait) in void-app-node
- Application change: Implement
AppFlush for App in orderbook
This is the cleanest solution because:
- It's explicit about what observers need
- No wasted proof computation
- Applications control their flush logic
- The trait bound makes the requirement visible in the type system
Files to Modify
void-app-node (framework)
src/transitions.rs - Add AppFlush trait
src/lib.rs - Add S::App: AppFlush bound to observer_state_transition_with_storage and call apply.app.flush()
orderbook (application)
crates/orderbook/src/app.rs - Implement AppFlush for App
Related Code References
void-app-node
observer_state_transition_with_storage: lib.rs:609-642
state_transition_with_storage (publisher): lib.rs:569-607
Memory::apply: memory.rs:105-116
ProofConversions trait: proof.rs:32-45
orderbook
flush() definition: app.rs:585-598
try_from_storage (calls flush): app.rs:648-666
get_burn_proof (only reads trie): state_reads.rs:122-183
get_balance (reads recording first): app.rs:428-456
is_burned (reads recording first): app.rs:553-568
Observer
get-burn-proofAPI Returns Null Despite Burn Being ProcessedSummary
The
/get-burn-proof/{owner}/{nonce}API endpoint returnsnullon the observer node even when:Root Cause
The
get_burn_prooffunction only reads from the merkle trie, whileget_balancereads from uncommitted state first.Technical Details
The observer processes state transitions which write to
recording.writes(a pending buffer). These writes are only moved to the merkle trie whenflush()is called. However,flush()is only called insideWitness::try_from_storage()(part of theProofConversionstrait), which appears to not be invoked for observers.Inconsistent Read Patterns
recording.writes?tree?get_balanceapp.rs:442is_burnedapp.rs:557get_burn_proofstate_reads.rs:139Code Flow
get_balance(works correctly):get_burn_proof(broken):Why
flush()Isn't Called on ObserversThe
flush()method is defined inapp.rs:585and is only called fromWitness::try_from_storage()atapp.rs:662:This method is part of the
ProofConversionstrait and is called by thevoid-app-nodeframework. For publishers, this is called after each block to generate the ZK proof witness. For observers, if the framework doesn't call this (because observers verify proofs rather than generate them), thenflush()is never invoked.Impact
nullinstead of an error, making debugging difficultEvidence
https://orderbook-api.bigbangblock.builders/get-burn-proof/00a568abd68d9cb4b5acbd87d7bd04fd95d6bcb4e6/5app/tests.rs:163-167shows thatis_burnedreturns true before flush (fromrecording), and the trie query only works after flushalb.tf:182-200routes/get-burn-proof/*/*to the observer target groupPotential Solutions
Option 1: Fix in void-app-node (Proper Fix)
Ensure the framework calls
flush()(ortry_from_storage) for observers after each block, not just for publishers.Pros: Fixes the root cause
Cons: Requires changes to external framework
Option 2: Add flush() to api_update
Add a
flush()call to theapi_updatefunction since it runs afterstate_transition_functionand has mutable access to state.Pros: Application-level fix
Cons: May interfere with proof generation if framework expects unflushed state; needs careful coordination with void-app-node
Option 3: Make get_burn_proof Check recording.writes
Modify
get_burn_proofto checkrecording.writesfirst and return a meaningful error if the burn is pending but not flushed.Pros: Provides better error feedback; no changes to flush timing
Cons: Doesn't fix the underlying issue; users must retry
Option 4: Separate Read Path for Observers
Create an observer-specific API endpoint or mode that reads from
recording.writesand returns burn data without a merkle proof (for informational purposes only).Pros: Works around the issue
Cons: Two different APIs; non-standard behavior
void-app-node Framework Analysis
Publisher vs Observer State Transition Functions
The framework has two separate functions for state transitions in
void-examples/node/src/lib.rs:Publisher:
state_transition_with_storage(lines 569-607)Observer:
observer_state_transition_with_storage(lines 609-642)Key Difference: Observer function does not have
Pas a type parameter and never callsP::try_from(), which meansProofConversions::try_from_storage()is never invoked, and thereforeflush()is never called.Why Observers Don't Generate Proofs
This is intentional - observers verify proofs received from publishers rather than generating their own. The proof type
Pis not even available in the observer function signature.Recommended Fix in void-app-node
Option A: Add
AppFlushTrait (Cleanest)Add a new trait that allows flushing without proof generation:
Then modify
observer_state_transition_with_storage:Application side (orderbook):
Pros:
Cons:
Option B: Add
flush_storagetoProofConversionsTraitExtend the existing trait with an optional flush method:
Then modify
observer_state_transition_with_storageto callP::flush_storage()wherePis passed as a type parameter.Pros:
Cons:
Ptype parameter to observer functionOption C: Post-Block Flush Hook
Add a hook in the observer stream processing:
This requires adding a
flush_app()method to theStoragetrait.Recommended Approach
AppFlushforAppin orderbookThis is the cleanest solution because:
Files to Modify
void-app-node (framework)
src/transitions.rs- AddAppFlushtraitsrc/lib.rs- AddS::App: AppFlushbound toobserver_state_transition_with_storageand callapply.app.flush()orderbook (application)
crates/orderbook/src/app.rs- ImplementAppFlushforAppRelated Code References
void-app-node
observer_state_transition_with_storage:lib.rs:609-642state_transition_with_storage(publisher):lib.rs:569-607Memory::apply:memory.rs:105-116ProofConversionstrait:proof.rs:32-45orderbook
flush()definition:app.rs:585-598try_from_storage(calls flush):app.rs:648-666get_burn_proof(only reads trie):state_reads.rs:122-183get_balance(reads recording first):app.rs:428-456is_burned(reads recording first):app.rs:553-568