From 526c3cd97830d6045807aa3128b9c68f8eef0e69 Mon Sep 17 00:00:00 2001 From: Moudy Date: Thu, 16 Apr 2026 18:07:32 +0200 Subject: [PATCH] test: add private PDA circuit tests and two guest programs --- nssa/src/program.rs | 20 +++ nssa/src/state.rs | 125 +++++++++++++++++- .../guest/src/bin/pda_claimer.rs | 32 +++++ .../guest/src/bin/private_pda_claimer.rs | 34 +++++ 4 files changed, 209 insertions(+), 2 deletions(-) create mode 100644 test_program_methods/guest/src/bin/pda_claimer.rs create mode 100644 test_program_methods/guest/src/bin/private_pda_claimer.rs diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 698032e2..11d1c025 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -292,6 +292,26 @@ mod tests { } } + #[must_use] + pub fn private_pda_claimer() -> Self { + use test_program_methods::{PRIVATE_PDA_CLAIMER_ELF, PRIVATE_PDA_CLAIMER_ID}; + + Self { + id: PRIVATE_PDA_CLAIMER_ID, + elf: PRIVATE_PDA_CLAIMER_ELF.to_vec(), + } + } + + #[must_use] + pub fn pda_claimer() -> Self { + use test_program_methods::{PDA_CLAIMER_ELF, PDA_CLAIMER_ID}; + + Self { + id: PDA_CLAIMER_ID, + elf: PDA_CLAIMER_ELF.to_vec(), + } + } + #[must_use] pub fn changer_claimer() -> Self { use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID}; diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 17abc6d1..b19decd2 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -366,7 +366,10 @@ pub mod tests { Timestamp, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey}, - program::{BlockValidityWindow, PdaSeed, ProgramId, TimestampValidityWindow}, + program::{ + BlockValidityWindow, PdaSeed, ProgramId, TimestampValidityWindow, + private_pda_account_id, + }, }; use crate::{ @@ -2243,8 +2246,11 @@ pub mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } + /// A mask-3 account with no proven `Claim::PrivatePda` or `ChainedCall.private_pda_seeds` + /// attestation must be rejected by the circuit, since there is no binding from which to verify + /// its npk. #[test] - fn circuit_should_fail_with_invalid_visibility_mask_value() { + fn mask_3_without_binding_panics() { let program = Program::simple_balance_transfer(); let public_account_1 = AccountWithMetadata::new( Account { @@ -2272,6 +2278,121 @@ pub mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } + /// Happy path: a program claims a new mask-3 account via `Claim::PrivatePda { seed, npk }`. + /// The circuit derives the `AccountId` via `private_pda_account_id(program_id, seed, npk)` + /// and matches it against the proven claim; the wallet-supplied npk in `private_account_keys` + /// matches the attested npk from the bindings map; a commitment, nullifier and ciphertext are + /// produced. + #[test] + fn mask_3_private_pda_claim_succeeds() { + let program = Program::private_pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + let account_id = private_pda_account_id(&program.id(), &seed, &npk); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((seed, npk)).unwrap(), + vec![3], + vec![(npk, shared_secret)], + vec![], + vec![None], + &program.into(), + ); + + let (output, _proof) = result.expect("mask-3 private PDA claim should succeed"); + assert_eq!(output.new_nullifiers.len(), 1); + assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.ciphertexts.len(), 1); + assert!(output.public_pre_states.is_empty()); + assert!(output.public_post_states.is_empty()); + } + + /// The wallet supplies an npk in `private_account_keys` that differs from the npk attested + /// by the program's `Claim::PrivatePda`. The circuit's mask-3 binding check must reject. + #[test] + fn mask_3_wallet_npk_mismatch_panics() { + let program = Program::private_pda_claimer(); + let attested_keys = test_private_account_keys_1(); + let wallet_keys = test_private_account_keys_2(); + let attested_npk = attested_keys.npk(); + let wallet_npk = wallet_keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret = SharedSecretKey::new(&[55; 32], &wallet_keys.vpk()); + + // The account_id derives from the attested npk (what the program claims). The wallet + // supplies a different npk in private_account_keys, which must fail the binding check. + let account_id = private_pda_account_id(&program.id(), &seed, &attested_npk); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((seed, attested_npk)).unwrap(), + vec![3], + vec![(wallet_npk, shared_secret)], + vec![], + vec![None], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + /// A program must not be allowed to claim a mask-0 (public) account via `Claim::PrivatePda`. + /// The circuit panics in `validate_and_sync_states` when the visibility and claim kind disagree. + #[test] + fn mask_0_cannot_be_claimed_as_private_pda_panics() { + let program = Program::private_pda_claimer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + + // Public account: program_owner = DEFAULT, account_id arbitrary. + let pre_state = + AccountWithMetadata::new(Account::default(), false, AccountId::new([7; 32])); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((seed, npk)).unwrap(), + vec![0], + vec![], + vec![], + vec![], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + /// A program must not be allowed to claim a mask-3 (private PDA) account via `Claim::Pda`. + /// Private PDAs use a distinct derivation and must be claimed with `Claim::PrivatePda`. + #[test] + fn mask_3_cannot_be_claimed_as_public_pda_panics() { + let program = Program::pda_claimer(); + let seed = PdaSeed::new([42; 32]); + + // The account_id does not need to match any private-PDA derivation; the circuit panics on + // the mask-3 / `Claim::Pda` mismatch before any derivation check. + let pre_state = + AccountWithMetadata::new(Account::default(), false, AccountId::new([7; 32])); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![3], + vec![], + vec![], + vec![], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + #[test] fn circuit_should_fail_with_too_many_nonces() { let program = Program::simple_balance_transfer(); diff --git a/test_program_methods/guest/src/bin/pda_claimer.rs b/test_program_methods/guest/src/bin/pda_claimer.rs new file mode 100644 index 00000000..5dec4da4 --- /dev/null +++ b/test_program_methods/guest/src/bin/pda_claimer.rs @@ -0,0 +1,32 @@ +use nssa_core::program::{ + AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +type Instruction = PdaSeed; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: seed, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + let account_post = AccountPostState::new_claimed(pre.account.clone(), Claim::Pda(seed)); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![account_post], + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/private_pda_claimer.rs b/test_program_methods/guest/src/bin/private_pda_claimer.rs new file mode 100644 index 00000000..d63eb030 --- /dev/null +++ b/test_program_methods/guest/src/bin/private_pda_claimer.rs @@ -0,0 +1,34 @@ +use nssa_core::{ + NullifierPublicKey, + program::{AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs}, +}; + +type Instruction = (PdaSeed, NullifierPublicKey); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (seed, npk), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + let account_post = + AccountPostState::new_claimed(pre.account.clone(), Claim::PrivatePda { seed, npk }); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![account_post], + ) + .write(); +}