diff --git a/Cargo.toml b/Cargo.toml index 594b875..44682bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,13 @@ [workspace] -members = ["doxa-client","doxa-server", "doxa-trees", "doxa-e2e", "doxa-subpool-database", "doxa-subpool-operator", "doxa-state-sync"] +members = [ + "doxa-client", + "doxa-server", + "doxa-trees", + "doxa-e2e", + "doxa-subpool-database", + "doxa-subpool-operator", + "doxa-state-sync", +] resolver = "2" [workspace.dependencies] @@ -30,8 +38,15 @@ itertools = "0.14.0" primitive-types = "0.14.0" tiny-keccak = { version = "2.0", features = ["keccak"] } hashbrown = "0.14" -sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "macros", "migrate", "json", "chrono"] } -chrono = { version = "0.4", features = ["serde"] } +sqlx = { version = "0.8", features = [ + "runtime-tokio-rustls", + "postgres", + "macros", + "migrate", + "json", + "chrono", +] } +chrono = { version = "0.4", features = ["serde"] } tower-http = { version = "0.6", features = ["cors", "trace"] } # Uncomment to use local plonky2 checkout instead of git (for debugging). diff --git a/doxa-client/src/plonky2_gadgets/merkle.rs b/doxa-client/src/plonky2_gadgets/merkle.rs index 0df643b..c142ff4 100644 --- a/doxa-client/src/plonky2_gadgets/merkle.rs +++ b/doxa-client/src/plonky2_gadgets/merkle.rs @@ -1,7 +1,7 @@ use plonky2::{ hash::hash_types::{HashOutTarget, RichField}, iop::{ - target::BoolTarget, + target::{BoolTarget, Target}, witness::{PartialWitness, WitnessWrite}, }, plonk::circuit_builder::CircuitBuilder, @@ -56,6 +56,14 @@ pub struct MerkleRootTarget { } impl MerkleRootTarget { + /// Compose `bits` into a single Target encoding the leaf's position in the tree + pub fn pos(&self, builder: &mut CircuitBuilder) -> Target + where + F: RichField + Extendable, + { + builder.le_sum(self.bits.iter()) + } + pub(crate) fn set_witness(&self, pw: &mut PartialWitness, proof: &MerkleProof) { set_merkle_path_witness(pw, &self.siblings, &self.bits, proof); } diff --git a/doxa-client/src/plonky2_gadgets/priv_tx/builder/built_priv_tx.rs b/doxa-client/src/plonky2_gadgets/priv_tx/builder/built_priv_tx.rs index e6188fe..8d18087 100644 --- a/doxa-client/src/plonky2_gadgets/priv_tx/builder/built_priv_tx.rs +++ b/doxa-client/src/plonky2_gadgets/priv_tx/builder/built_priv_tx.rs @@ -433,11 +433,6 @@ impl BuiltPrivTx { // Input note t.private.inotes[i].set_witness(pw, inote); t.private.inotes_nct_merkle[i].set_witness(pw, proof); - pw.set_target( - t.private.inotes_pos[i], - F::from_canonical_u64(proof.pos as u64), - ) - .unwrap(); pw.set_bool_target(t.private.inotes_isactive[i], true) .unwrap(); t.private.dinotes[i].set(pw, Default::default()); // active slot — value unused but target must be set @@ -462,16 +457,10 @@ impl BuiltPrivTx { let proof = &self.inotes_nct_proofs[j]; t.private.inotes[slot].set_witness(pw, note); t.private.inotes_nct_merkle[slot].set_witness(pw, proof); - pw.set_target( - t.private.inotes_pos[slot], - F::from_canonical_u64(proof.pos as u64), - ) - .unwrap(); pw.set_bool_target(t.private.inotes_isactive[slot], true) .unwrap(); t.private.dinotes[slot].set(pw, Default::default()); // active slot — value unused } else { - pw.set_target(t.private.inotes_pos[slot], F::ZERO).unwrap(); pw.set_bool_target(t.private.inotes_isactive[slot], false) .unwrap(); t.private.dinotes[slot].set(pw, self.dinotes[j - n_in]); diff --git a/doxa-client/src/plonky2_gadgets/priv_tx/circuit.rs b/doxa-client/src/plonky2_gadgets/priv_tx/circuit.rs index cc67225..fd011f0 100644 --- a/doxa-client/src/plonky2_gadgets/priv_tx/circuit.rs +++ b/doxa-client/src/plonky2_gadgets/priv_tx/circuit.rs @@ -88,6 +88,14 @@ where let is_update_auth = builder.add_virtual_bool_target_safe(); let is_priv_tx = builder.add_virtual_bool_target_safe(); + // Exactly one kind flag is set for a real tx; all zero for a dummy. + let kind_sum = builder.add_many([ + is_fresh_acc.target, + is_update_auth.target, + is_priv_tx.target, + ]); + builder.connect(kind_sum, not_fake_tx.target); + let root = StateRootTarget(builder.add_virtual_hash()); let mainpool_config_root = MainPoolConfigRootTarget(builder.add_virtual_hash()); @@ -155,12 +163,9 @@ where // Inactive slots are filled with dummy values; all are padded to NOTE_BATCH. let inotes: [StandardNoteTarget; NOTE_BATCH] = core::array::from_fn(|_| builder.add_virtual_note_target()); - let inotes_pos: [Target; NOTE_BATCH] = core::array::from_fn(|_| builder.add_virtual_target()); let inotes_isactive: [BoolTarget; NOTE_BATCH] = core::array::from_fn(|_| builder.add_virtual_bool_target_safe()); let inotes_comm = core::array::from_fn(|i| builder.derive_note_commitment(inotes[i])); - let inotes_null: [NoteNullifierTarget; NOTE_BATCH] = - core::array::from_fn(|i| builder.derive_note_nullifier(inotes_comm[i], inotes_pos[i], nk)); let onotes: [StandardNoteTarget; NOTE_BATCH] = core::array::from_fn(|_| builder.add_virtual_note_target()); @@ -203,6 +208,13 @@ where root, ); + // Step 9b: Bind note positions to merkle proofs + let inotes_pos: [Target; NOTE_BATCH] = + core::array::from_fn(|i| inotes_mrkltrgt[i].pos(builder)); + let inotes_null: [NoteNullifierTarget; NOTE_BATCH] = core::array::from_fn(|i| { + builder.derive_note_nullifier(inotes_comm[i], inotes_pos[i], nk) + }); + // Step 10: Balance invariant — assets are conserved across accounts and notes. // accin_amt + Σ(active inote amounts) == accout_amt + Σ(active onote amounts) builder.assert_balance_invariant( @@ -276,7 +288,7 @@ where accin_null, accout_comm, inotes_null: effective_inotes_null, - onotes_comm: donotes_comm, + onotes_comm: derived_onotes_comm, }; public_targets.register(builder); @@ -317,7 +329,7 @@ pub struct PrivTxCircuit { pub circuit_data: doxa_utils::CircuitDataNative, pub targets: TxCircuitTargets, } - + /// Build the priv_tx circuit using `HashOutput` as the Merkle hasher. pub fn build_priv_tx_circuit() -> PrivTxCircuit { use plonky2::plonk::circuit_data::CircuitConfig; diff --git a/doxa-client/src/plonky2_gadgets/priv_tx/circuit_builder.rs b/doxa-client/src/plonky2_gadgets/priv_tx/circuit_builder.rs index 571936e..c9ca72e 100644 --- a/doxa-client/src/plonky2_gadgets/priv_tx/circuit_builder.rs +++ b/doxa-client/src/plonky2_gadgets/priv_tx/circuit_builder.rs @@ -640,10 +640,9 @@ where let expected_nonce = self.add(accin.nonce, one); self.connect(accout.nonce, expected_nonce); - // acc_ast_root is immutable for FreshAccTx and UpdateAuthTx; PrivTx may update it - // - // not_spend = !is_priv_tx = is_fresh_acc | is_update_auth | (reject pairs), because we - // constrain elsewhere that only 1 flag of the set is set to true at any time + // acc_ast_root is immutable for FreshAccTx and UpdateAuthTx; PrivTx may update it. + // One-hot kind flags (enforced in priv_tx_circuit) make + // !is_priv_tx == (is_fresh_acc | is_update_auth) for real txs. let not_spend = self.not(is_priv_tx); for i in 0..HASH_SIZE { // TODO: use is_fresh_acc | is_update_auth here instead of not_spend diff --git a/doxa-client/src/plonky2_gadgets/priv_tx/tests.rs b/doxa-client/src/plonky2_gadgets/priv_tx/tests.rs index c5155d9..1c0eb9d 100644 --- a/doxa-client/src/plonky2_gadgets/priv_tx/tests.rs +++ b/doxa-client/src/plonky2_gadgets/priv_tx/tests.rs @@ -1,6 +1,8 @@ -use std::{array, sync::Arc}; +use std::{ + array, + sync::{Arc, OnceLock}, +}; -use itertools::Itertools; use plonky2::{ hash::poseidon::PoseidonHash, iop::witness::{PartialWitness, WitnessWrite}, @@ -25,8 +27,9 @@ use crate::{ StandardAccount, StandardNote, SubpoolId, derive_priv_tx_hash, plonky2_gadgets::{ priv_tx::{ - builder::{FakeSpendTxBuilder, FreshAccTxBuilder, SpendTxBuilder, TxSignError}, + builder::{BuiltPrivTx, FakeSpendTxBuilder, FreshAccTxBuilder, SpendTxBuilder, TxSignError}, targets::TxKindFlags, + utils::double_hash_native, }, tests::print_common_data, }, @@ -71,6 +74,235 @@ fn spend_test_acc0( (acc0, spend_sk) } +/// Baseline transaction bundled with the secret keys needed to mutate and re-sign it. +struct PrivTxFixture { + priv_tx: BuiltPrivTx, + approval_sk: PrivateKey, + spend_sk: Option, +} + +impl PrivTxFixture { + fn resign(&mut self, rng: &mut R) { + let nk = self.priv_tx.accin.nk(); + let n_rjct = self.priv_tx.rejected_inotes.len(); + let n_in = self.priv_tx.inotes.len(); + let n_out = self.priv_tx.onotes.len(); + + let inote_nulls: [NoteNullifier; NOTE_BATCH] = array::from_fn(|i| { + if i < n_rjct { + let p = self.priv_tx.rejected_inotes_nct_proofs[i].pos; + self.priv_tx.rejected_inotes[i].nullifier(p, &nk).unwrap() + } else if i - n_rjct < n_in { + let j = i - n_rjct; + let p = self.priv_tx.inotes_nct_proofs[j].pos; + self.priv_tx.inotes[j].nullifier(p, &nk).unwrap() + } else { + let j = i - n_rjct - n_in; + NoteNullifier(HashOutput(double_hash_native(self.priv_tx.dinotes[j]))) + } + }); + let onote_comms: [NoteCommitment; NOTE_BATCH] = array::from_fn(|i| { + if i < n_rjct { + let mut o = self.priv_tx.rejected_inotes[i].clone(); + o.recipient = o.sender; + o.commitment() + } else if i - n_rjct < n_out { + self.priv_tx.onotes[i - n_rjct].commitment() + } else { + let j = i - n_rjct - n_out; + NoteCommitment(HashOutput(double_hash_native(self.priv_tx.donotes[j]))) + } + }); + + let tx_hash = derive_priv_tx_hash( + self.priv_tx.accin.nullifier(), + self.priv_tx.accout.commitment(), + inote_nulls, + onote_comms, + ); + self.priv_tx.tx_hash = tx_hash; + + if self.priv_tx.approval_sig.is_some() { + let k = Scalar::sample(rng); + self.priv_tx.approval_sig = Some(schnorr_sign(&self.approval_sk, &tx_hash.0, k)); + } + if let Some(sk) = &self.spend_sk + && self.priv_tx.spend_sig.is_some() + { + let k = Scalar::sample(rng); + self.priv_tx.spend_sig = Some(schnorr_sign(sk, &tx_hash.0, k)); + } + } + + fn assert_prove_fails(&self) { + self.assert_prove_fails_ctx("prove unexpectedly succeeded — constraint regressed") + } + + fn assert_prove_fails_ctx(&self, ctx: &str) { + let c = priv_tx_circuit(); + assert!(self.priv_tx.prove(&c.circuit_data, &c.targets).is_err(), "{ctx}"); + } +} + +/// Process-wide cached priv_tx circuit. +fn priv_tx_circuit() -> &'static PrivTxCircuit { + static CIRCUIT: OnceLock = OnceLock::new(); + CIRCUIT.get_or_init(super::circuit::build_priv_tx_circuit) +} + +/// Sentinel value used to corrupt merkle proofs in negative tests. +fn bogus_hash() -> HashOutput { + HashOutput([F::from_canonical_u64(0x1234); 4]) +} + +/// Spec for a spend-kind (`is_priv_tx`) baseline fixture +struct SpendSpec { + ast_balance: Option, + input_note_amount: Option, + output_note_amount: Option, +} + +impl SpendSpec { + /// Spend from an AST balance; no input notes. + const fn spend(ast_balance: u64, output_note_amount: u64) -> Self { + Self { + ast_balance: Some(ast_balance), + input_note_amount: None, + output_note_amount: Some(output_note_amount), + } + } + + /// Delegated-consume: one input note into acc, no output notes. + const fn consume(input_note_amount: u64) -> Self { + Self { + ast_balance: None, + input_note_amount: Some(input_note_amount), + output_note_amount: None, + } + } + + /// AST balance, one input note, one output note. + const fn mixed(ast_balance: u64, input_note_amount: u64, output_note_amount: u64) -> Self { + Self { + ast_balance: Some(ast_balance), + input_note_amount: Some(input_note_amount), + output_note_amount: Some(output_note_amount), + } + } + + fn build(self, rng: &mut R) -> PrivTxFixture { + let (approval_sk, approval_cpk, subpool_id, main_pool) = spend_test_subpool(rng); + let (mut acc0, spend_sk) = spend_test_acc0(rng, subpool_id); + let asset_id = AssetId(F::ONE); + + if let Some(bal) = self.ast_balance { + acc0.ast.insert_or_update_asset(asset_id, U256::from(bal)); + } + let peer = StandardAccount::sample(rng, subpool_id); + + let inote = self.input_note_amount.map(|amt| { + StandardNote::create( + rng, + AccountAddress::from_acc(&acc0), + AccountAddress::from_acc(&peer), + U256::from(amt), + asset_id, + [0u8; 512], + ) + }); + + let mut state_tree = MerkleTree::::new(STATE_TREE_DEPTH); + let acc0_pos = state_tree.insert(acc0.commitment().0).unwrap(); + let inote_pos = inote.map(|n| state_tree.insert(n.commitment().0).unwrap()); + + let mut builder = SpendTxBuilder::new(acc0, asset_id).unwrap(); + if let (Some(n), Some(pos)) = (inote, inote_pos) { + builder = builder.add_input_note(n, pos).unwrap(); + } + if let Some(amt) = self.output_note_amount { + builder = builder + .add_output_note( + AccountAddress::from_acc(&peer), + U256::from(amt), + [0u8; 512], + rng, + ) + .unwrap(); + } + let built = builder.fill_dinotes(rng).fill_donotes(rng).build().unwrap(); + + let accin_proof = state_tree.merkle_proof(acc0_pos).unwrap(); + let inote_proofs: Vec<_> = inote_pos + .map(|p| state_tree.merkle_proof(p).unwrap()) + .into_iter() + .collect(); + let subpool = SubpoolConfig::new(approval_cpk); + let subpool_proof = main_pool.full_subpool_proof(&subpool, subpool_id).unwrap(); + + // A spend signature is required only when an output note is emitted. + let mut signed = built; + if self.output_note_amount.is_some() { + signed = signed.spend_sign(&spend_sk, rng).unwrap(); + } + let priv_tx = signed + .approval_sign(&approval_sk, rng) + .unwrap() + .with_account_path(accin_proof) + .with_input_notes_path(inote_proofs) + .with_rejected_notes_path(vec![]) + .with_subpool_proof(subpool_proof) + .into_priv_tx() + .unwrap(); + + PrivTxFixture { + priv_tx, + approval_sk, + spend_sk: self.output_note_amount.is_some().then_some(spend_sk), + } + } +} + +/// Standard spend baseline: AST balance 100, emits a 50-token onote. +const SPEND: SpendSpec = SpendSpec::spend(100, 50); +/// Standard delegated-consume baseline: one 100-token inote, approval sig only. +const CONSUME: SpendSpec = SpendSpec::consume(100); +/// Standard mixed baseline: AST balance 50, consume 100, emit 50. +const MIXED: SpendSpec = SpendSpec::mixed(50, 100, 50); + +/// Baseline FreshAcc: default accin activates into accout (nonce 1, new keys). +fn build_fresh_acc_fixture(rng: &mut R) -> PrivTxFixture { + let (approval_sk, approval_cpk, subpool_id, main_pool) = spend_test_subpool(rng); + + let accin = StandardAccount::sample(rng, subpool_id); + let new_spend_cpk: CompPubKey = PrivateKey::sample(rng).public_key::().into(); + + let state_tree = MerkleTree::::new(STATE_TREE_DEPTH); + let built = FreshAccTxBuilder::new(accin) + .unwrap() + .with_new_spend_key(new_spend_cpk) + .with_delegated_consume() + .fill_dinotes(rng) + .fill_donotes(rng) + .build() + .unwrap(); + + let subpool = SubpoolConfig::new(approval_cpk); + let subpool_proof = main_pool.full_subpool_proof(&subpool, subpool_id).unwrap(); + let priv_tx = built + .approval_sign(&approval_sk, rng) + .unwrap() + .with_state_root(state_tree.root()) + .with_subpool_proof(subpool_proof) + .into_priv_tx() + .unwrap(); + + PrivTxFixture { + priv_tx, + approval_sk, + spend_sk: None, + } +} + /// Consume an input note sent from acc1 to acc0, with delegated consume auth. /// /// Delegated consume: consume_auth.config=false (default). The circuit does not @@ -421,3 +653,119 @@ fn test_prove_fresh_acc_tx() { .verify(proven.0) .expect("verify failed"); } + +/// One-hot kind flags: `is_fresh_acc + is_update_auth + is_priv_tx == not_fake_tx`. +#[test] +fn test_one_hot_kind_flag_constraint() { + for (label, flags) in [ + ("two kind flags + not_fake_tx=1", TxKindFlags { + is_fresh_acc: false, + is_update_auth: true, + is_priv_tx: true, + not_fake_tx: true, + }), + ("kind flag + not_fake_tx=0", TxKindFlags { + is_fresh_acc: false, + is_update_auth: false, + is_priv_tx: true, + not_fake_tx: false, + }), + ] { + let mut fixture = SPEND.build(&mut rand::rng()); + fixture.priv_tx.tx_kind_flags = flags; + fixture.assert_prove_fails_ctx(label); + } +} + +#[test] +fn test_act_membership_constraint() { + let mut fixture = SPEND.build(&mut rand::rng()); + fixture.priv_tx.accin_merkle_proof.siblings[0] = bogus_hash(); + fixture.assert_prove_fails(); +} + +#[test] +fn test_nct_membership_constraint() { + let mut fixture = CONSUME.build(&mut rand::rng()); + fixture.priv_tx.inotes_nct_proofs[0].siblings[0] = bogus_hash(); + fixture.assert_prove_fails(); +} + +#[test] +fn test_subpool_config_membership_constraint() { + let mut fixture = SPEND.build(&mut rand::rng()); + fixture.priv_tx.subpool_proof.main_pool_proof.siblings[0] = bogus_hash(); + fixture.assert_prove_fails(); +} + +#[test] +fn test_account_nonce_must_increment_by_one() { + let mut rng = rand::rng(); + let mut fixture = SPEND.build(&mut rng); + fixture.priv_tx.accout.nonce = Nonce(fixture.priv_tx.accout.nonce.0 + F::ONE); + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + +#[test] +fn test_account_private_identifier_must_match() { + let mut rng = rand::rng(); + let mut fixture = SPEND.build(&mut rng); + fixture.priv_tx.accout.private_identifier.0[0] = F::from_canonical_u64(0x1234); + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + +#[test] +fn test_account_subpool_id_must_match() { + let mut rng = rand::rng(); + let mut fixture = SPEND.build(&mut rng); + fixture.priv_tx.accout.subpool_id = SubpoolId(F::from_canonical_u64(99)); + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + +#[test] +fn test_fresh_account_must_have_zero_nonce() { + let mut rng = rand::rng(); + let mut fixture = build_fresh_acc_fixture(&mut rng); + // Bump both nonces by 1 so the increment-by-one rule stays satisfied and + // only the fresh-acc default-state check fires. + fixture.priv_tx.accin.nonce = Nonce(F::ONE); + fixture.priv_tx.accout.nonce = Nonce(F::from_canonical_u64(2)); + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + +#[test] +fn test_fresh_account_spend_auth_must_be_default() { + let mut rng = rand::rng(); + let mut fixture = build_fresh_acc_fixture(&mut rng); + let sk = PrivateKey::sample(&mut rng); + fixture.priv_tx.accin.spend_auth = SpendAuth::new(sk.public_key::().into()); + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + +#[test] +fn test_fresh_account_consume_auth_must_be_default() { + let mut rng = rand::rng(); + let mut fixture = build_fresh_acc_fixture(&mut rng); + let sk = PrivateKey::sample(&mut rng); + fixture.priv_tx.accin.consume_auth = ConsumeAuth { + config: true, + pk: Some(sk.public_key::().into()), + }; + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + +#[test] +fn test_note_asset_id_must_match_tx_asset_id() { + let mut rng = rand::rng(); + let mut fixture = MIXED.build(&mut rng); + fixture.priv_tx.onotes[0].asset_id = AssetId(F::from_canonical_u64(0xC0FFEE)); + fixture.resign(&mut rng); + fixture.assert_prove_fails(); +} + diff --git a/doxa-trees/src/tree.rs b/doxa-trees/src/tree.rs index c4e8f5b..1761be2 100644 --- a/doxa-trees/src/tree.rs +++ b/doxa-trees/src/tree.rs @@ -120,6 +120,12 @@ impl MerkleTree { &self.leaves } + /// Returns the index of the first stored leaf matching `leaf`. + /// Use only for note position recovery for spend/reject nullifier derivation. + pub fn leaf_position(&self, leaf: &H::Digest) -> Option { + self.leaves.iter().position(|current| current == leaf) + } + pub fn root(&self) -> H::Digest { let last_layer: &Vec = self.layers.last().unwrap(); let root = if last_layer.is_empty() { diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f1fdf6e --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "nightly-2026-03-04"