Skip to content

test_mock_proof_public_data_matches_witnesses#2736

Draft
bkolad wants to merge 5 commits intodevfrom
blaze/debug_circuit
Draft

test_mock_proof_public_data_matches_witnesses#2736
bkolad wants to merge 5 commits intodevfrom
blaze/debug_circuit

Conversation

@bkolad
Copy link
Copy Markdown
Member

@bkolad bkolad commented Apr 14, 2026

What Was Broken
The failing test was showing that native witness generation and guest-side replay produced different public data, specifically a different final state root. The important part is that the STF logic was not the problem. The bug was in how Sovereign eplayed NOMT state updates inside the guest.

Before the fix, Sovereign effectively treated NOMT like:

  • one compact multiproof for reads
  • one flat sorted list of writes for updates

That is too lossy. NOMT update verification is path-scoped, not just “all writes sorted by key”.

  1. Add a Regression Test
    The new test in examples/demo-rollup/tests/prover/sp1_cpu_prover.rs:79 runs the mock SP1 guest for each witness and checks that the guest’s public outputs match the witness:
  • initial_state_root
  • final_state_root
  • slot_hash

The helper in examples/demo-rollup/tests/prover/sp1_cpu_prover.rs:160 deserializes public_values from the mock proof so the test compares exactly what the guest computed.

That test is what exposed the NOMT replay bug.

  1. Preserve the Full NOMT Witness on the Prover Side
    The key prover-side change is in crates/module-system/sov-state/src/nomt/prover_storage.rs:537.

Before this change, the prover took NOMT’s full witness and collapsed it into a MultiProof, discarding:

  • original witnessed paths
  • NOMT read/write operation metadata
  • path-to-operation structure

Now it stores the full nomt::Witness in the Sovereign witness:

  • path_proofs
  • operations.reads
  • operations.writes

That is necessary because the verifier needs more than a flat multiproof if it wants to replay updates correctly.

  1. Rewrite Guest-Side Replay Around the Full Witness
    The main fix is in crates/module-system/sov-state/src/nomt/zk_storage.rs:37.

The flow now is:

  1. Build canonical expected read/write maps from Sovereign state accesses.
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:46
  2. Deserialize the full NomtWitness.
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:69
  3. Reconstruct a MultiProof from the witnessed paths and use it only for reads.
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:70
  4. Verify all reads against that multiproof.
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:83
  5. Validate the witnessed write set against expected writes:
  • no unexpected writes
  • no missing writes
  • no duplicates
  • witnessed value hash must exactly match the expected authenticated write hash
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:108
  1. Verify each path proof independently into a VerifiedPathProof.
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:133
  2. Group each write onto the deepest matching path prefix using:
  • the verified path depth
  • a canonical truncated prefix key
  • a map keyed by (depth, truncated_prefix)
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:151
    Support function: crates/module-system/sov-state/src/nomt/zk_storage.rs:194
  1. Build PathUpdates and call NOMT’s path-based verify_update.
    Code: crates/module-system/sov-state/src/nomt/zk_storage.rs:179

Why This Fix Works
The old approach was wrong because it replayed updates as a flat batch. Reads can be checked that way, but writes cannot. NOMT updates need each write attached to a path that actually scopes that key.

The final version fixes that by:

  • keeping the full NOMT witness
  • using the multiproof only for read verification
  • validating the exact write set
  • regrouping writes by actual path scope
  • using path-based verify_update

That is why the final_state_root mismatch disappeared.

Why There Was a Second Failure on Larger Data
After the first fix, larger data still hit OpOutOfScope. That showed another issue: you could not safely rely on NOMT’s serialized write-to-path bookkeeping alone for guest replay.

The current code avoids that by deriving write grouping from actual path prefixes instead of trusting the witness path_index. The likely upstream cause is NOMT witness assembly under larger workloads; that part is an inference. The proven part is that
regrouping by prefix scope fixes the failure.

Net Effect
The PR changes the protocol from:

  • “store compact multiproof, replay flat writes”

to:

  • “store full NOMT witness, verify reads globally, verify updates path-by-path”

  • I have updated CHANGELOG.md with a new entry if my PR makes any breaking changes or fixes a bug. If my PR removes a feature or changes its behavior, I provide help for users on how to migrate to the new behavior.

  • I have carefully reviewed all my Cargo.toml changes before opening the PRs. (Are all new dependencies necessary? Is any module dependency leaked into the full-node (hint: it shouldn't)?)

Linked Issues

  • Fixes # (issue, if applicable)
  • Related to # (issue)

Testing

Describe how these changes were tested. If you've added new features, have you added unit tests?

Docs

Describe where this code is documented. If it changes a documented interface, have the docs been updated?

Rendered docs are available at https://sovlabs-ci-rustdoc-artifacts.us-east-1.amazonaws.com/<BRANCH_NAME>/index.html

}

let multi_proof: MultiProof = array_witness.get_hint();
let nomt_witness: NomtWitness = array_witness.get_hint();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This duplicates the information we send to ZK.

NomtWitness contains operations, which has matching data to state_accesses

pub struct NomtWitness {
    pub path_proofs: Vec<WitnessedPath>,
    pub operations: WitnessedOperations,
}

pub struct WitnessedOperations {
    pub reads: Vec<WitnessedRead>,
    pub writes: Vec<WitnessedWrite>,
}

pub struct WitnessedPath {
    pub inner: PathProof,
    pub path: TriePosition,
}

pub struct WitnessedRead {
    pub key: KeyPath,
    pub value: Option<ValueHash>,
    pub path_index: usize,
}

.collect::<anyhow::Result<Vec<_>>>()?;
verified_paths.sort_by(|a, b| a.0.path().cmp(b.0.path()));

let mut path_index_by_prefix = BTreeMap::new();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this done? This seems like this should be part of nomt

})
.ok_or_else(|| anyhow::anyhow!("No NOMT path proof covers write key {:?}", key))?;

verified_paths[matching_index].3.push((key, value));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is verified_paths exactly in the end? What does it represent?

anyhow::bail!("Missing NOMT write witness entries");
}

let mut verified_paths = nomt_witness
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let mut verified_paths = nomt_witness
// Vec<(VerifiedPathProof, usize, KeyPath, Vec<(KeyPath, Option<ValueHash>)>)>
let mut verified_paths = nomt_witness

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is pretty complicated variable.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants