fix(state): canonical SMT value from 64-byte wallet commit messages - #135
Merged
TaprootFreak merged 1 commit intoMay 28, 2026
Merged
Conversation
`State::update` previously inserted the BIP-340 message digest into the commitment SMT, which only happened to match the in-circuit `CommitmentMerkleProofs::commitment()` invariant for 32-byte canonical-digest messages (mint flow). The wallet wire format ships a 64-byte `account_state_hash || output_coins_root` concatenation, and `get_account_state_hash` returned `sha256(message)` for that shape — so the SMT leaf was sha256(asth||ocr) instead of the canonical `hash_concat(asth, ocr)`. The in-circuit SMT inclusion check rejected wallet-shaped commitments, surfacing as `prove_account_update_with_in_and_out_coins_and_sources failed` on the second send from any wallet-built account (`second_send_succeeds_without_prev_commitment_pubkey_field`, PR #132). Special-case the 64-byte message shape: split into `ash || ocr`, reinterpret each half via `digest_from_bytes`, and store `hash_concat(ash, ocr)`. The 32-byte canonical path round-trips through `digest_from_bytes` to the same canonical digest, so mint commitments keep working unchanged. Non-32/non-64 fixtures (test-only) keep the legacy sha256 fallback to avoid a tests-only refactor; production callers never produce that shape. Regression tests pin the contract that 64-byte wallet messages produce the in-circuit canonical SMT value and that the two on-the-wire shapes agree on the SMT entry over identical (ash, ocr) halves. No migration needed: stale wallet-commitment entries refer to account rows wiped by migration 0012 and are unreferenced. Signed-off-by: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com>
TaprootFreak
marked this pull request as ready for review
May 28, 2026 10:23
7 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The e2e regression test
second_send_succeeds_without_prev_commitment_pubkey_fieldadded in PR #132 fails on DEV with500 prove_account_update_with_in_and_out_coins_and_sources failed. This PR fixes the underlying protocol-level mismatch between what the server stores into the commitment SMT and what the in-circuit gadget reconstructs — the bug surfaces on every second send from the same account, regardless ofprev_commitment_pubkey.Root cause
Two on-the-wire commitment shapes hit
/api/commitand the scanner:Commitment.messageshared::ClientAccount::create_commitment)digest_to_bytes(hash_concat(ash, ocr))(canonical Poseidon digest)zk-coins/app/rust/client/src/lib.rs::create_commitment) and the e2eTestWallet::sign_commitaccount_state_hash || output_coins_rootsha256(message)State::updatestoreddigest_from_bytes(commitment.get_account_state_hash())into the SMT — which round-trips correctly for the 32-byte path (mint), but for the 64-byte path produceddigest_from_bytes(sha256(ash \|\| ocr)). The in-circuit gadget (CommitmentMerkleProofs::commitment()and the matchingcircuit/main.rsSMT-inclusion check) expectshash_concat(ash, ocr)— Poseidon, not sha256. The two never reconcile, so any account that built itsaccount.proofthrough a wallet-shaped commit silently couldn't ever passprove_account_update.PR #132's new e2e is the first to exercise two consecutive sends from the same account; everything before only tested
prove_initial, which doesn't consultprev_cmp. The bug had shipped years ago but stayed unobserved until that test landed.Fix
State::updatenow branches oncommitment.message.len():ash+ 32-byteocr, lift each throughdigest_from_bytes, storehash_concat(&ash, &ocr). Matches the in-circuit canonical commitment exactly.digest_from_bytes(commitment.get_account_state_hash())— preserves the 32-byte mint canonical (round-trips back to the samehash_concat(ash, ocr)digest as the 64-byte path) and keeps existing arbitrary-length test fixtures working.Mint commitments and scanner-replay commitments are unaffected (they're 32-byte canonical and round-trip identically before and after this PR).
Why no migration
Migration 0012 (added in PR #132) wiped the
accountstable. The stale wallet-shaped SMT entries persisted before that wipe are unreferenced — noaccount.proofpoints at any pre-fixcommitment_history_root. The MMR keeps growing on top of the existing sequence; old leaves are dead weight, not corruption.Test plan
cargo fmt --checkcargo clippy -p node -p shared -- -D warningscargo clippy -p node --all-features -- -D warningscargo check --workspace --all-features --testscargo test -p node --lib state::tests::update_with -- --test-threads=1— both new unit tests green; existingtest_update_with_*re-run, no regressionci:fulllabel set)second_send_succeeds_without_prev_commitment_pubkey_fieldpasses in the api_remote suiteaccount_node_tests::test_send_coins_second_send_succeeds_without_prev_commitment_pubkeykeeps passing in the slim lane (uses the 32-byte canonical path; behaviour preserved by the else branch)New regression tests
state::tests::update_with_64_byte_wallet_commitment_stores_canonical_hash_concat— pins the 64-byte wallet wire format → canonical SMT value contract.state::tests::update_with_32_byte_canonical_commitment_stores_same_hash_concat_as_64_byte_form— equivalence test between the two wire formats for the same(ash, ocr)pair.Follow-up (out of scope)
zk-coins/appcan migrate to canonical 32-byte commit messages once the wasm exposes a Poseidon helper. Until then this PR keeps the wallet's current wire format working end-to-end.