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
67 changes: 59 additions & 8 deletions node/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use shared::SECP256K1;
use sqlx::PgPool;
use std::collections::HashMap;
use zkcoins_program::circuit::main::MMR_PROOF_PATH_LEN;
use zkcoins_program::hash::{hash_concat, HashDigest, ZERO_HASH};
use zkcoins_program::hash::{digest_from_bytes, hash_concat, HashDigest, ZERO_HASH};
use zkcoins_program::merkle::merkle_mountain_range::{MMRProof, MerkleMountainRange};
use zkcoins_program::merkle::sparse_merkle_tree::{InclusionProof, SparseMerkleTree};

Expand Down Expand Up @@ -178,14 +178,65 @@ impl State {
let key_bytes = commitment.public_key.serialize();
let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array();

// Store the BIP-340 message digest (32 raw bytes) reinterpreted
// as a Poseidon `HashOut<F>` — `digest_from_bytes` is the
// canonical inverse of `digest_to_bytes` (round-trip safe).
let message_bytes = commitment.get_account_state_hash();
let message_data = zkcoins_program::hash::digest_from_bytes(&message_bytes);
// The SMT value is the canonical Poseidon combiner of the
// commitment's two halves:
//
// smt_value = hash_concat(asth, ocr)
//
// That is exactly what the in-circuit gadget reconstructs in
// `CommitmentMerkleProofs::commitment()`
// (`program-plonky2/src/inputs.rs`) and feeds back into the
// SMT inclusion check. Any other value here surfaces as the
// server-side `prove_account_update_*` failing on the second
// send from an account (the first end-to-end test that
// exercises a non-initial proof is
// `second_send_succeeds_without_prev_commitment_pubkey_field`,
// added in PR #132).
//
// The protocol ships two on-the-wire shapes for
// `Commitment.message`, and both must produce the canonical
// SMT value:
//
// * 64 bytes — wallet wire format
// (`zk-coins/app/rust/client/src/lib.rs::create_commitment`,
// mirrored by `TestWallet::sign_commit` in
// `node/tests/api_remote.rs`): raw concatenation
// `asth_bytes || ocr_bytes`. The Schnorr signature is
// over `sha256(message)` (see `Commitment::verify` in
// `shared/src/commitment.rs`), but the SMT value MUST
// ignore that signature digest and reconstruct the
// canonical Poseidon combiner over the two halves.
// * 32 bytes — mint flow (`ClientAccount::create_commitment`
// in `shared/src/lib.rs`): the already-canonical
// `digest_to_bytes(hash_concat(asth, ocr))`. Round-trips
// through `digest_from_bytes` and recovers the same
// canonical `hash_concat(asth, ocr)` digest the 64-byte
// path produces — so the two forms agree on the SMT
// entry, by construction.
//
// Any other length is a test-only fixture (existing
// `state_tests.rs` uses arbitrary byte slices to exercise
// the surrounding state machinery); production callers
// never produce that shape, so we preserve the legacy
// sha256-fallback path via `get_account_state_hash` rather
// than forcing a tests-only refactor. The SMT value on
// that path is opaque but consistent — fine for the test
// surface, never reached by deployed code.
let smt_value = if commitment.message.len() == 64 {
let mut ash_bytes = [0u8; 32];
let mut ocr_bytes = [0u8; 32];
ash_bytes.copy_from_slice(&commitment.message[..32]);
ocr_bytes.copy_from_slice(&commitment.message[32..]);
let ash = digest_from_bytes(&ash_bytes);
let ocr = digest_from_bytes(&ocr_bytes);
hash_concat(&ash, &ocr)
} else {
let message_bytes = commitment.get_account_state_hash();
digest_from_bytes(&message_bytes)
};

// Update the SMT with just the message
self.smt.insert(key, message_data)?;
// Update the SMT with the canonical commitment value.
self.smt.insert(key, smt_value)?;
}

// 2. Get the current SMT root
Expand Down
131 changes: 131 additions & 0 deletions node/src/state_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -918,3 +918,134 @@ fn derive_num_pubkeys_from_smt_panics_on_loop_bound_exceeded() {
}
let _ = derive_num_pubkeys_from_smt_with_bound(&xpriv, &smt, BOUND);
}

// ---- canonical SMT value from wallet-shaped commit message ----------------

/// A 64-byte wallet-shaped `Commitment.message` (raw
/// `account_state_hash || output_coins_root` concatenation, as built by
/// `zk-coins/app/rust/client/src/lib.rs::create_commitment` and mirrored
/// by `TestWallet::sign_commit` in `node/tests/api_remote.rs`) must end
/// up in the SMT as the canonical Poseidon combiner
/// `hash_concat(digest_from_bytes(ash), digest_from_bytes(ocr))`.
///
/// This is the value the in-circuit `CommitmentMerkleProofs::commitment()`
/// (`program-plonky2/src/inputs.rs`) reconstructs and feeds back into
/// the SMT inclusion check. Storing the sha256 of the 64-byte message
/// (the legacy `get_account_state_hash` shape) caused
/// `prove_account_update_with_in_and_out_coins_and_sources` to reject
/// the second send from any wallet-built account — the e2e regression
/// is `second_send_succeeds_without_prev_commitment_pubkey_field` in
/// PR #132.
#[test]
fn update_with_64_byte_wallet_commitment_stores_canonical_hash_concat() {
let mut state = State::new();

// Build a 64-byte message: 32 ash bytes || 32 ocr bytes. Distinct
// byte patterns so the two halves can't accidentally agree.
let ash_bytes: [u8; 32] = [0xAAu8; 32];
let ocr_bytes: [u8; 32] = [0xCCu8; 32];
let mut message = Vec::with_capacity(64);
message.extend_from_slice(&ash_bytes);
message.extend_from_slice(&ocr_bytes);
assert_eq!(message.len(), 64);

let secret_key =
SecretKey::from_str("000000000000000000000000000000000000000000000000000000000000000a")
.expect("Invalid key");
let commitment = Commitment::new(&secret_key, message).expect("commitment");

state
.update(std::slice::from_ref(&commitment))
.expect("update");

// Independently compute the canonical SMT value the in-circuit
// gadget reconstructs.
let expected = hash_concat(
&digest_from_bytes(&ash_bytes),
&digest_from_bytes(&ocr_bytes),
);

// Retrieve the stored leaf via the SMT inclusion proof and assert
// it equals the canonical value.
let key_bytes = commitment.public_key.serialize();
let key: [u8; 32] = bitcoin::hashes::sha256::Hash::hash(&key_bytes).to_byte_array();
let (_proof, stored) = state
.smt
.generate_inclusion_proof(&key)
.expect("inclusion proof for wallet commitment key");
assert_eq!(
stored, expected,
"wallet 64-byte commit message must produce the canonical hash_concat(ash, ocr) SMT value"
);
}

/// Equivalence test: a 32-byte canonical-digest commit message (mint
/// flow shape, `ClientAccount::create_commitment` in `shared/src/lib.rs`)
/// and a 64-byte wallet-shape commit message over the SAME (ash, ocr)
/// pair must produce the SAME SMT entry. Documents that the two
/// on-the-wire shapes agree on the canonical SMT value, so a wallet
/// commitment and a mint commitment over identical halves are
/// indistinguishable from the SMT's perspective.
///
/// Uses two distinct secret keys so both commitments coexist in the
/// same SMT — the assertion is on the stored leaf VALUES, not on the
/// keys.
#[test]
fn update_with_32_byte_canonical_commitment_stores_same_hash_concat_as_64_byte_form() {
let mut state = State::new();

let ash_bytes: [u8; 32] = [0x11u8; 32];
let ocr_bytes: [u8; 32] = [0x22u8; 32];
let ash = digest_from_bytes(&ash_bytes);
let ocr = digest_from_bytes(&ocr_bytes);
let canonical_digest = hash_concat(&ash, &ocr);
let canonical_bytes = zkcoins_program::hash::digest_to_bytes(&canonical_digest);

// 32-byte canonical-digest form (mint flow shape).
let mint_secret =
SecretKey::from_str("000000000000000000000000000000000000000000000000000000000000000b")
.expect("invalid key");
let mint_commitment =
Commitment::new(&mint_secret, canonical_bytes.to_vec()).expect("mint commitment");
assert_eq!(mint_commitment.message.len(), 32);

// 64-byte wallet wire form over the SAME (ash, ocr).
let mut wallet_message = Vec::with_capacity(64);
wallet_message.extend_from_slice(&ash_bytes);
wallet_message.extend_from_slice(&ocr_bytes);
let wallet_secret =
SecretKey::from_str("000000000000000000000000000000000000000000000000000000000000000c")
.expect("invalid key");
let wallet_commitment =
Commitment::new(&wallet_secret, wallet_message).expect("wallet commitment");
assert_eq!(wallet_commitment.message.len(), 64);

state
.update(&[mint_commitment.clone(), wallet_commitment.clone()])
.expect("update");

let mint_key: [u8; 32] =
bitcoin::hashes::sha256::Hash::hash(&mint_commitment.public_key.serialize())
.to_byte_array();
let wallet_key: [u8; 32] =
bitcoin::hashes::sha256::Hash::hash(&wallet_commitment.public_key.serialize())
.to_byte_array();

let (_mint_proof, mint_stored) = state
.smt
.generate_inclusion_proof(&mint_key)
.expect("mint inclusion proof");
let (_wallet_proof, wallet_stored) = state
.smt
.generate_inclusion_proof(&wallet_key)
.expect("wallet inclusion proof");

assert_eq!(
mint_stored, wallet_stored,
"32-byte canonical and 64-byte wallet commit-message forms must store the same SMT value"
);
assert_eq!(
mint_stored, canonical_digest,
"stored SMT value must equal hash_concat(ash, ocr)"
);
}
Loading