test: add private PDA circuit tests and two guest programs

This commit is contained in:
Moudy 2026-04-16 18:07:32 +02:00
parent f1b2c04f3d
commit 526c3cd978
4 changed files with 209 additions and 2 deletions

View File

@ -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};

View File

@ -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();

View File

@ -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::<Instruction>();
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();
}

View File

@ -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::<Instruction>();
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();
}