4525 lines
174 KiB
Rust
Raw Normal View History

use std::collections::{BTreeSet, HashMap, HashSet};
2025-11-26 00:27:20 +03:00
use borsh::{BorshDeserialize, BorshSerialize};
use clock_core::ClockAccountData;
pub use clock_core::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS,
};
2025-11-26 00:27:20 +03:00
use nssa_core::{
2026-03-28 03:52:14 -03:00
BlockId, Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, MembershipProof, Nullifier,
Timestamp,
2026-03-19 15:03:45 -03:00
account::{Account, AccountId, Nonce},
2026-03-28 03:52:14 -03:00
program::ProgramId,
2025-11-26 00:27:20 +03:00
};
2025-08-06 20:05:04 -03:00
use crate::{
error::NssaError,
merkle_tree::MerkleTree,
privacy_preserving_transaction::PrivacyPreservingTransaction,
program::Program,
program_deployment_transaction::ProgramDeploymentTransaction,
public_transaction::PublicTransaction,
validated_state_diff::{StateDiff, ValidatedStateDiff},
2025-08-06 20:05:04 -03:00
};
pub const MAX_NUMBER_CHAINED_CALLS: usize = 10;
2026-02-24 19:41:01 +03:00
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
2026-03-09 18:27:56 +03:00
pub struct CommitmentSet {
2025-08-25 07:44:56 -03:00
merkle_tree: MerkleTree,
commitments: HashMap<Commitment, usize>,
2025-08-27 16:24:20 -03:00
root_history: HashSet<CommitmentSetDigest>,
2025-08-25 07:44:56 -03:00
}
impl CommitmentSet {
pub(crate) fn digest(&self) -> CommitmentSetDigest {
2025-08-25 07:44:56 -03:00
self.merkle_tree.root()
}
2026-03-10 00:17:43 +03:00
/// Queries the `CommitmentSet` for a membership proof of commitment.
2025-08-27 16:24:20 -03:00
pub fn get_proof_for(&self, commitment: &Commitment) -> Option<MembershipProof> {
2025-08-25 07:44:56 -03:00
let index = *self.commitments.get(commitment)?;
2025-08-27 16:24:20 -03:00
self.merkle_tree
2025-08-25 07:44:56 -03:00
.get_authentication_path_for(index)
2025-08-27 16:24:20 -03:00
.map(|path| (index, path))
}
2025-08-18 14:28:26 -03:00
/// Inserts a list of commitments to the `CommitmentSet`.
2025-08-22 13:42:37 -03:00
pub(crate) fn extend(&mut self, commitments: &[Commitment]) {
2025-08-25 07:44:56 -03:00
for commitment in commitments.iter().cloned() {
let index = self.merkle_tree.insert(commitment.to_byte_array());
self.commitments.insert(commitment, index);
}
2025-08-25 09:22:59 -03:00
self.root_history.insert(self.digest());
}
2025-08-18 14:28:26 -03:00
fn contains(&self, commitment: &Commitment) -> bool {
2025-08-25 07:44:56 -03:00
self.commitments.contains_key(commitment)
}
/// Initializes an empty `CommitmentSet` with a given capacity.
2026-03-03 23:21:08 +03:00
/// If the capacity is not a `power_of_two`, then capacity is taken
/// to be the next `power_of_two`.
2026-03-09 18:27:56 +03:00
pub(crate) fn with_capacity(capacity: usize) -> Self {
2025-08-25 07:44:56 -03:00
Self {
merkle_tree: MerkleTree::with_capacity(capacity),
commitments: HashMap::new(),
2025-08-25 09:22:59 -03:00
root_history: HashSet::new(),
2025-08-25 07:44:56 -03:00
}
2025-08-18 14:28:26 -03:00
}
}
2025-08-18 07:39:41 -03:00
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
2026-02-24 19:41:01 +03:00
#[derive(Clone)]
struct NullifierSet(BTreeSet<Nullifier>);
2025-08-06 20:05:04 -03:00
impl NullifierSet {
2026-03-09 18:27:56 +03:00
const fn new() -> Self {
Self(BTreeSet::new())
}
fn extend(&mut self, new_nullifiers: &[Nullifier]) {
self.0.extend(new_nullifiers);
}
fn contains(&self, nullifier: &Nullifier) -> bool {
self.0.contains(nullifier)
}
}
impl BorshSerialize for NullifierSet {
fn serialize<W: std::io::Write>(&self, writer: &mut W) -> std::io::Result<()> {
self.0.iter().collect::<Vec<_>>().serialize(writer)
}
}
impl BorshDeserialize for NullifierSet {
fn deserialize_reader<R: std::io::Read>(reader: &mut R) -> std::io::Result<Self> {
let vec = Vec::<Nullifier>::deserialize_reader(reader)?;
let mut set = BTreeSet::new();
for n in vec {
if !set.insert(n) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"duplicate nullifier in NullifierSet",
));
}
}
Ok(Self(set))
}
}
2026-02-24 19:41:01 +03:00
#[derive(Clone, BorshSerialize, BorshDeserialize)]
#[cfg_attr(test, derive(Debug, PartialEq, Eq))]
pub struct V03State {
public_state: HashMap<AccountId, Account>,
2025-08-27 16:24:20 -03:00
private_state: (CommitmentSet, NullifierSet),
2025-10-14 17:15:04 -03:00
programs: HashMap<ProgramId, Program>,
2025-08-06 20:05:04 -03:00
}
impl V03State {
2026-03-03 23:21:08 +03:00
#[must_use]
2025-09-18 15:59:17 +03:00
pub fn new_with_genesis_accounts(
initial_data: &[(AccountId, u128)],
2026-04-07 13:35:13 -03:00
initial_private_accounts: Vec<(Commitment, Nullifier)>,
genesis_timestamp: nssa_core::Timestamp,
2025-09-18 15:59:17 +03:00
) -> Self {
2025-08-10 00:53:53 -03:00
let authenticated_transfer_program = Program::authenticated_transfer_program();
2025-08-07 15:19:06 -03:00
let public_state = initial_data
2025-08-09 19:20:19 -03:00
.iter()
.copied()
.map(|(account_id, balance)| {
2025-08-09 18:40:32 -03:00
let account = Account {
2025-08-09 19:20:19 -03:00
balance,
2025-08-10 00:53:53 -03:00
program_owner: authenticated_transfer_program.id(),
2025-08-09 18:40:32 -03:00
..Account::default()
};
(account_id, account)
2025-08-07 15:19:06 -03:00
})
.collect();
2026-04-07 13:35:13 -03:00
let mut commitment_set = CommitmentSet::with_capacity(32);
commitment_set.extend(&[DUMMY_COMMITMENT]);
let (commitments, nullifiers): (Vec<Commitment>, Vec<Nullifier>) =
initial_private_accounts.into_iter().unzip();
commitment_set.extend(&commitments);
let mut nullifier_set = NullifierSet::new();
nullifier_set.extend(&nullifiers);
2026-04-07 13:35:13 -03:00
let private_state = (commitment_set, nullifier_set);
2025-09-18 15:59:17 +03:00
2025-08-10 09:57:10 -03:00
let mut this = Self {
public_state,
2026-04-07 13:35:13 -03:00
private_state,
2025-10-14 17:15:04 -03:00
programs: HashMap::new(),
2025-08-10 09:57:10 -03:00
};
this.insert_program(Program::clock());
this.insert_clock_accounts(genesis_timestamp);
2025-08-10 09:57:10 -03:00
this.insert_program(Program::authenticated_transfer_program());
2025-09-12 15:18:25 -03:00
this.insert_program(Program::token());
2025-12-17 14:21:36 +02:00
this.insert_program(Program::amm());
this.insert_program(Program::ata());
2025-08-10 09:57:10 -03:00
this
}
fn insert_clock_accounts(&mut self, genesis_timestamp: nssa_core::Timestamp) {
let data = ClockAccountData {
block_id: 0,
timestamp: genesis_timestamp,
}
.to_bytes();
let clock_program_id = Program::clock().id();
for account_id in CLOCK_PROGRAM_ACCOUNT_IDS {
self.public_state.insert(
account_id,
Account {
program_owner: clock_program_id,
data: data
.clone()
.try_into()
.expect("Clock account data should fit within accounts data"),
..Account::default()
},
);
}
}
2025-08-12 21:27:27 -03:00
pub(crate) fn insert_program(&mut self, program: Program) {
2025-10-14 17:15:04 -03:00
self.programs.insert(program.id(), program);
2025-08-07 15:19:06 -03:00
}
pub fn apply_state_diff(&mut self, diff: ValidatedStateDiff) {
let StateDiff {
signer_account_ids,
public_diff,
new_commitments,
new_nullifiers,
program,
} = diff.into_state_diff();
2026-03-04 18:42:33 +03:00
#[expect(
clippy::iter_over_hash_type,
reason = "Iteration order doesn't matter here"
)]
for (account_id, account) in public_diff {
*self.get_account_by_id_mut(account_id) = account;
2025-08-06 20:05:04 -03:00
}
for account_id in signer_account_ids {
self.get_account_by_id_mut(account_id)
.nonce
.public_account_nonce_increment();
}
self.private_state.0.extend(&new_commitments);
self.private_state.1.extend(&new_nullifiers);
if let Some(program) = program {
self.insert_program(program);
2025-08-06 20:05:04 -03:00
}
}
2025-08-09 20:35:44 -03:00
pub fn transition_from_public_transaction(
&mut self,
tx: &PublicTransaction,
block_id: BlockId,
timestamp: Timestamp,
) -> Result<(), NssaError> {
let diff = ValidatedStateDiff::from_public_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
2025-08-06 20:05:04 -03:00
Ok(())
}
pub fn transition_from_privacy_preserving_transaction(
&mut self,
tx: &PrivacyPreservingTransaction,
block_id: BlockId,
2026-03-28 03:47:25 -03:00
timestamp: Timestamp,
) -> Result<(), NssaError> {
let diff =
ValidatedStateDiff::from_privacy_preserving_transaction(tx, self, block_id, timestamp)?;
self.apply_state_diff(diff);
Ok(())
}
pub fn transition_from_program_deployment_transaction(
&mut self,
tx: &ProgramDeploymentTransaction,
) -> Result<(), NssaError> {
let diff = ValidatedStateDiff::from_program_deployment_transaction(tx, self)?;
self.apply_state_diff(diff);
Ok(())
}
fn get_account_by_id_mut(&mut self, account_id: AccountId) -> &mut Account {
self.public_state.entry(account_id).or_default()
2025-08-06 20:05:04 -03:00
}
2026-03-03 23:21:08 +03:00
#[must_use]
pub fn get_account_by_id(&self, account_id: AccountId) -> Account {
2025-08-06 20:05:04 -03:00
self.public_state
.get(&account_id)
2025-08-06 20:05:04 -03:00
.cloned()
2026-03-09 18:27:56 +03:00
.unwrap_or_else(Account::default)
2025-08-06 20:05:04 -03:00
}
2026-03-03 23:21:08 +03:00
#[must_use]
2025-08-27 16:24:20 -03:00
pub fn get_proof_for_commitment(&self, commitment: &Commitment) -> Option<MembershipProof> {
self.private_state.0.get_proof_for(commitment)
}
2026-03-09 18:27:56 +03:00
pub(crate) const fn programs(&self) -> &HashMap<ProgramId, Program> {
2025-10-14 17:15:04 -03:00
&self.programs
2025-08-06 20:05:04 -03:00
}
2026-03-03 23:21:08 +03:00
#[must_use]
pub fn commitment_set_digest(&self) -> CommitmentSetDigest {
self.private_state.0.digest()
}
pub(crate) fn check_commitments_are_new(
&self,
new_commitments: &[Commitment],
) -> Result<(), NssaError> {
2026-03-03 23:21:08 +03:00
for commitment in new_commitments {
2025-08-18 14:28:26 -03:00
if self.private_state.0.contains(commitment) {
return Err(NssaError::InvalidInput(
2026-03-04 18:42:33 +03:00
"Commitment already seen".to_owned(),
2025-08-18 14:28:26 -03:00
));
}
}
Ok(())
}
2025-08-25 09:22:59 -03:00
pub(crate) fn check_nullifiers_are_valid(
&self,
2025-08-25 09:22:59 -03:00
new_nullifiers: &[(Nullifier, CommitmentSetDigest)],
) -> Result<(), NssaError> {
2026-03-03 23:21:08 +03:00
for (nullifier, digest) in new_nullifiers {
2025-08-18 14:28:26 -03:00
if self.private_state.1.contains(nullifier) {
2026-03-04 18:42:33 +03:00
return Err(NssaError::InvalidInput("Nullifier already seen".to_owned()));
2025-08-18 14:28:26 -03:00
}
2025-08-25 09:22:59 -03:00
if !self.private_state.0.root_history.contains(digest) {
return Err(NssaError::InvalidInput(
2026-03-04 18:42:33 +03:00
"Unrecognized commitment set digest".to_owned(),
2025-08-25 09:22:59 -03:00
));
}
2025-08-18 14:28:26 -03:00
}
Ok(())
}
2025-08-06 20:05:04 -03:00
}
2025-08-13 01:33:11 -03:00
2025-09-05 23:45:44 -03:00
// TODO: Testnet only. Refactor to prevent compilation on mainnet.
impl V03State {
pub fn add_pinata_program(&mut self, account_id: AccountId) {
2025-09-04 17:05:12 -03:00
self.insert_program(Program::pinata());
self.public_state.insert(
account_id,
2025-09-04 17:05:12 -03:00
Account {
program_owner: Program::pinata().id(),
2026-03-03 23:21:08 +03:00
balance: 1_500_000,
2025-09-04 17:05:12 -03:00
// Difficulty: 3
data: vec![3; 33].try_into().expect("should fit"),
2026-03-18 10:28:52 -04:00
nonce: Nonce::default(),
2025-09-04 17:05:12 -03:00
},
);
}
2025-11-28 11:10:00 -03:00
pub fn add_pinata_token_program(&mut self, account_id: AccountId) {
self.insert_program(Program::pinata_token());
self.public_state.insert(
account_id,
Account {
program_owner: Program::pinata_token().id(),
// Difficulty: 3
data: vec![3; 33].try_into().expect("should fit"),
2025-11-28 11:10:00 -03:00
..Account::default()
},
);
}
2025-09-04 17:05:12 -03:00
}
2026-03-17 19:16:09 -04:00
#[cfg(any(test, feature = "test-utils"))]
impl V03State {
2026-03-17 19:16:09 -04:00
pub fn force_insert_account(&mut self, account_id: AccountId, account: Account) {
self.public_state.insert(account_id, account);
}
}
2025-08-13 01:33:11 -03:00
#[cfg(test)]
2025-08-22 12:29:45 -03:00
pub mod tests {
2026-03-04 18:42:33 +03:00
#![expect(
clippy::arithmetic_side_effects,
clippy::shadow_unrelated,
reason = "We don't care about it in tests"
)]
2025-08-13 01:33:11 -03:00
use std::collections::HashMap;
2025-11-26 00:27:20 +03:00
use nssa_core::{
BlockId, Commitment, InputAccountIdentity, Nullifier, NullifierPublicKey,
NullifierSecretKey, SharedSecretKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
2026-01-21 17:48:10 -05:00
encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey},
program::{
BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId,
TimestampValidityWindow, WrappedBalanceSum,
},
2025-11-26 00:27:20 +03:00
};
2025-08-13 01:33:11 -03:00
use crate::{
PublicKey, PublicTransaction, V03State,
error::{InvalidProgramBehaviorError, NssaError},
2025-09-02 12:38:31 -03:00
execute_and_prove,
privacy_preserving_transaction::{
2025-11-22 16:39:56 -03:00
PrivacyPreservingTransaction,
circuit::{self, ProgramWithDependencies},
message::Message,
witness_set::WitnessSet,
2025-12-09 14:42:58 -05:00
},
program::Program,
2025-11-22 16:39:56 -03:00
public_transaction,
2025-12-09 14:42:58 -05:00
signature::PrivateKey,
state::{
CLOCK_01_PROGRAM_ACCOUNT_ID, CLOCK_10_PROGRAM_ACCOUNT_ID, CLOCK_50_PROGRAM_ACCOUNT_ID,
CLOCK_PROGRAM_ACCOUNT_IDS, MAX_NUMBER_CHAINED_CALLS,
},
2025-08-13 01:33:11 -03:00
};
2025-08-26 14:53:02 -03:00
impl V03State {
2026-03-10 00:17:43 +03:00
/// Include test programs in the builtin programs map.
2026-03-03 23:21:08 +03:00
#[must_use]
2025-08-13 01:33:11 -03:00
pub fn with_test_programs(mut self) -> Self {
self.insert_program(Program::nonce_changer_program());
self.insert_program(Program::extra_output_program());
self.insert_program(Program::missing_output_program());
self.insert_program(Program::program_owner_changer());
self.insert_program(Program::simple_balance_transfer());
self.insert_program(Program::data_changer());
self.insert_program(Program::minter());
self.insert_program(Program::burner());
2025-10-29 15:34:11 -03:00
self.insert_program(Program::chain_caller());
2025-11-20 21:02:18 -05:00
self.insert_program(Program::amm());
self.insert_program(Program::claimer());
self.insert_program(Program::changer_claimer());
2026-03-19 15:03:45 -03:00
self.insert_program(Program::validity_window());
self.insert_program(Program::flash_swap_initiator());
self.insert_program(Program::flash_swap_callback());
self.insert_program(Program::malicious_self_program_id());
self.insert_program(Program::malicious_caller_program_id());
self.insert_program(Program::time_locked_transfer());
self.insert_program(Program::pinata_cooldown());
2025-08-13 01:33:11 -03:00
self
}
2026-03-03 23:21:08 +03:00
#[must_use]
2025-08-13 01:33:11 -03:00
pub fn with_non_default_accounts_but_default_program_owners(mut self) -> Self {
let account_with_default_values_except_balance = Account {
balance: 100,
..Account::default()
};
let account_with_default_values_except_nonce = Account {
2026-03-18 10:28:52 -04:00
nonce: Nonce(37),
2025-08-13 01:33:11 -03:00
..Account::default()
};
let account_with_default_values_except_data = Account {
data: vec![0xca, 0xfe].try_into().unwrap(),
2025-08-13 01:33:11 -03:00
..Account::default()
};
self.force_insert_account(
AccountId::new([255; 32]),
2025-08-13 01:33:11 -03:00
account_with_default_values_except_balance,
);
self.force_insert_account(
AccountId::new([254; 32]),
2025-08-13 01:33:11 -03:00
account_with_default_values_except_nonce,
);
self.force_insert_account(
AccountId::new([253; 32]),
2025-08-13 01:33:11 -03:00
account_with_default_values_except_data,
);
self
}
2026-03-03 23:21:08 +03:00
#[must_use]
2025-08-13 01:33:11 -03:00
pub fn with_account_owned_by_burner_program(mut self) -> Self {
let account = Account {
program_owner: Program::burner().id(),
balance: 100,
..Default::default()
};
self.force_insert_account(AccountId::new([252; 32]), account);
2025-08-13 01:33:11 -03:00
self
}
2026-03-03 23:21:08 +03:00
#[must_use]
2025-08-22 12:29:45 -03:00
pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self {
let account_id = AccountId::from((&keys.npk(), 0));
let commitment = Commitment::new(&account_id, account);
self.private_state.0.extend(&[commitment]);
2025-08-22 12:29:45 -03:00
self
}
2025-08-13 01:33:11 -03:00
}
2026-03-04 18:42:33 +03:00
pub struct TestPublicKeys {
pub signing_key: PrivateKey,
}
2025-08-13 01:33:11 -03:00
2026-03-04 18:42:33 +03:00
impl TestPublicKeys {
pub fn account_id(&self) -> AccountId {
AccountId::from(&PublicKey::new_from_private_key(&self.signing_key))
}
2025-08-13 01:33:11 -03:00
}
2026-03-04 18:42:33 +03:00
pub struct TestPrivateKeys {
pub nsk: NullifierSecretKey,
pub vsk: Scalar,
}
2025-08-13 01:33:11 -03:00
2026-03-04 18:42:33 +03:00
impl TestPrivateKeys {
pub fn npk(&self) -> NullifierPublicKey {
NullifierPublicKey::from(&self.nsk)
}
2025-08-13 01:33:11 -03:00
2026-03-04 18:42:33 +03:00
pub fn vpk(&self) -> ViewingPublicKey {
ViewingPublicKey::from_scalar(self.vsk)
}
2025-08-13 01:33:11 -03:00
}
// ── Flash Swap types (mirrors of guest types for host-side serialisation) ──
#[derive(serde::Serialize, serde::Deserialize)]
struct CallbackInstruction {
return_funds: bool,
token_program_id: ProgramId,
amount: u128,
}
#[derive(serde::Serialize, serde::Deserialize)]
enum FlashSwapInstruction {
Initiate {
token_program_id: ProgramId,
callback_program_id: ProgramId,
amount_out: u128,
callback_instruction_data: Vec<u32>,
},
InvariantCheck {
min_vault_balance: u128,
},
}
fn transfer_transaction(
from: AccountId,
from_key: &PrivateKey,
from_nonce: u128,
to: AccountId,
to_key: &PrivateKey,
to_nonce: u128,
balance: u128,
) -> PublicTransaction {
let account_ids = vec![from, to];
let nonces = vec![Nonce(from_nonce), Nonce(to_nonce)];
let program_id = Program::authenticated_transfer_program().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, nonces, balance).unwrap();
let witness_set =
public_transaction::WitnessSet::for_message(&message, &[from_key, to_key]);
PublicTransaction::new(message, witness_set)
}
fn build_flash_swap_tx(
initiator: &Program,
vault_id: AccountId,
receiver_id: AccountId,
instruction: FlashSwapInstruction,
) -> PublicTransaction {
let message = public_transaction::Message::try_new(
initiator.id(),
vec![vault_id, receiver_id],
vec![], // no signers — vault is PDA-authorised
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
}
2025-09-02 14:09:50 -03:00
#[test]
2026-03-04 18:42:33 +03:00
fn new_with_genesis() {
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let addr1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let addr2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(addr1, 100_u128), (addr2, 151_u128)];
let authenticated_transfers_program = Program::authenticated_transfer_program();
let clock_program = Program::clock();
2026-03-04 18:42:33 +03:00
let expected_public_state = {
let mut this = HashMap::new();
this.insert(
addr1,
Account {
balance: 100,
program_owner: authenticated_transfers_program.id(),
..Account::default()
},
);
this.insert(
addr2,
Account {
balance: 151,
program_owner: authenticated_transfers_program.id(),
..Account::default()
},
);
for account_id in CLOCK_PROGRAM_ACCOUNT_IDS {
this.insert(
account_id,
Account {
program_owner: clock_program.id(),
data: [0_u8; 16].to_vec().try_into().unwrap(),
..Account::default()
},
);
}
2026-03-04 18:42:33 +03:00
this
};
let expected_builtin_programs = {
let mut this = HashMap::new();
this.insert(
authenticated_transfers_program.id(),
authenticated_transfers_program,
);
this.insert(clock_program.id(), clock_program);
2026-03-04 18:42:33 +03:00
this.insert(Program::token().id(), Program::token());
this.insert(Program::amm().id(), Program::amm());
this.insert(Program::ata().id(), Program::ata());
2026-03-04 18:42:33 +03:00
this
};
2025-09-02 14:09:50 -03:00
let state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0);
2025-09-02 14:09:50 -03:00
2026-03-04 18:42:33 +03:00
assert_eq!(state.public_state, expected_public_state);
assert_eq!(state.programs, expected_builtin_programs);
2025-09-02 14:09:50 -03:00
}
#[test]
fn new_with_genesis_includes_nullifiers_for_private_accounts() {
let keys1 = test_private_account_keys_1();
let keys2 = test_private_account_keys_2();
let account = Account {
balance: 100,
program_owner: Program::authenticated_transfer_program().id(),
..Account::default()
};
let account_id1 = AccountId::from((&keys1.npk(), 0));
let account_id2 = AccountId::from((&keys2.npk(), 0));
let init_commitment1 = Commitment::new(&account_id1, &account);
let init_commitment2 = Commitment::new(&account_id2, &account);
let init_nullifier1 = Nullifier::for_account_initialization(&account_id1);
let init_nullifier2 = Nullifier::for_account_initialization(&account_id2);
let initial_private_accounts = vec![
(init_commitment1, init_nullifier1),
(init_commitment2, init_nullifier2),
];
let state = V03State::new_with_genesis_accounts(&[], initial_private_accounts, 0);
assert!(state.private_state.1.contains(&init_nullifier1));
assert!(state.private_state.1.contains(&init_nullifier2));
}
2025-09-02 14:09:50 -03:00
#[test]
2026-03-04 18:42:33 +03:00
fn insert_program() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
2026-03-04 18:42:33 +03:00
let program_to_insert = Program::simple_balance_transfer();
let program_id = program_to_insert.id();
assert!(!state.programs.contains_key(&program_id));
2025-09-02 14:09:50 -03:00
2026-03-04 18:42:33 +03:00
state.insert_program(program_to_insert);
2025-09-02 14:09:50 -03:00
2026-03-04 18:42:33 +03:00
assert!(state.programs.contains_key(&program_id));
2025-09-02 14:09:50 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn get_account_by_account_id_non_default_account() {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(account_id, 100_u128)];
let state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0);
2026-03-04 18:42:33 +03:00
let expected_account = &state.public_state[&account_id];
2025-09-02 14:09:50 -03:00
2026-03-04 18:42:33 +03:00
let account = state.get_account_by_id(account_id);
2025-09-02 12:38:31 -03:00
2026-03-04 18:42:33 +03:00
assert_eq!(&account, expected_account);
2025-09-02 12:38:31 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn get_account_by_account_id_default_account() {
let addr2 = AccountId::new([0; 32]);
let state = V03State::new_with_genesis_accounts(&[], vec![], 0);
2026-03-04 18:42:33 +03:00
let expected_account = Account::default();
2026-03-04 18:42:33 +03:00
let account = state.get_account_by_id(addr2);
2026-03-04 18:42:33 +03:00
assert_eq!(account, expected_account);
}
2025-09-03 16:25:02 -03:00
#[test]
2026-03-04 18:42:33 +03:00
fn builtin_programs_getter() {
let state = V03State::new_with_genesis_accounts(&[], vec![], 0);
2025-09-03 16:25:02 -03:00
2026-03-04 18:42:33 +03:00
let builtin_programs = state.programs();
2025-09-03 16:25:02 -03:00
2026-03-04 18:42:33 +03:00
assert_eq!(builtin_programs, &state.programs);
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn transition_from_authenticated_transfer_program_invocation_default_account_destination() {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(account_id, 100)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0);
2026-03-04 18:42:33 +03:00
let from = account_id;
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
2026-03-04 18:42:33 +03:00
assert_eq!(state.get_account_by_id(to), Account::default());
let balance_to_move = 5;
2025-09-03 16:25:02 -03:00
let tx = transfer_transaction(from, &key, 0, to, &to_key, 0, balance_to_move);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2026-03-04 18:42:33 +03:00
assert_eq!(state.get_account_by_id(from).balance, 95);
assert_eq!(state.get_account_by_id(to).balance, 5);
2026-03-18 10:28:52 -04:00
assert_eq!(state.get_account_by_id(from).nonce, Nonce(1));
assert_eq!(state.get_account_by_id(to).nonce, Nonce(1));
}
#[test]
2026-03-04 18:42:33 +03:00
fn transition_from_authenticated_transfer_program_invocation_insuficient_balance() {
let key = PrivateKey::try_new([1; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&key));
let initial_data = [(account_id, 100)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0);
2026-03-04 18:42:33 +03:00
let from = account_id;
let from_key = key;
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
2026-03-04 18:42:33 +03:00
let balance_to_move = 101;
assert!(state.get_account_by_id(from).balance < balance_to_move);
let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move);
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_))));
assert_eq!(state.get_account_by_id(from).balance, 100);
assert_eq!(state.get_account_by_id(to).balance, 0);
2026-03-18 10:28:52 -04:00
assert_eq!(state.get_account_by_id(from).nonce, Nonce(0));
assert_eq!(state.get_account_by_id(to).nonce, Nonce(0));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn transition_from_authenticated_transfer_program_invocation_non_default_account_destination() {
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(account_id1, 100), (account_id2, 200)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0);
2026-03-04 18:42:33 +03:00
let from = account_id2;
let from_key = key2;
let to = account_id1;
let to_key = key1;
2026-03-04 18:42:33 +03:00
assert_ne!(state.get_account_by_id(to), Account::default());
let balance_to_move = 8;
2025-09-03 16:25:02 -03:00
let tx = transfer_transaction(from, &from_key, 0, to, &to_key, 0, balance_to_move);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2025-09-03 16:25:02 -03:00
2026-03-04 18:42:33 +03:00
assert_eq!(state.get_account_by_id(from).balance, 192);
assert_eq!(state.get_account_by_id(to).balance, 108);
2026-03-18 10:28:52 -04:00
assert_eq!(state.get_account_by_id(from).nonce, Nonce(1));
assert_eq!(state.get_account_by_id(to).nonce, Nonce(1));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn transition_from_sequence_of_authenticated_transfer_program_invocations() {
let key1 = PrivateKey::try_new([8; 32]).unwrap();
let account_id1 = AccountId::from(&PublicKey::new_from_private_key(&key1));
let key2 = PrivateKey::try_new([2; 32]).unwrap();
let account_id2 = AccountId::from(&PublicKey::new_from_private_key(&key2));
let initial_data = [(account_id1, 100)];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0);
let key3 = PrivateKey::try_new([3; 32]).unwrap();
let account_id3 = AccountId::from(&PublicKey::new_from_private_key(&key3));
2026-03-04 18:42:33 +03:00
let balance_to_move = 5;
2025-11-14 01:28:34 -03:00
let tx = transfer_transaction(
account_id1,
&key1,
0,
account_id2,
&key2,
0,
balance_to_move,
);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2026-03-04 18:42:33 +03:00
let balance_to_move = 3;
let tx = transfer_transaction(
account_id2,
&key2,
1,
account_id3,
&key3,
0,
balance_to_move,
);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2025-09-03 16:25:02 -03:00
2026-03-04 18:42:33 +03:00
assert_eq!(state.get_account_by_id(account_id1).balance, 95);
assert_eq!(state.get_account_by_id(account_id2).balance, 2);
assert_eq!(state.get_account_by_id(account_id3).balance, 3);
2026-03-18 10:28:52 -04:00
assert_eq!(state.get_account_by_id(account_id1).nonce, Nonce(1));
assert_eq!(state.get_account_by_id(account_id2).nonce, Nonce(2));
assert_eq!(state.get_account_by_id(account_id3).nonce, Nonce(1));
2025-09-03 16:25:02 -03:00
}
fn clock_transaction(timestamp: nssa_core::Timestamp) -> PublicTransaction {
let message = public_transaction::Message::try_new(
Program::clock().id(),
CLOCK_PROGRAM_ACCOUNT_IDS.to_vec(),
vec![],
timestamp,
)
.unwrap();
PublicTransaction::new(
message,
public_transaction::WitnessSet::from_raw_parts(vec![]),
)
}
fn clock_account_data(state: &V03State, account_id: AccountId) -> (u64, nssa_core::Timestamp) {
let data = state.get_account_by_id(account_id).data.into_inner();
let parsed = clock_core::ClockAccountData::from_bytes(&data);
(parsed.block_id, parsed.timestamp)
}
#[test]
fn clock_genesis_state_has_zero_block_id_and_genesis_timestamp() {
let genesis_timestamp = 1_000_000_u64;
let state = V03State::new_with_genesis_accounts(&[], vec![], genesis_timestamp);
let (block_id, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id, 0);
assert_eq!(timestamp, genesis_timestamp);
}
#[test]
fn clock_invocation_increments_block_id() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
let tx = clock_transaction(1234);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (block_id, _) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id, 1);
}
#[test]
fn clock_invocation_stores_timestamp_from_instruction() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
let block_timestamp = 1_700_000_000_000_u64;
let tx = clock_transaction(block_timestamp);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (_, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(timestamp, block_timestamp);
}
#[test]
fn clock_invocation_sequence_correctly_increments_block_id() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
for expected_block_id in 1_u64..=5 {
let tx = clock_transaction(expected_block_id * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (block_id, timestamp) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id, expected_block_id);
assert_eq!(timestamp, expected_block_id * 1000);
}
}
#[test]
fn clock_10_account_not_updated_when_block_id_not_multiple_of_10() {
let genesis_timestamp = 0_u64;
let mut state = V03State::new_with_genesis_accounts(&[], vec![], genesis_timestamp);
// Run 9 clock ticks (block_ids 1..=9), none of which are multiples of 10.
for tick in 1_u64..=9 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_10, timestamp_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID);
// The 10-block account should still reflect genesis state.
assert_eq!(block_id_10, 0);
assert_eq!(timestamp_10, genesis_timestamp);
}
#[test]
fn clock_10_account_updated_when_block_id_is_multiple_of_10() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// Run 10 clock ticks so block_id reaches 10.
for tick in 1_u64..=10 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_1, timestamp_1) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
let (block_id_10, timestamp_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_1, 10);
assert_eq!(block_id_10, 10);
assert_eq!(timestamp_10, timestamp_1);
}
#[test]
fn clock_50_account_only_updated_at_multiples_of_50() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// After 49 ticks the 50-block account should be unchanged.
for tick in 1_u64..=49 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_50, _) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_50, 0);
// Tick 50 — now the 50-block account should update.
let tx = clock_transaction(50 * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
let (block_id_50, timestamp_50) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_50, 50);
assert_eq!(timestamp_50, 50 * 1000);
}
#[test]
fn all_three_clock_accounts_updated_at_multiple_of_50() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// Advance to block 50 (a multiple of both 10 and 50).
for tick in 1_u64..=50 {
let tx = clock_transaction(tick * 1000);
state.transition_from_public_transaction(&tx, 0, 0).unwrap();
}
let (block_id_1, ts_1) = clock_account_data(&state, CLOCK_01_PROGRAM_ACCOUNT_ID);
let (block_id_10, ts_10) = clock_account_data(&state, CLOCK_10_PROGRAM_ACCOUNT_ID);
let (block_id_50, ts_50) = clock_account_data(&state, CLOCK_50_PROGRAM_ACCOUNT_ID);
assert_eq!(block_id_1, 50);
assert_eq!(block_id_10, 50);
assert_eq!(block_id_50, 50);
assert_eq!(ts_1, ts_10);
assert_eq!(ts_1, ts_50);
}
2025-09-03 16:25:02 -03:00
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_modifies_nonces() {
let account_id = AccountId::new([1; 32]);
let initial_data = [(account_id, 100)];
2026-03-04 18:42:33 +03:00
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let account_ids = vec![account_id];
2026-03-04 18:42:33 +03:00
let program_id = Program::nonce_changer_program().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:25:02 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedNonce { account_id: err_account_id }
)
)) if err_account_id == account_id
));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_output_accounts_exceed_inputs() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let account_ids = vec![AccountId::new([1; 32])];
let program_id = Program::extra_output_program().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:25:02 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length,
post_state_length
}
)
)) if pre_state_length == 1 && post_state_length == 2
));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_with_missing_output_accounts() {
let initial_data = [(AccountId::new([1; 32]), 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let account_ids = vec![AccountId::new([1; 32]), AccountId::new([2; 32])];
let program_id = Program::missing_output_program().id();
let message =
public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:25:02 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedPreStatePostStateLength {
pre_state_length,
post_state_length
}
)
)) if pre_state_length == 2 && post_state_length == 1
));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_modifies_program_owner_with_only_non_default_program_owner() {
let initial_data = [(AccountId::new([1; 32]), 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let account_id = AccountId::new([1; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in the program owner
// field
assert_ne!(account.program_owner, Account::default().program_owner);
assert_eq!(account.balance, Account::default().balance);
assert_eq!(account.nonce, Account::default().nonce);
assert_eq!(account.data, Account::default().data);
let program_id = Program::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:25:02 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_modifies_program_owner_with_only_non_default_balance() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0)
2026-03-04 18:42:33 +03:00
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([255; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in balance field
assert_eq!(account.program_owner, Account::default().program_owner);
assert_ne!(account.balance, Account::default().balance);
assert_eq!(account.nonce, Account::default().nonce);
assert_eq!(account.data, Account::default().data);
let program_id = Program::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:25:02 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
2025-09-03 16:25:02 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_modifies_program_owner_with_only_non_default_nonce() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0)
2026-03-04 18:42:33 +03:00
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([254; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in nonce field
assert_eq!(account.program_owner, Account::default().program_owner);
assert_eq!(account.balance, Account::default().balance);
assert_ne!(account.nonce, Account::default().nonce);
assert_eq!(account.data, Account::default().data);
let program_id = Program::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:25:02 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:25:02 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
2025-09-03 16:25:02 -03:00
}
2025-09-03 16:44:55 -03:00
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_modifies_program_owner_with_only_non_default_data() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0)
2026-03-04 18:42:33 +03:00
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([253; 32]);
let account = state.get_account_by_id(account_id);
// Assert the target account only differs from the default account in data field
assert_eq!(account.program_owner, Account::default().program_owner);
assert_eq!(account.balance, Account::default().balance);
assert_eq!(account.nonce, Account::default().nonce);
assert_ne!(account.data, Account::default().data);
let program_id = Program::program_owner_changer().id();
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:44:55 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:44:55 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id }
))) if err_account_id == account_id
));
2025-09-03 16:44:55 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_transfers_balance_from_non_owned_account() {
let sender_account_id = AccountId::new([1; 32]);
let receiver_account_id = AccountId::new([2; 32]);
let initial_data = [(sender_account_id, 100)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let balance_to_move: u128 = 1;
let program_id = Program::simple_balance_transfer().id();
assert_ne!(
state.get_account_by_id(sender_account_id).program_owner,
program_id
);
2026-03-04 18:42:33 +03:00
let message = public_transaction::Message::try_new(
program_id,
vec![sender_account_id, receiver_account_id],
vec![],
balance_to_move,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:44:55 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:44:55 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id }
))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id
));
2025-09-03 16:44:55 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_modifies_data_of_non_owned_account() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0)
2026-03-04 18:42:33 +03:00
.with_test_programs()
.with_non_default_accounts_but_default_program_owners();
let account_id = AccountId::new([255; 32]);
let program_id = Program::data_changer().id();
2025-09-03 16:44:55 -03:00
2026-03-04 18:42:33 +03:00
assert_ne!(state.get_account_by_id(account_id), Account::default());
assert_ne!(
state.get_account_by_id(account_id).program_owner,
program_id
);
2026-03-04 18:42:33 +03:00
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], vec![0])
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
2025-09-03 16:44:55 -03:00
let result = state.transition_from_public_transaction(&tx, 1, 0);
2025-09-03 16:44:55 -03:00
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id }
))) if err_account_id == account_id && executing_program_id == program_id
));
2025-09-03 16:44:55 -03:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn program_should_fail_if_does_not_preserve_total_balance_by_minting() {
let initial_data = [];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let account_id = AccountId::new([1; 32]);
let program_id = Program::minter().id();
2026-03-04 18:42:33 +03:00
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into()
));
2026-03-04 18:42:33 +03:00
}
#[test]
fn program_should_fail_if_does_not_preserve_total_balance_by_burning() {
let initial_data = [];
let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0)
2026-03-04 18:42:33 +03:00
.with_test_programs()
.with_account_owned_by_burner_program();
let program_id = Program::burner().id();
let account_id = AccountId::new([252; 32]);
assert_eq!(
state.get_account_by_id(account_id).program_owner,
program_id
);
2026-03-04 18:42:33 +03:00
let balance_to_burn: u128 = 1;
assert!(state.get_account_by_id(account_id).balance > balance_to_burn);
2026-03-04 18:42:33 +03:00
let message = public_transaction::Message::try_new(
program_id,
vec![account_id],
vec![],
balance_to_burn,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into()
));
2026-03-04 18:42:33 +03:00
}
2026-03-04 18:42:33 +03:00
fn test_public_account_keys_1() -> TestPublicKeys {
TestPublicKeys {
signing_key: PrivateKey::try_new([37; 32]).unwrap(),
}
}
2026-05-07 00:54:01 -03:00
fn test_public_account_keys_2() -> TestPublicKeys {
TestPublicKeys {
signing_key: PrivateKey::try_new([38; 32]).unwrap(),
}
}
2026-03-04 18:42:33 +03:00
pub fn test_private_account_keys_1() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [13; 32],
vsk: [31; 32],
}
}
2026-03-04 18:42:33 +03:00
pub fn test_private_account_keys_2() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [38; 32],
vsk: [83; 32],
}
}
2026-03-04 18:42:33 +03:00
fn shielded_balance_transfer_for_tests(
sender_keys: &TestPublicKeys,
recipient_keys: &TestPrivateKeys,
balance_to_move: u128,
state: &V03State,
2026-03-04 18:42:33 +03:00
) -> PrivacyPreservingTransaction {
let sender = AccountWithMetadata::new(
state.get_account_by_id(sender_keys.account_id()),
2025-10-03 18:31:56 -03:00
true,
2026-03-04 18:42:33 +03:00
sender_keys.account_id(),
2025-10-03 18:31:56 -03:00
);
2026-03-04 18:42:33 +03:00
let sender_nonce = sender.account.nonce;
2025-10-03 18:31:56 -03:00
2026-04-19 23:13:51 -03:00
let recipient =
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
2025-10-29 15:34:11 -03:00
2026-03-04 18:42:33 +03:00
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &recipient_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
2026-03-04 18:42:33 +03:00
let (output, proof) = circuit::execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: shared_secret,
identifier: 0,
},
],
2026-03-04 18:42:33 +03:00
&Program::authenticated_transfer_program().into(),
)
.unwrap();
2026-03-04 18:42:33 +03:00
let message = Message::try_from_circuit_output(
vec![sender_keys.account_id()],
vec![sender_nonce],
vec![(recipient_keys.npk(), recipient_keys.vpk(), epk)],
output,
)
.unwrap();
2026-03-04 18:42:33 +03:00
let witness_set = WitnessSet::for_message(&message, proof, &[&sender_keys.signing_key]);
PrivacyPreservingTransaction::new(message, witness_set)
2025-10-03 18:31:56 -03:00
}
2025-10-29 15:34:11 -03:00
2026-03-04 18:42:33 +03:00
fn private_balance_transfer_for_tests(
sender_keys: &TestPrivateKeys,
sender_private_account: &Account,
recipient_keys: &TestPrivateKeys,
balance_to_move: u128,
state: &V03State,
2026-03-04 18:42:33 +03:00
) -> PrivacyPreservingTransaction {
let program = Program::authenticated_transfer_program();
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_commitment = Commitment::new(&sender_account_id, sender_private_account);
2026-04-19 23:13:51 -03:00
let sender_pre = AccountWithMetadata::new(
sender_private_account.clone(),
true,
(&sender_keys.npk(), 0),
);
2026-03-04 18:42:33 +03:00
let recipient_pre =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
2026-03-04 18:42:33 +03:00
let esk_1 = [3; 32];
let shared_secret_1 = SharedSecretKey::new(&esk_1, &sender_keys.vpk());
let epk_1 = EphemeralPublicKey::from_scalar(esk_1);
2026-03-04 18:42:33 +03:00
let esk_2 = [3; 32];
let shared_secret_2 = SharedSecretKey::new(&esk_2, &recipient_keys.vpk());
let epk_2 = EphemeralPublicKey::from_scalar(esk_2);
2026-03-04 18:42:33 +03:00
let (output, proof) = circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: shared_secret_1,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: shared_secret_2,
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
2026-02-16 19:53:32 -05:00
)
.unwrap();
2026-03-04 18:42:33 +03:00
let message = Message::try_from_circuit_output(
vec![],
vec![],
vec![
(sender_keys.npk(), sender_keys.vpk(), epk_1),
(recipient_keys.npk(), recipient_keys.vpk(), epk_2),
],
output,
2025-10-29 15:34:11 -03:00
)
.unwrap();
2026-03-04 18:42:33 +03:00
let witness_set = WitnessSet::for_message(&message, proof, &[]);
2026-03-04 18:42:33 +03:00
PrivacyPreservingTransaction::new(message, witness_set)
}
2026-03-04 18:42:33 +03:00
fn deshielded_balance_transfer_for_tests(
sender_keys: &TestPrivateKeys,
sender_private_account: &Account,
recipient_account_id: &AccountId,
balance_to_move: u128,
state: &V03State,
2026-03-04 18:42:33 +03:00
) -> PrivacyPreservingTransaction {
let program = Program::authenticated_transfer_program();
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_commitment = Commitment::new(&sender_account_id, sender_private_account);
2026-04-19 23:13:51 -03:00
let sender_pre = AccountWithMetadata::new(
sender_private_account.clone(),
true,
(&sender_keys.npk(), 0),
);
2026-03-04 18:42:33 +03:00
let recipient_pre = AccountWithMetadata::new(
state.get_account_by_id(*recipient_account_id),
false,
*recipient_account_id,
);
2026-03-04 18:42:33 +03:00
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &sender_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (output, proof) = circuit::execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(balance_to_move).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: shared_secret,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
2026-03-04 18:42:33 +03:00
&program.into(),
2025-10-29 15:34:11 -03:00
)
.unwrap();
2026-03-04 18:42:33 +03:00
let message = Message::try_from_circuit_output(
vec![*recipient_account_id],
vec![],
vec![(sender_keys.npk(), sender_keys.vpk(), epk)],
output,
)
.unwrap();
2026-03-04 18:42:33 +03:00
let witness_set = WitnessSet::for_message(&message, proof, &[]);
2025-12-06 14:52:18 -05:00
2026-03-04 18:42:33 +03:00
PrivacyPreservingTransaction::new(message, witness_set)
}
2025-11-20 21:02:18 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn transition_from_privacy_preserving_transaction_shielded() {
let sender_keys = test_public_account_keys_1();
let recipient_keys = test_private_account_keys_1();
2026-03-04 18:42:33 +03:00
let mut state =
V03State::new_with_genesis_accounts(&[(sender_keys.account_id(), 200)], vec![], 0);
2026-03-04 18:42:33 +03:00
let balance_to_move = 37;
2025-12-06 14:52:18 -05:00
2026-03-04 18:42:33 +03:00
let tx = shielded_balance_transfer_for_tests(
&sender_keys,
&recipient_keys,
balance_to_move,
&state,
);
2025-12-06 14:52:18 -05:00
2026-03-04 18:42:33 +03:00
let expected_sender_post = {
let mut this = state.get_account_by_id(sender_keys.account_id());
this.balance -= balance_to_move;
2026-03-18 10:28:52 -04:00
this.nonce.public_account_nonce_increment();
2026-03-04 18:42:33 +03:00
this
};
2025-12-06 14:52:18 -05:00
2026-03-04 18:42:33 +03:00
let [expected_new_commitment] = tx.message().new_commitments.clone().try_into().unwrap();
assert!(!state.private_state.0.contains(&expected_new_commitment));
2025-12-06 14:52:18 -05:00
2026-03-04 18:42:33 +03:00
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
2026-03-04 18:42:33 +03:00
.unwrap();
2025-11-24 19:44:08 -05:00
2026-03-04 18:42:33 +03:00
let sender_post = state.get_account_by_id(sender_keys.account_id());
assert_eq!(sender_post, expected_sender_post);
assert!(state.private_state.0.contains(&expected_new_commitment));
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert_eq!(
state.get_account_by_id(sender_keys.account_id()).balance,
200 - balance_to_move
);
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn transition_from_privacy_preserving_transaction_private() {
let sender_keys = test_private_account_keys_1();
2026-03-18 10:28:52 -04:00
let sender_nonce = Nonce(0xdead_beef);
2026-03-04 18:42:33 +03:00
let sender_private_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
2026-03-18 10:28:52 -04:00
nonce: sender_nonce,
2026-03-04 18:42:33 +03:00
data: Data::default(),
};
let recipient_keys = test_private_account_keys_2();
2025-12-18 18:45:57 -05:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0)
2026-03-04 18:42:33 +03:00
.with_private_account(&sender_keys, &sender_private_account);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let balance_to_move = 37;
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let tx = private_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys,
balance_to_move,
&state,
);
2025-12-18 18:45:57 -05:00
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0));
2026-03-04 18:42:33 +03:00
let expected_new_commitment_1 = Commitment::new(
&sender_account_id,
2026-03-04 18:42:33 +03:00
&Account {
program_owner: Program::authenticated_transfer_program().id(),
2026-03-18 10:28:52 -04:00
nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk),
2026-03-04 18:42:33 +03:00
balance: sender_private_account.balance - balance_to_move,
data: Data::default(),
},
);
2025-12-18 18:45:57 -05:00
let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account);
2026-03-04 18:42:33 +03:00
let expected_new_nullifier =
Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let expected_new_commitment_2 = Commitment::new(
&recipient_account_id,
2026-03-04 18:42:33 +03:00
&Account {
program_owner: Program::authenticated_transfer_program().id(),
nonce: Nonce::private_account_nonce_init(&recipient_account_id),
2026-03-04 18:42:33 +03:00
balance: balance_to_move,
..Account::default()
},
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let previous_public_state = state.public_state.clone();
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(!state.private_state.0.contains(&expected_new_commitment_1));
assert!(!state.private_state.0.contains(&expected_new_commitment_2));
assert!(!state.private_state.1.contains(&expected_new_nullifier));
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
2026-03-04 18:42:33 +03:00
.unwrap();
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert_eq!(state.public_state, previous_public_state);
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(state.private_state.0.contains(&expected_new_commitment_1));
assert!(state.private_state.0.contains(&expected_new_commitment_2));
assert!(state.private_state.1.contains(&expected_new_nullifier));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn transition_from_privacy_preserving_transaction_deshielded() {
let sender_keys = test_private_account_keys_1();
2026-03-18 10:28:52 -04:00
let sender_nonce = Nonce(0xdead_beef);
2026-03-04 18:42:33 +03:00
let sender_private_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
2026-03-18 10:28:52 -04:00
nonce: sender_nonce,
2026-03-04 18:42:33 +03:00
data: Data::default(),
};
let recipient_keys = test_public_account_keys_1();
let recipient_initial_balance = 400;
let mut state = V03State::new_with_genesis_accounts(
2026-03-04 18:42:33 +03:00
&[(recipient_keys.account_id(), recipient_initial_balance)],
2026-04-07 13:35:13 -03:00
vec![],
0,
2026-03-04 18:42:33 +03:00
)
.with_private_account(&sender_keys, &sender_private_account);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let balance_to_move = 37;
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let expected_recipient_post = {
let mut this = state.get_account_by_id(recipient_keys.account_id());
this.balance += balance_to_move;
this
};
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let tx = deshielded_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys.account_id(),
balance_to_move,
&state,
);
2025-12-18 18:45:57 -05:00
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
2026-03-04 18:42:33 +03:00
let expected_new_commitment = Commitment::new(
&sender_account_id,
2026-03-04 18:42:33 +03:00
&Account {
program_owner: Program::authenticated_transfer_program().id(),
2026-03-18 10:28:52 -04:00
nonce: sender_nonce.private_account_nonce_increment(&sender_keys.nsk),
2026-03-04 18:42:33 +03:00
balance: sender_private_account.balance - balance_to_move,
data: Data::default(),
},
);
2025-12-18 18:45:57 -05:00
let sender_pre_commitment = Commitment::new(&sender_account_id, &sender_private_account);
2026-03-04 18:42:33 +03:00
let expected_new_nullifier =
Nullifier::for_account_update(&sender_pre_commitment, &sender_keys.nsk);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(!state.private_state.0.contains(&expected_new_commitment));
assert!(!state.private_state.1.contains(&expected_new_nullifier));
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
2026-03-04 18:42:33 +03:00
.unwrap();
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let recipient_post = state.get_account_by_id(recipient_keys.account_id());
assert_eq!(recipient_post, expected_recipient_post);
assert!(state.private_state.0.contains(&sender_pre_commitment));
assert!(state.private_state.0.contains(&expected_new_commitment));
assert!(state.private_state.1.contains(&expected_new_nullifier));
assert_eq!(
state.get_account_by_id(recipient_keys.account_id()).balance,
recipient_initial_balance + balance_to_move
);
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn burner_program_should_fail_in_privacy_preserving_circuit() {
let program = Program::burner();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn minter_program_should_fail_in_privacy_preserving_circuit() {
let program = Program::minter();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn nonce_changer_program_should_fail_in_privacy_preserving_circuit() {
let program = Program::nonce_changer_program();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn data_changer_program_should_fail_for_non_owned_account_in_privacy_preserving_circuit() {
let program = Program::data_changer();
let public_account = AccountWithMetadata::new(
Account {
program_owner: [0, 1, 2, 3, 4, 5, 6, 7],
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(vec![0]).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn data_changer_program_should_fail_for_too_large_data_in_privacy_preserving_circuit() {
let program = Program::data_changer();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let large_data: Vec<u8> =
vec![
0;
usize::try_from(nssa_core::account::data::DATA_MAX_LENGTH.as_u64())
.expect("DATA_MAX_LENGTH fits in usize")
+ 1
];
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(large_data).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-09 18:27:56 +03:00
&program.into(),
2026-03-04 18:42:33 +03:00
);
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::ProgramProveFailed(_))));
}
2026-03-04 18:42:33 +03:00
#[test]
fn extra_output_program_should_fail_in_privacy_preserving_circuit() {
let program = Program::extra_output_program();
let public_account = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-24 03:39:39 +03:00
2026-03-04 18:42:33 +03:00
#[test]
fn missing_output_program_should_fail_in_privacy_preserving_circuit() {
let program = Program::missing_output_program();
let public_account_1 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let public_account_2 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([1; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account_1, public_account_2],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
2025-12-06 14:52:18 -05:00
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn program_owner_changer_should_fail_in_privacy_preserving_circuit() {
let program = Program::program_owner_changer();
let public_account = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
2025-12-06 14:52:18 -05:00
}
2026-03-04 18:42:33 +03:00
#[test]
fn transfer_from_non_owned_account_should_fail_in_privacy_preserving_circuit() {
let program = Program::simple_balance_transfer();
let public_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: [0, 1, 2, 3, 4, 5, 6, 7],
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let public_account_2 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([1; 32]),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account_1, public_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public, InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_fails_if_visibility_masks_have_incorrect_lenght() {
let program = Program::simple_balance_transfer();
let public_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let public_account_2 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 0,
..Account::default()
},
true,
AccountId::new([1; 32]),
);
2025-12-18 18:45:57 -05:00
// Single account_identity entry for a circuit execution with two pre_state accounts.
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account_1, public_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![InputAccountIdentity::Public],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_fails_if_invalid_auth_keys_are_provided() {
2026-03-04 18:42:33 +03:00
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
let private_account_2 =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), false, (&recipient_keys.npk(), 0));
2025-12-18 18:45:57 -05:00
// Setting the recipient nsk to authorize the sender.
// This should be set to the sender private account in a normal circumstance.
// `PrivateAuthorizedUpdate` derives npk from nsk and asserts equality with
// `pre_state.account_id`, so a mismatched nsk fails that check.
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![private_account_1, private_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[55; 32], &sender_keys.vpk()),
nsk: recipient_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_should_fail_if_new_private_account_with_non_default_balance_is_provided() {
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
let private_account_2 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
// Non default balance
balance: 1,
..Account::default()
},
false,
2026-04-14 18:02:38 -03:00
(&recipient_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![private_account_1, private_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[55; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_should_fail_if_new_private_account_with_non_default_program_owner_is_provided() {
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
let private_account_2 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
// Non default program_owner
program_owner: [0, 1, 2, 3, 4, 5, 6, 7],
..Account::default()
},
false,
2026-04-14 18:02:38 -03:00
(&recipient_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![private_account_1, private_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[55; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_should_fail_if_new_private_account_with_non_default_data_is_provided() {
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
let private_account_2 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
// Non default data
data: b"hola mundo".to_vec().try_into().unwrap(),
..Account::default()
},
false,
2026-04-14 18:02:38 -03:00
(&recipient_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![private_account_1, private_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[55; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_should_fail_if_new_private_account_with_non_default_nonce_is_provided() {
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
let private_account_2 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
// Non default nonce
2026-03-18 10:28:52 -04:00
nonce: Nonce(0xdead_beef),
2026-03-04 18:42:33 +03:00
..Account::default()
},
false,
2026-04-14 18:02:38 -03:00
(&recipient_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![private_account_1, private_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[55; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
#[test]
fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_but_marked_as_authorized()
{
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let recipient_keys = test_private_account_keys_2();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
let private_account_2 = AccountWithMetadata::new(
Account::default(),
// This should be set to false in normal circumstances
true,
2026-04-14 18:02:38 -03:00
(&recipient_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![private_account_1, private_account_2],
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[55; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateUnauthorized {
npk: recipient_keys.npk(),
ssk: SharedSecretKey::new(&[56; 32], &recipient_keys.vpk()),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// A mask-3 account that no program claims via `Claim::Pda` and no caller authorizes via
/// `ChainedCall.pda_seeds` has no binding between its supplied npk and its `account_id`,
/// so the circuit must reject. Here `simple_balance_transfer` emits no claim for the
/// second account, leaving position 1 unbound.
2026-03-04 18:42:33 +03:00
#[test]
fn private_pda_without_binding_fails() {
2026-03-04 18:42:33 +03:00
let program = Program::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
2026-03-04 18:42:33 +03:00
let public_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let private_pda_account =
2026-03-04 18:42:33 +03:00
AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32]));
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let result = execute_and_prove(
vec![public_account_1, private_pda_account],
2026-03-04 18:42:33 +03:00
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
},
],
2026-03-04 18:42:33 +03:00
&program.into(),
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Happy path: a program claims a new mask-3 account via `Claim::Pda(seed)`. The circuit
/// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s
/// position, derives `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`, and
/// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim
/// and binds the supplied npk to the `account_id`.
#[test]
fn private_pda_claim_succeeds() {
let program = Program::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());
2026-05-01 00:05:50 -03:00
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&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());
}
/// An npk is supplied that does not match the `pre_state`'s `account_id` under
/// `AccountId::for_private_pda(program, claim_seed, npk)`. The claim equality check rejects.
#[test]
fn private_pda_npk_mismatch_fails() {
// `keys_a` produces the `pre_state`'s `account_id` (the registered pair), `keys_b` is
// the mismatched pair supplied in `private_account_keys` for that pre_state.
let program = Program::pda_claimer();
let keys_a = test_private_account_keys_1();
let keys_b = test_private_account_keys_2();
let npk_a = keys_a.npk();
let npk_b = keys_b.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys_b.vpk());
// `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state.
// `AccountId::for_private_pda(program, seed, npk_b) != account_id`, so the claim check in
// the circuit must reject.
2026-05-01 00:05:50 -03:00
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk_a, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk: npk_b,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program.into(),
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Happy path for the caller-seeds authorization of a mask-3 PDA. The delegator claims a
/// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same
docs: split miscoupled private-PDA test docs and clean phrasing Addresses the following review comments: - "Isn't two_mask_3_claims_under_same_seed_are_rejected already checking that there's a mechanism protecting against this exploit scenario?" The doc block at nssa/src/state.rs:2488-2504 mixes three paragraphs, one about reuse, one TODO about wallet side input, one exploit pin, all attached to two_mask_3_claims_under_same_seed_are_rejected. The reuse test below it had no doc at all. I split as follows: the exploit-pin paragraph stays on two_mask_3_claims_..., the reuse paragraph moves to a fresh docstring on mask_3_reuse_across_txs_currently_unsupported. - "I don't understand this. I think this should fail because ... the input pre_state which is marked with is_authorized=true will make things fail." The reuse test's new docstring cites the actual reject site, the post-loop private_pda_bound_positions assertion in privacy_preserving_circuit.rs:185-192. At top level the Entry::Vacant arm accepts is_authorized=true unconditionally, the rejection comes from the bound-positions check firing because noop emits no Claim::Pda and there is no caller ChainedCall.pda_seeds. - "let's dont have this TODO as part of the doc" The block is moved out into regular // comments immediately above mask_3_reuse_across_txs_currently_unsupported. - "let's not add implementation details to docs" In caller_pda_seeds_authorize_mask_3_private_pda_for_callee's docstring, I dropped the parenthetical "(Occupied branch)" and the trailing sentence about which validate_and_sync_states code path gets exercised. - "what does \`Claim::Pda(seed)\` / \`pda_seeds\` mean?" I rewrote the pda_family_binding docstring at privacy_preserving_circuit.rs:33-39: replaced the ambiguous "Claim::PrivatePda and ChainedCall's private seeds into plain Claim::Pda(seed) / pda_seeds" phrase with "a Claim::Pda(seed) in a program's post_state or a caller's ChainedCall.pda_seeds entry". - Suggestion on nssa/src/validated_state_diff.rs:226 rewriting "The public-execution path only sees mask-0 accounts" to "The public-execution path only sees public accounts". Applied: "The public-execution path only sees public accounts". - Clarification requested on the private_pda_bound_positions field: I expanded the docstring at privacy_preserving_circuit.rs:26-31 to state that binding is an idempotent property, not an event, and to enumerate the two proof paths that populate it (a Claim::Pda on a mask-3 pre_state, or a caller's pda_seeds matching under the private derivation).
2026-04-21 00:37:06 +02:00
/// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state`'s authorization
/// is established via the private derivation
/// `AccountId::for_private_pda(delegator, seed, npk) == pre.account_id`.
#[test]
fn caller_pda_seeds_authorize_private_pda_for_callee() {
let delegator = Program::private_pda_delegator();
test: exercise callee authorization in private-PDA delegation tests Addresses the following review comments: - "Shouldn't we use a program that checks authorization in this test as callee? If not, I'm not sure if we are fully testing what the test docs describe (namely, that the callee got the input account with is_authorized=true). Maybe add a variant of the noop that checks the input account is authorized." I added test_program_methods/guest/src/bin/auth_asserting_noop.rs: same shape as noop.rs except it asserts pre.is_authorized == true for every pre_state before echoing the post_states. Any unauthorized pre_state panics the guest, failing the whole circuit proof. I added Program::auth_asserting_noop() as the matching helper. In caller_pda_seeds_authorize_private_pda_for_callee and caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee, I swapped Program::noop() for Program::auth_asserting_noop() as the callee. The positive test now proves the callee actually sees is_authorized=true, not just that the circuit's consistency check did not reject. The negative test doubles its evidence, both the circuit's authorization reconciliation and the callee guest would now reject a wrong-seed delegation. - "This branching logic is only correct because we are not supporting non-authorized private accounts with non-default values. Likely to be changed in the future. I'm sure there's use cases for this. For example the multisig program if ran completely private it would need a private non-default and non-authorized input account." Agreed. Supporting this needs wallet-supplied `(seed, owner)` side input so the npk-to-account_id binding can be re-verified for an existing private PDA without a fresh Claim::Pda or a caller pda_seeds match. I handled this in the second PR. I added a TODO(private-pdas-pr-2/3) marker on the `else` branch in privacy_preserving_circuit.rs:3 => { ... } so the constraint is visible to future maintainers, along with a comment noting the multisig use case.
2026-04-21 02:08:02 +02:00
let callee = Program::auth_asserting_noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
2026-05-01 00:05:50 -03:00
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
test: exercise callee authorization in private-PDA delegation tests Addresses the following review comments: - "Shouldn't we use a program that checks authorization in this test as callee? If not, I'm not sure if we are fully testing what the test docs describe (namely, that the callee got the input account with is_authorized=true). Maybe add a variant of the noop that checks the input account is authorized." I added test_program_methods/guest/src/bin/auth_asserting_noop.rs: same shape as noop.rs except it asserts pre.is_authorized == true for every pre_state before echoing the post_states. Any unauthorized pre_state panics the guest, failing the whole circuit proof. I added Program::auth_asserting_noop() as the matching helper. In caller_pda_seeds_authorize_private_pda_for_callee and caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee, I swapped Program::noop() for Program::auth_asserting_noop() as the callee. The positive test now proves the callee actually sees is_authorized=true, not just that the circuit's consistency check did not reject. The negative test doubles its evidence, both the circuit's authorization reconciliation and the callee guest would now reject a wrong-seed delegation. - "This branching logic is only correct because we are not supporting non-authorized private accounts with non-default values. Likely to be changed in the future. I'm sure there's use cases for this. For example the multisig program if ran completely private it would need a private non-default and non-authorized input account." Agreed. Supporting this needs wallet-supplied `(seed, owner)` side input so the npk-to-account_id binding can be re-verified for an existing private PDA without a fresh Claim::Pda or a caller pda_seeds match. I handled this in the second PR. I added a TODO(private-pdas-pr-2/3) marker on the `else` branch in privacy_preserving_circuit.rs:3 => { ... } so the constraint is visible to future maintainers, along with a comment noting the multisig use case.
2026-04-21 02:08:02 +02:00
let callee_id = callee.id();
let program_with_deps =
ProgramWithDependencies::new(delegator, [(callee_id, callee)].into());
let result = execute_and_prove(
vec![pre_state],
test: exercise callee authorization in private-PDA delegation tests Addresses the following review comments: - "Shouldn't we use a program that checks authorization in this test as callee? If not, I'm not sure if we are fully testing what the test docs describe (namely, that the callee got the input account with is_authorized=true). Maybe add a variant of the noop that checks the input account is authorized." I added test_program_methods/guest/src/bin/auth_asserting_noop.rs: same shape as noop.rs except it asserts pre.is_authorized == true for every pre_state before echoing the post_states. Any unauthorized pre_state panics the guest, failing the whole circuit proof. I added Program::auth_asserting_noop() as the matching helper. In caller_pda_seeds_authorize_private_pda_for_callee and caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee, I swapped Program::noop() for Program::auth_asserting_noop() as the callee. The positive test now proves the callee actually sees is_authorized=true, not just that the circuit's consistency check did not reject. The negative test doubles its evidence, both the circuit's authorization reconciliation and the callee guest would now reject a wrong-seed delegation. - "This branching logic is only correct because we are not supporting non-authorized private accounts with non-default values. Likely to be changed in the future. I'm sure there's use cases for this. For example the multisig program if ran completely private it would need a private non-default and non-authorized input account." Agreed. Supporting this needs wallet-supplied `(seed, owner)` side input so the npk-to-account_id binding can be re-verified for an existing private PDA without a fresh Claim::Pda or a caller pda_seeds match. I handled this in the second PR. I added a TODO(private-pdas-pr-2/3) marker on the `else` branch in privacy_preserving_circuit.rs:3 => { ... } so the constraint is visible to future maintainers, along with a comment noting the multisig use case.
2026-04-21 02:08:02 +02:00
Program::serialize_instruction((seed, seed, callee_id)).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program_with_deps,
);
let (output, _proof) =
result.expect("caller-seeds authorization of mask-3 private PDA should succeed");
assert_eq!(output.new_commitments.len(), 1);
assert_eq!(output.new_nullifiers.len(), 1);
}
/// The delegator chains with a different seed than the one it claimed with. In the callee
/// step, neither public nor private caller-seeds authorization matches; `pre.is_authorized`
/// was set to `true` by the delegator but no proven source supports it, so the consistency
/// assertion rejects.
#[test]
fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() {
let delegator = Program::private_pda_delegator();
test: exercise callee authorization in private-PDA delegation tests Addresses the following review comments: - "Shouldn't we use a program that checks authorization in this test as callee? If not, I'm not sure if we are fully testing what the test docs describe (namely, that the callee got the input account with is_authorized=true). Maybe add a variant of the noop that checks the input account is authorized." I added test_program_methods/guest/src/bin/auth_asserting_noop.rs: same shape as noop.rs except it asserts pre.is_authorized == true for every pre_state before echoing the post_states. Any unauthorized pre_state panics the guest, failing the whole circuit proof. I added Program::auth_asserting_noop() as the matching helper. In caller_pda_seeds_authorize_private_pda_for_callee and caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee, I swapped Program::noop() for Program::auth_asserting_noop() as the callee. The positive test now proves the callee actually sees is_authorized=true, not just that the circuit's consistency check did not reject. The negative test doubles its evidence, both the circuit's authorization reconciliation and the callee guest would now reject a wrong-seed delegation. - "This branching logic is only correct because we are not supporting non-authorized private accounts with non-default values. Likely to be changed in the future. I'm sure there's use cases for this. For example the multisig program if ran completely private it would need a private non-default and non-authorized input account." Agreed. Supporting this needs wallet-supplied `(seed, owner)` side input so the npk-to-account_id binding can be re-verified for an existing private PDA without a fresh Claim::Pda or a caller pda_seeds match. I handled this in the second PR. I added a TODO(private-pdas-pr-2/3) marker on the `else` branch in privacy_preserving_circuit.rs:3 => { ... } so the constraint is visible to future maintainers, along with a comment noting the multisig use case.
2026-04-21 02:08:02 +02:00
let callee = Program::auth_asserting_noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let claim_seed = PdaSeed::new([77; 32]);
let wrong_delegated_seed = PdaSeed::new([88; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
2026-05-01 00:05:50 -03:00
let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
test: exercise callee authorization in private-PDA delegation tests Addresses the following review comments: - "Shouldn't we use a program that checks authorization in this test as callee? If not, I'm not sure if we are fully testing what the test docs describe (namely, that the callee got the input account with is_authorized=true). Maybe add a variant of the noop that checks the input account is authorized." I added test_program_methods/guest/src/bin/auth_asserting_noop.rs: same shape as noop.rs except it asserts pre.is_authorized == true for every pre_state before echoing the post_states. Any unauthorized pre_state panics the guest, failing the whole circuit proof. I added Program::auth_asserting_noop() as the matching helper. In caller_pda_seeds_authorize_private_pda_for_callee and caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee, I swapped Program::noop() for Program::auth_asserting_noop() as the callee. The positive test now proves the callee actually sees is_authorized=true, not just that the circuit's consistency check did not reject. The negative test doubles its evidence, both the circuit's authorization reconciliation and the callee guest would now reject a wrong-seed delegation. - "This branching logic is only correct because we are not supporting non-authorized private accounts with non-default values. Likely to be changed in the future. I'm sure there's use cases for this. For example the multisig program if ran completely private it would need a private non-default and non-authorized input account." Agreed. Supporting this needs wallet-supplied `(seed, owner)` side input so the npk-to-account_id binding can be re-verified for an existing private PDA without a fresh Claim::Pda or a caller pda_seeds match. I handled this in the second PR. I added a TODO(private-pdas-pr-2/3) marker on the `else` branch in privacy_preserving_circuit.rs:3 => { ... } so the constraint is visible to future maintainers, along with a comment noting the multisig use case.
2026-04-21 02:08:02 +02:00
let callee_id = callee.id();
let program_with_deps =
ProgramWithDependencies::new(delegator, [(callee_id, callee)].into());
let result = execute_and_prove(
vec![pre_state],
test: exercise callee authorization in private-PDA delegation tests Addresses the following review comments: - "Shouldn't we use a program that checks authorization in this test as callee? If not, I'm not sure if we are fully testing what the test docs describe (namely, that the callee got the input account with is_authorized=true). Maybe add a variant of the noop that checks the input account is authorized." I added test_program_methods/guest/src/bin/auth_asserting_noop.rs: same shape as noop.rs except it asserts pre.is_authorized == true for every pre_state before echoing the post_states. Any unauthorized pre_state panics the guest, failing the whole circuit proof. I added Program::auth_asserting_noop() as the matching helper. In caller_pda_seeds_authorize_private_pda_for_callee and caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee, I swapped Program::noop() for Program::auth_asserting_noop() as the callee. The positive test now proves the callee actually sees is_authorized=true, not just that the circuit's consistency check did not reject. The negative test doubles its evidence, both the circuit's authorization reconciliation and the callee guest would now reject a wrong-seed delegation. - "This branching logic is only correct because we are not supporting non-authorized private accounts with non-default values. Likely to be changed in the future. I'm sure there's use cases for this. For example the multisig program if ran completely private it would need a private non-default and non-authorized input account." Agreed. Supporting this needs wallet-supplied `(seed, owner)` side input so the npk-to-account_id binding can be re-verified for an existing private PDA without a fresh Claim::Pda or a caller pda_seeds match. I handled this in the second PR. I added a TODO(private-pdas-pr-2/3) marker on the `else` branch in privacy_preserving_circuit.rs:3 => { ... } so the constraint is visible to future maintainers, along with a comment noting the multisig use case.
2026-04-21 02:08:02 +02:00
Program::serialize_instruction((claim_seed, wrong_delegated_seed, callee_id)).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program_with_deps,
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of
docs: split miscoupled private-PDA test docs and clean phrasing Addresses the following review comments: - "Isn't two_mask_3_claims_under_same_seed_are_rejected already checking that there's a mechanism protecting against this exploit scenario?" The doc block at nssa/src/state.rs:2488-2504 mixes three paragraphs, one about reuse, one TODO about wallet side input, one exploit pin, all attached to two_mask_3_claims_under_same_seed_are_rejected. The reuse test below it had no doc at all. I split as follows: the exploit-pin paragraph stays on two_mask_3_claims_..., the reuse paragraph moves to a fresh docstring on mask_3_reuse_across_txs_currently_unsupported. - "I don't understand this. I think this should fail because ... the input pre_state which is marked with is_authorized=true will make things fail." The reuse test's new docstring cites the actual reject site, the post-loop private_pda_bound_positions assertion in privacy_preserving_circuit.rs:185-192. At top level the Entry::Vacant arm accepts is_authorized=true unconditionally, the rejection comes from the bound-positions check firing because noop emits no Claim::Pda and there is no caller ChainedCall.pda_seeds. - "let's dont have this TODO as part of the doc" The block is moved out into regular // comments immediately above mask_3_reuse_across_txs_currently_unsupported. - "let's not add implementation details to docs" In caller_pda_seeds_authorize_mask_3_private_pda_for_callee's docstring, I dropped the parenthetical "(Occupied branch)" and the trailing sentence about which validate_and_sync_states code path gets exercised. - "what does \`Claim::Pda(seed)\` / \`pda_seeds\` mean?" I rewrote the pda_family_binding docstring at privacy_preserving_circuit.rs:33-39: replaced the ambiguous "Claim::PrivatePda and ChainedCall's private seeds into plain Claim::Pda(seed) / pda_seeds" phrase with "a Claim::Pda(seed) in a program's post_state or a caller's ChainedCall.pda_seeds entry". - Suggestion on nssa/src/validated_state_diff.rs:226 rewriting "The public-execution path only sees mask-0 accounts" to "The public-execution path only sees public accounts". Applied: "The public-execution path only sees public accounts". - Clarification requested on the private_pda_bound_positions field: I expanded the docstring at privacy_preserving_circuit.rs:26-31 to state that binding is an idempotent property, not an event, and to enumerate the two proof paths that populate it (a Claim::Pda on a mask-3 pre_state, or a caller's pda_seeds matching under the private derivation).
2026-04-21 00:37:06 +02:00
/// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide
/// family-binding check, a program could claim `PDA_alice` (mask-3, `alice_npk`) and
docs: split miscoupled private-PDA test docs and clean phrasing Addresses the following review comments: - "Isn't two_mask_3_claims_under_same_seed_are_rejected already checking that there's a mechanism protecting against this exploit scenario?" The doc block at nssa/src/state.rs:2488-2504 mixes three paragraphs, one about reuse, one TODO about wallet side input, one exploit pin, all attached to two_mask_3_claims_under_same_seed_are_rejected. The reuse test below it had no doc at all. I split as follows: the exploit-pin paragraph stays on two_mask_3_claims_..., the reuse paragraph moves to a fresh docstring on mask_3_reuse_across_txs_currently_unsupported. - "I don't understand this. I think this should fail because ... the input pre_state which is marked with is_authorized=true will make things fail." The reuse test's new docstring cites the actual reject site, the post-loop private_pda_bound_positions assertion in privacy_preserving_circuit.rs:185-192. At top level the Entry::Vacant arm accepts is_authorized=true unconditionally, the rejection comes from the bound-positions check firing because noop emits no Claim::Pda and there is no caller ChainedCall.pda_seeds. - "let's dont have this TODO as part of the doc" The block is moved out into regular // comments immediately above mask_3_reuse_across_txs_currently_unsupported. - "let's not add implementation details to docs" In caller_pda_seeds_authorize_mask_3_private_pda_for_callee's docstring, I dropped the parenthetical "(Occupied branch)" and the trailing sentence about which validate_and_sync_states code path gets exercised. - "what does \`Claim::Pda(seed)\` / \`pda_seeds\` mean?" I rewrote the pda_family_binding docstring at privacy_preserving_circuit.rs:33-39: replaced the ambiguous "Claim::PrivatePda and ChainedCall's private seeds into plain Claim::Pda(seed) / pda_seeds" phrase with "a Claim::Pda(seed) in a program's post_state or a caller's ChainedCall.pda_seeds entry". - Suggestion on nssa/src/validated_state_diff.rs:226 rewriting "The public-execution path only sees mask-0 accounts" to "The public-execution path only sees public accounts". Applied: "The public-execution path only sees public accounts". - Clarification requested on the private_pda_bound_positions field: I expanded the docstring at privacy_preserving_circuit.rs:26-31 to state that binding is an idempotent property, not an event, and to enumerate the two proof paths that populate it (a Claim::Pda on a mask-3 pre_state, or a caller's pda_seeds matching under the private derivation).
2026-04-21 00:37:06 +02:00
/// `PDA_bob` (mask-3, `bob_npk`) under the same seed in one transaction, and once reuse
/// is supported a later chained call could delegate both to a callee via
/// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup
/// here: after the first claim records `(program, seed) → PDA_alice`, the second claim
/// tries to record `(program, seed) → PDA_bob` and panics.
#[test]
fn two_private_pda_claims_under_same_seed_are_rejected() {
let program = Program::two_pda_claimer();
let keys_a = test_private_account_keys_1();
let keys_b = test_private_account_keys_2();
let seed = PdaSeed::new([55; 32]);
let shared_a = SharedSecretKey::new(&[66; 32], &keys_a.vpk());
let shared_b = SharedSecretKey::new(&[77; 32], &keys_b.vpk());
2026-05-01 00:05:50 -03:00
let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk(), u128::MAX);
let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk(), u128::MAX);
let pre_a = AccountWithMetadata::new(Account::default(), false, account_a);
let pre_b = AccountWithMetadata::new(Account::default(), false, account_b);
let result = execute_and_prove(
vec![pre_a, pre_b],
Program::serialize_instruction(seed).unwrap(),
vec![
InputAccountIdentity::PrivatePdaInit {
npk: keys_a.npk(),
ssk: shared_a,
identifier: u128::MAX,
},
InputAccountIdentity::PrivatePdaInit {
npk: keys_b.npk(),
ssk: shared_b,
identifier: u128::MAX,
},
],
&program.into(),
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
docs: split miscoupled private-PDA test docs and clean phrasing Addresses the following review comments: - "Isn't two_mask_3_claims_under_same_seed_are_rejected already checking that there's a mechanism protecting against this exploit scenario?" The doc block at nssa/src/state.rs:2488-2504 mixes three paragraphs, one about reuse, one TODO about wallet side input, one exploit pin, all attached to two_mask_3_claims_under_same_seed_are_rejected. The reuse test below it had no doc at all. I split as follows: the exploit-pin paragraph stays on two_mask_3_claims_..., the reuse paragraph moves to a fresh docstring on mask_3_reuse_across_txs_currently_unsupported. - "I don't understand this. I think this should fail because ... the input pre_state which is marked with is_authorized=true will make things fail." The reuse test's new docstring cites the actual reject site, the post-loop private_pda_bound_positions assertion in privacy_preserving_circuit.rs:185-192. At top level the Entry::Vacant arm accepts is_authorized=true unconditionally, the rejection comes from the bound-positions check firing because noop emits no Claim::Pda and there is no caller ChainedCall.pda_seeds. - "let's dont have this TODO as part of the doc" The block is moved out into regular // comments immediately above mask_3_reuse_across_txs_currently_unsupported. - "let's not add implementation details to docs" In caller_pda_seeds_authorize_mask_3_private_pda_for_callee's docstring, I dropped the parenthetical "(Occupied branch)" and the trailing sentence about which validate_and_sync_states code path gets exercised. - "what does \`Claim::Pda(seed)\` / \`pda_seeds\` mean?" I rewrote the pda_family_binding docstring at privacy_preserving_circuit.rs:33-39: replaced the ambiguous "Claim::PrivatePda and ChainedCall's private seeds into plain Claim::Pda(seed) / pda_seeds" phrase with "a Claim::Pda(seed) in a program's post_state or a caller's ChainedCall.pda_seeds entry". - Suggestion on nssa/src/validated_state_diff.rs:226 rewriting "The public-execution path only sees mask-0 accounts" to "The public-execution path only sees public accounts". Applied: "The public-execution path only sees public accounts". - Clarification requested on the private_pda_bound_positions field: I expanded the docstring at privacy_preserving_circuit.rs:26-31 to state that binding is an idempotent property, not an event, and to enumerate the two proof paths that populate it (a Claim::Pda on a mask-3 pre_state, or a caller's pda_seeds matching under the private derivation).
2026-04-21 00:37:06 +02:00
/// Pins the current limitation: a mask-3 PDA that was claimed in a previous transaction
/// cannot be re-used in a new transaction as-is. This PR only binds supplied npks via a
/// fresh `Claim::Pda` or a caller's `ChainedCall.pda_seeds`, neither is present when a
/// program operates on an already-owned private PDA at top level. The reject site is the
/// post-loop `private_pda_bound_positions` assertion in
/// `privacy_preserving_circuit.rs`: `noop` emits no `Claim::Pda` and there is no caller
/// `ChainedCall.pda_seeds`, so position 0 is never bound and the assertion fires.
// TODO: a follow-up PR in the Private PDAs series needs to let the wallet supply a
// `(seed, original_owner_program_id)` side input per mask-3 `pre_state` so the circuit
// can re-verify `AccountId::for_private_pda(owner, seed, npk) == pre.account_id` without a
docs: split miscoupled private-PDA test docs and clean phrasing Addresses the following review comments: - "Isn't two_mask_3_claims_under_same_seed_are_rejected already checking that there's a mechanism protecting against this exploit scenario?" The doc block at nssa/src/state.rs:2488-2504 mixes three paragraphs, one about reuse, one TODO about wallet side input, one exploit pin, all attached to two_mask_3_claims_under_same_seed_are_rejected. The reuse test below it had no doc at all. I split as follows: the exploit-pin paragraph stays on two_mask_3_claims_..., the reuse paragraph moves to a fresh docstring on mask_3_reuse_across_txs_currently_unsupported. - "I don't understand this. I think this should fail because ... the input pre_state which is marked with is_authorized=true will make things fail." The reuse test's new docstring cites the actual reject site, the post-loop private_pda_bound_positions assertion in privacy_preserving_circuit.rs:185-192. At top level the Entry::Vacant arm accepts is_authorized=true unconditionally, the rejection comes from the bound-positions check firing because noop emits no Claim::Pda and there is no caller ChainedCall.pda_seeds. - "let's dont have this TODO as part of the doc" The block is moved out into regular // comments immediately above mask_3_reuse_across_txs_currently_unsupported. - "let's not add implementation details to docs" In caller_pda_seeds_authorize_mask_3_private_pda_for_callee's docstring, I dropped the parenthetical "(Occupied branch)" and the trailing sentence about which validate_and_sync_states code path gets exercised. - "what does \`Claim::Pda(seed)\` / \`pda_seeds\` mean?" I rewrote the pda_family_binding docstring at privacy_preserving_circuit.rs:33-39: replaced the ambiguous "Claim::PrivatePda and ChainedCall's private seeds into plain Claim::Pda(seed) / pda_seeds" phrase with "a Claim::Pda(seed) in a program's post_state or a caller's ChainedCall.pda_seeds entry". - Suggestion on nssa/src/validated_state_diff.rs:226 rewriting "The public-execution path only sees mask-0 accounts" to "The public-execution path only sees public accounts". Applied: "The public-execution path only sees public accounts". - Clarification requested on the private_pda_bound_positions field: I expanded the docstring at privacy_preserving_circuit.rs:26-31 to state that binding is an idempotent property, not an event, and to enumerate the two proof paths that populate it (a Claim::Pda on a mask-3 pre_state, or a caller's pda_seeds matching under the private derivation).
2026-04-21 00:37:06 +02:00
// claim.
#[test]
fn private_pda_top_level_reuse_rejected_by_binding_check() {
let program = Program::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let seed = PdaSeed::new([99; 32]);
// Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized =
// true, account_id derived via the private formula.
2026-05-01 00:05:50 -03:00
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX);
let owned_pre_state = AccountWithMetadata::new(
Account {
program_owner: program.id(),
..Account::default()
},
true,
account_id,
);
let result = execute_and_prove(
vec![owned_pre_state],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
2026-03-04 18:42:33 +03:00
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn private_accounts_can_only_be_initialized_once() {
let sender_keys = test_private_account_keys_1();
2026-03-18 13:10:36 -04:00
let sender_nonce = Nonce(0xdead_beef);
2026-03-04 18:42:33 +03:00
let sender_private_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: 100,
2026-03-18 13:10:36 -04:00
nonce: sender_nonce,
2026-03-04 18:42:33 +03:00
data: Data::default(),
};
let recipient_keys = test_private_account_keys_2();
2025-12-18 18:45:57 -05:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0)
2026-03-04 18:42:33 +03:00
.with_private_account(&sender_keys, &sender_private_account);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let balance_to_move = 37;
2026-03-18 13:10:36 -04:00
let balance_to_move_2 = 30;
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let tx = private_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys,
balance_to_move,
&state,
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
2026-03-04 18:42:33 +03:00
.unwrap();
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let sender_private_account = Account {
program_owner: Program::authenticated_transfer_program().id(),
2026-03-18 13:10:36 -04:00
balance: 100,
nonce: sender_nonce,
2026-03-04 18:42:33 +03:00
data: Data::default(),
};
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let tx = private_balance_transfer_for_tests(
&sender_keys,
&sender_private_account,
&recipient_keys,
2026-03-18 13:10:36 -04:00
balance_to_move_2,
2026-03-04 18:42:33 +03:00
&state,
);
2025-12-18 18:45:57 -05:00
let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::InvalidInput(_))));
let NssaError::InvalidInput(error_message) = result.err().unwrap() else {
panic!("Incorrect message error");
};
let expected_error_message = "Nullifier already seen".to_owned();
assert_eq!(error_message, expected_error_message);
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn circuit_should_fail_if_there_are_repeated_ids() {
let program = Program::simple_balance_transfer();
let sender_keys = test_private_account_keys_1();
let private_account_1 = AccountWithMetadata::new(
2025-12-18 18:45:57 -05:00
Account {
2026-03-04 18:42:33 +03:00
program_owner: program.id(),
balance: 100,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&sender_keys.npk(), 0),
2026-03-04 18:42:33 +03:00
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let shared_secret = SharedSecretKey::new(&[55; 32], &sender_keys.vpk());
let result = execute_and_prove(
vec![private_account_1.clone(), private_account_1],
Program::serialize_instruction(100_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: shared_secret,
nsk: sender_keys.nsk,
membership_proof: (1, vec![]),
identifier: 0,
},
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: shared_secret,
nsk: sender_keys.nsk,
membership_proof: (1, vec![]),
identifier: 0,
},
2026-03-04 18:42:33 +03:00
],
&program.into(),
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn claiming_mechanism() {
let program = Program::authenticated_transfer_program();
let from_key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&from_key));
2026-03-04 18:42:33 +03:00
let initial_balance = 100;
let initial_data = [(from, initial_balance)];
2026-03-04 18:42:33 +03:00
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
2026-03-04 18:42:33 +03:00
let amount: u128 = 37;
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
// Check the recipient is an uninitialized account
assert_eq!(state.get_account_by_id(to), Account::default());
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let expected_recipient_post = Account {
program_owner: program.id(),
balance: amount,
nonce: Nonce(1),
2026-03-04 18:42:33 +03:00
..Account::default()
};
2025-12-18 18:45:57 -05:00
2026-03-18 10:28:52 -04:00
let message = public_transaction::Message::try_new(
program.id(),
vec![from, to],
vec![Nonce(0), Nonce(0)],
2026-03-18 10:28:52 -04:00
amount,
)
.unwrap();
let witness_set =
public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]);
2026-03-04 18:42:33 +03:00
let tx = PublicTransaction::new(message, witness_set);
2025-12-18 18:45:57 -05:00
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let recipient_post = state.get_account_by_id(to);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
assert_eq!(recipient_post, expected_recipient_post);
}
2025-12-18 18:45:57 -05:00
#[test]
fn unauthorized_public_account_claiming_fails() {
let program = Program::authenticated_transfer_program();
let account_key = PrivateKey::try_new([9; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
assert_eq!(state.get_account_by_id(account_id), Account::default());
let message =
public_transaction::Message::try_new(program.id(), vec![account_id], vec![], 0_u128)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(result, Err(NssaError::ProgramExecutionFailed(_))));
assert_eq!(state.get_account_by_id(account_id), Account::default());
}
#[test]
fn authorized_public_account_claiming_succeeds() {
let program = Program::authenticated_transfer_program();
let account_key = PrivateKey::try_new([10; 32]).unwrap();
let account_id = AccountId::from(&PublicKey::new_from_private_key(&account_key));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
assert_eq!(state.get_account_by_id(account_id), Account::default());
let message = public_transaction::Message::try_new(
program.id(),
vec![account_id],
vec![Nonce(0)],
0_u128,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&account_key]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
assert_eq!(
state.get_account_by_id(account_id),
Account {
program_owner: program.id(),
nonce: Nonce(1),
..Account::default()
}
);
}
2026-03-04 18:42:33 +03:00
#[test]
fn public_chained_call() {
let program = Program::chain_caller();
let key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&key));
let to = AccountId::new([2; 32]);
let initial_balance = 1000;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let from_key = key;
let amount: u128 = 37;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
Program::authenticated_transfer_program().id(),
2,
None,
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let expected_to_post = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: amount * 2, // The `chain_caller` chains the program twice
..Account::default()
};
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let message = public_transaction::Message::try_new(
program.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
2026-03-18 10:28:52 -04:00
vec![Nonce(0)],
2026-03-04 18:42:33 +03:00
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]);
let tx = PublicTransaction::new(message, witness_set);
2025-12-18 18:45:57 -05:00
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let from_post = state.get_account_by_id(from);
let to_post = state.get_account_by_id(to);
// The `chain_caller` program calls the program twice
assert_eq!(from_post.balance, initial_balance - 2 * amount);
assert_eq!(to_post, expected_to_post);
}
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
#[test]
fn execution_fails_if_chained_calls_exceeds_depth() {
let program = Program::chain_caller();
let key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&key));
let to = AccountId::new([2; 32]);
let initial_balance = 100;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
2026-03-04 18:42:33 +03:00
let from_key = key;
let amount: u128 = 0;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
Program::authenticated_transfer_program().id(),
u32::try_from(MAX_NUMBER_CHAINED_CALLS).expect("MAX_NUMBER_CHAINED_CALLS fits in u32")
+ 1,
None,
);
2025-12-18 18:45:57 -05:00
2026-03-04 18:42:33 +03:00
let message = public_transaction::Message::try_new(
program.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
2026-03-18 10:28:52 -04:00
vec![Nonce(0)],
2026-03-04 18:42:33 +03:00
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&from_key]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
2026-03-04 18:42:33 +03:00
assert!(matches!(
result,
Err(NssaError::MaxChainedCallsDepthExceeded)
));
2025-12-06 14:52:18 -05:00
}
#[test]
2026-03-04 18:42:33 +03:00
fn execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() {
let chain_caller = Program::chain_caller();
let pda_seed = PdaSeed::new([37; 32]);
let from = AccountId::for_public_pda(&chain_caller.id(), &pda_seed);
let to = AccountId::new([2; 32]);
let initial_balance = 1000;
let initial_data = [(from, initial_balance), (to, 0)];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let amount: u128 = 58;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
Program::authenticated_transfer_program().id(),
1,
Some(pda_seed),
);
let expected_to_post = Account {
program_owner: Program::authenticated_transfer_program().id(),
balance: amount, // The `chain_caller` chains the program twice
..Account::default()
};
let message = public_transaction::Message::try_new(
chain_caller.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
vec![],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let from_post = state.get_account_by_id(from);
let to_post = state.get_account_by_id(to);
assert_eq!(from_post.balance, initial_balance - amount);
assert_eq!(to_post, expected_to_post);
}
2025-12-03 17:06:09 -03:00
#[test]
2026-03-04 18:42:33 +03:00
fn claiming_mechanism_within_chain_call() {
2025-12-03 17:06:09 -03:00
// This test calls the authenticated transfer program through the chain_caller program.
// The transfer is made from an initialized sender to an uninitialized recipient. And
// it is expected that the recipient account is claimed by the authenticated transfer
// program and not the chained_caller program.
let chain_caller = Program::chain_caller();
let auth_transfer = Program::authenticated_transfer_program();
let from_key = PrivateKey::try_new([1; 32]).unwrap();
let from = AccountId::from(&PublicKey::new_from_private_key(&from_key));
2025-12-03 17:06:09 -03:00
let initial_balance = 100;
let initial_data = [(from, initial_balance)];
2025-12-03 17:06:09 -03:00
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let to_key = PrivateKey::try_new([2; 32]).unwrap();
let to = AccountId::from(&PublicKey::new_from_private_key(&to_key));
2025-12-03 17:06:09 -03:00
let amount: u128 = 37;
// Check the recipient is an uninitialized account
assert_eq!(state.get_account_by_id(to), Account::default());
2025-12-03 17:06:09 -03:00
let expected_to_post = Account {
// The expected program owner is the authenticated transfer program
program_owner: auth_transfer.id(),
balance: amount,
nonce: Nonce(1),
2025-12-03 17:06:09 -03:00
..Account::default()
};
// The transaction executes the chain_caller program, which internally calls the
// authenticated_transfer program
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
Program::authenticated_transfer_program().id(),
1,
None,
);
2025-12-03 17:06:09 -03:00
let message = public_transaction::Message::try_new(
chain_caller.id(),
vec![to, from], // The chain_caller program permutes the account order in the chain
// call
vec![Nonce(0), Nonce(0)],
2025-12-03 17:06:09 -03:00
instruction,
)
.unwrap();
let witness_set =
public_transaction::WitnessSet::for_message(&message, &[&from_key, &to_key]);
2025-12-03 17:06:09 -03:00
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
2025-12-03 17:06:09 -03:00
let from_post = state.get_account_by_id(from);
let to_post = state.get_account_by_id(to);
assert_eq!(from_post.balance, initial_balance - amount);
assert_eq!(to_post, expected_to_post);
}
2025-11-28 11:10:00 -03:00
#[test]
fn unauthorized_public_account_claiming_fails_when_executed_privately() {
let program = Program::authenticated_transfer_program();
let account_id = AccountId::new([11; 32]);
let public_account = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![public_account],
Program::serialize_instruction(0_u128).unwrap(),
vec![InputAccountIdentity::Public],
&program.into(),
);
assert!(matches!(result, Err(NssaError::ProgramProveFailed(_))));
}
#[test]
fn authorized_public_account_claiming_succeeds_when_executed_privately() {
let program = Program::authenticated_transfer_program();
let program_id = program.id();
let sender_keys = test_private_account_keys_1();
let sender_private_account = Account {
program_owner: program_id,
balance: 100,
..Account::default()
};
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account);
let sender_init_nullifier = Nullifier::for_account_initialization(&sender_account_id);
let mut state = V03State::new_with_genesis_accounts(
&[],
2026-04-07 13:35:13 -03:00
vec![(sender_commitment.clone(), sender_init_nullifier)],
0,
);
2026-04-19 23:13:51 -03:00
let sender_pre =
AccountWithMetadata::new(sender_private_account, true, (&sender_keys.npk(), 0));
let recipient_private_key = PrivateKey::try_new([2; 32]).unwrap();
let recipient_account_id =
AccountId::from(&PublicKey::new_from_private_key(&recipient_private_key));
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, recipient_account_id);
let esk = [5; 32];
let shared_secret = SharedSecretKey::new(&esk, &sender_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (output, proof) = execute_and_prove(
vec![sender_pre, recipient_pre],
Program::serialize_instruction(37_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: shared_secret,
nsk: sender_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&sender_commitment)
.expect("sender's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
&program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![recipient_account_id],
vec![Nonce(0)],
vec![(sender_keys.npk(), sender_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_private_key]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
let nullifier = Nullifier::for_account_update(&sender_commitment, &sender_keys.nsk);
assert!(state.private_state.1.contains(&nullifier));
assert_eq!(
state.get_account_by_id(recipient_account_id),
Account {
program_owner: program_id,
balance: 37,
nonce: Nonce(1),
..Account::default()
}
);
}
#[test_case::test_case(1; "single call")]
#[test_case::test_case(2; "two calls")]
2026-03-04 18:42:33 +03:00
fn private_chained_call(number_of_calls: u32) {
2025-11-22 17:48:29 -03:00
// Arrange
2025-11-20 19:25:56 -03:00
let chain_caller = Program::chain_caller();
let auth_transfers = Program::authenticated_transfer_program();
2025-11-20 01:40:05 -03:00
let from_keys = test_private_account_keys_1();
2025-11-20 19:25:56 -03:00
let to_keys = test_private_account_keys_2();
2025-11-20 01:40:05 -03:00
let initial_balance = 100;
let from_account = AccountWithMetadata::new(
Account {
2025-11-20 19:25:56 -03:00
program_owner: auth_transfers.id(),
2025-11-20 01:40:05 -03:00
balance: initial_balance,
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&from_keys.npk(), 0),
2025-11-20 01:40:05 -03:00
);
2025-11-20 19:25:56 -03:00
let to_account = AccountWithMetadata::new(
Account {
program_owner: auth_transfers.id(),
..Account::default()
},
true,
2026-04-14 18:02:38 -03:00
(&to_keys.npk(), 0),
2025-11-20 19:25:56 -03:00
);
2025-11-22 17:48:29 -03:00
let from_account_id = AccountId::from((&from_keys.npk(), 0));
let to_account_id = AccountId::from((&to_keys.npk(), 0));
let from_commitment = Commitment::new(&from_account_id, &from_account.account);
let to_commitment = Commitment::new(&to_account_id, &to_account.account);
let from_init_nullifier = Nullifier::for_account_initialization(&from_account_id);
let to_init_nullifier = Nullifier::for_account_initialization(&to_account_id);
let mut state = V03State::new_with_genesis_accounts(
2025-11-20 19:25:56 -03:00
&[],
2026-04-07 13:35:13 -03:00
vec![
(from_commitment.clone(), from_init_nullifier),
(to_commitment.clone(), to_init_nullifier),
],
0,
2025-11-20 19:25:56 -03:00
)
.with_test_programs();
2025-11-20 01:40:05 -03:00
let amount: u128 = 37;
let instruction: (u128, ProgramId, u32, Option<PdaSeed>) = (
amount,
Program::authenticated_transfer_program().id(),
number_of_calls,
None,
);
2025-11-20 01:40:05 -03:00
let from_esk = [3; 32];
2026-01-21 17:27:23 -05:00
let from_ss = SharedSecretKey::new(&from_esk, &from_keys.vpk());
2025-11-22 17:48:29 -03:00
let from_epk = EphemeralPublicKey::from_scalar(from_esk);
2025-11-20 01:40:05 -03:00
2025-11-20 19:25:56 -03:00
let to_esk = [3; 32];
2026-01-21 17:27:23 -05:00
let to_ss = SharedSecretKey::new(&to_esk, &to_keys.vpk());
2025-11-22 17:48:29 -03:00
let to_epk = EphemeralPublicKey::from_scalar(to_esk);
2025-11-25 15:03:17 -03:00
2025-11-20 19:25:56 -03:00
let mut dependencies = HashMap::new();
2025-11-20 01:40:05 -03:00
2025-11-20 19:25:56 -03:00
dependencies.insert(auth_transfers.id(), auth_transfers);
let program_with_deps = ProgramWithDependencies::new(chain_caller, dependencies);
2026-03-18 10:28:52 -04:00
let from_new_nonce = Nonce::default().private_account_nonce_increment(&from_keys.nsk);
let to_new_nonce = Nonce::default().private_account_nonce_increment(&to_keys.nsk);
2025-11-22 17:48:29 -03:00
let from_expected_post = Account {
2026-03-03 23:21:08 +03:00
balance: initial_balance - u128::from(number_of_calls) * amount,
2025-11-22 17:48:29 -03:00
nonce: from_new_nonce,
..from_account.account.clone()
};
let from_expected_commitment = Commitment::new(&from_account_id, &from_expected_post);
2025-11-22 17:48:29 -03:00
let to_expected_post = Account {
2026-03-03 23:21:08 +03:00
balance: u128::from(number_of_calls) * amount,
2025-11-22 17:48:29 -03:00
nonce: to_new_nonce,
..to_account.account.clone()
};
let to_expected_commitment = Commitment::new(&to_account_id, &to_expected_post);
2025-11-22 17:48:29 -03:00
// Act
let (output, proof) = execute_and_prove(
vec![to_account, from_account],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: to_ss,
nsk: from_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&from_commitment)
.expect("from's commitment must be in state"),
identifier: 0,
},
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: from_ss,
nsk: to_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&to_commitment)
.expect("to's commitment must be in state"),
identifier: 0,
},
2025-11-20 19:25:56 -03:00
],
&program_with_deps,
2025-11-20 01:40:05 -03:00
)
.unwrap();
2025-11-20 19:25:56 -03:00
2025-11-22 17:48:29 -03:00
let message = Message::try_from_circuit_output(
vec![],
vec![],
vec![
2026-01-21 17:27:23 -05:00
(to_keys.npk(), to_keys.vpk(), to_epk),
(from_keys.npk(), from_keys.vpk(), from_epk),
2025-11-22 17:48:29 -03:00
],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
let transaction = PrivacyPreservingTransaction::new(message, witness_set);
state
.transition_from_privacy_preserving_transaction(&transaction, 1, 0)
2025-11-22 17:48:29 -03:00
.unwrap();
// Assert
assert!(
state
.get_proof_for_commitment(&from_expected_commitment)
.is_some()
);
assert!(
state
.get_proof_for_commitment(&to_expected_commitment)
.is_some()
);
}
#[test]
fn pda_mechanism_with_pinata_token_program() {
let pinata_token = Program::pinata_token();
let token = Program::token();
let pinata_definition_id = AccountId::new([1; 32]);
let pinata_token_definition_id = AccountId::new([2; 32]);
// Total supply of pinata token will be in an account under a PDA.
let pinata_token_holding_id =
AccountId::for_public_pda(&pinata_token.id(), &PdaSeed::new([0; 32]));
let winner_token_holding_id = AccountId::new([3; 32]);
let expected_winner_account_holding = token_core::TokenHolding::Fungible {
definition_id: pinata_token_definition_id,
balance: 150,
};
let expected_winner_token_holding_post = Account {
program_owner: token.id(),
data: Data::from(&expected_winner_account_holding),
..Account::default()
};
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
state.add_pinata_token_program(pinata_definition_id);
// Set up the token accounts directly (bypassing public transactions which
// would require signers for Claim::Authorized). The focus of this test is
// the PDA mechanism in the pinata program's chained call, not token creation.
let total_supply: u128 = 10_000_000;
let token_definition = token_core::TokenDefinition::Fungible {
name: String::from("PINATA"),
total_supply,
metadata_id: None,
};
let token_holding = token_core::TokenHolding::Fungible {
definition_id: pinata_token_definition_id,
balance: total_supply,
};
let winner_holding = token_core::TokenHolding::Fungible {
definition_id: pinata_token_definition_id,
balance: 0,
};
state.force_insert_account(
pinata_token_definition_id,
Account {
program_owner: token.id(),
data: Data::from(&token_definition),
..Account::default()
},
);
state.force_insert_account(
pinata_token_holding_id,
Account {
program_owner: token.id(),
data: Data::from(&token_holding),
..Account::default()
},
);
state.force_insert_account(
winner_token_holding_id,
Account {
program_owner: token.id(),
data: Data::from(&winner_holding),
..Account::default()
},
);
// Submit a solution to the pinata program to claim the prize
let solution: u128 = 989_106;
let message = public_transaction::Message::try_new(
pinata_token.id(),
vec![
pinata_definition_id,
pinata_token_holding_id,
winner_token_holding_id,
],
vec![],
solution,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
state.transition_from_public_transaction(&tx, 1, 0).unwrap();
let winner_token_holding_post = state.get_account_by_id(winner_token_holding_id);
assert_eq!(
winner_token_holding_post,
expected_winner_token_holding_post
);
}
#[test]
2026-03-04 18:42:33 +03:00
fn claiming_mechanism_cannot_claim_initialied_accounts() {
let claimer = Program::claimer();
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let account_id = AccountId::new([2; 32]);
// Insert an account with non-default program owner
state.force_insert_account(
account_id,
Account {
program_owner: [1, 2, 3, 4, 5, 6, 7, 8],
..Account::default()
},
);
let message =
public_transaction::Message::try_new(claimer.id(), vec![account_id], vec![], ())
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id }
)) if err_account_id == account_id
));
}
/// This test ensures that even if a malicious program tries to perform overflow of balances
/// it will not be able to break the balance validation.
#[test]
2026-03-04 18:42:33 +03:00
fn malicious_program_cannot_break_balance_validation() {
let sender_key = PrivateKey::try_new([37; 32]).unwrap();
let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key));
let sender_init_balance: u128 = 10;
let recipient_key = PrivateKey::try_new([42; 32]).unwrap();
let recipient_id = AccountId::from(&PublicKey::new_from_private_key(&recipient_key));
let recipient_init_balance: u128 = 10;
let mut state = V03State::new_with_genesis_accounts(
&[
(sender_id, sender_init_balance),
(recipient_id, recipient_init_balance),
],
2026-04-07 13:35:13 -03:00
vec![],
0,
);
state.insert_program(Program::modified_transfer_program());
let balance_to_move: u128 = 4;
let sender = AccountWithMetadata::new(state.get_account_by_id(sender_id), true, sender_id);
let sender_nonce = sender.account.nonce;
let _recipient =
AccountWithMetadata::new(state.get_account_by_id(recipient_id), false, sender_id);
let message = public_transaction::Message::try_new(
Program::modified_transfer_program().id(),
vec![sender_id, recipient_id],
vec![sender_nonce],
balance_to_move,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]);
let tx = PublicTransaction::new(message, witness_set);
let res = state.transition_from_public_transaction(&tx, 1, 0);
let expected_total_balance_pre_states = WrappedBalanceSum::from_balances(
[sender_init_balance, recipient_init_balance].into_iter(),
)
.unwrap();
let expected_total_balance_post_states = WrappedBalanceSum::from_balances(
[sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(),
)
.unwrap();
assert!(matches!(
res,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::ExecutionValidationFailed(
ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states }
)
)) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states
));
let sender_post = state.get_account_by_id(sender_id);
let recipient_post = state.get_account_by_id(recipient_id);
let expected_sender_post = {
let mut this = state.get_account_by_id(sender_id);
this.balance = sender_init_balance;
2026-03-18 10:28:52 -04:00
this.nonce = Nonce(0);
this
};
let expected_recipient_post = {
let mut this = state.get_account_by_id(sender_id);
this.balance = recipient_init_balance;
2026-03-18 10:28:52 -04:00
this.nonce = Nonce(0);
this
};
2025-12-18 18:45:57 -05:00
assert_eq!(expected_sender_post, sender_post);
assert_eq!(expected_recipient_post, recipient_post);
}
#[test]
2026-03-04 18:42:33 +03:00
fn private_authorized_uninitialized_account() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0);
// Set up keys for the authorized private account
let private_keys = test_private_account_keys_1();
// Create an authorized private account with default values (new account being initialized)
let authorized_account =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), true, (&private_keys.npk(), 0));
let program = Program::authenticated_transfer_program();
// Set up parameters for the new account
let esk = [3; 32];
2026-01-21 17:27:23 -05:00
let shared_secret = SharedSecretKey::new(&esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
// Balance to initialize the account with (0 for a new account)
let balance: u128 = 0;
// Execute and prove the circuit with the authorized account but no commitment proof
let (output, proof) = execute_and_prove(
vec![authorized_account],
Program::serialize_instruction(balance).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedInit {
ssk: shared_secret,
nsk: private_keys.nsk,
identifier: 0,
}],
&program.into(),
)
.unwrap();
// Create message from circuit output
let message = Message::try_from_circuit_output(
vec![],
vec![],
2026-01-21 17:27:23 -05:00
vec![(private_keys.npk(), private_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0);
assert!(result.is_ok());
let account_id = AccountId::from((&private_keys.npk(), 0));
let nullifier = Nullifier::for_account_initialization(&account_id);
assert!(state.private_state.1.contains(&nullifier));
}
#[test]
fn private_unauthorized_uninitialized_account_can_still_be_claimed() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let private_keys = test_private_account_keys_1();
// This is intentional: claim authorization was introduced to protect public accounts,
// especially PDAs. Private PDAs are not useful in practice because there is no way to
// operate them without the corresponding private keys, so unauthorized private claiming
// remains allowed.
let unauthorized_account =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), false, (&private_keys.npk(), 0));
let program = Program::claimer();
let esk = [5; 32];
let shared_secret = SharedSecretKey::new(&esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let (output, proof) = execute_and_prove(
vec![unauthorized_account],
Program::serialize_instruction(0_u128).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
npk: private_keys.npk(),
ssk: shared_secret,
identifier: 0,
}],
&program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![],
vec![],
vec![(private_keys.npk(), private_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
let account_id = AccountId::from((&private_keys.npk(), 0));
let nullifier = Nullifier::for_account_initialization(&account_id);
assert!(state.private_state.1.contains(&nullifier));
}
#[test]
2026-03-04 18:42:33 +03:00
fn private_account_claimed_then_used_without_init_flag_should_fail() {
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
// Set up keys for the private account
let private_keys = test_private_account_keys_1();
// Step 1: Create a new private account with authorization
let authorized_account =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), true, (&private_keys.npk(), 0));
let claimer_program = Program::claimer();
// Set up parameters for claiming the new account
let esk = [3; 32];
2026-01-21 17:27:23 -05:00
let shared_secret = SharedSecretKey::new(&esk, &private_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
let balance: u128 = 0;
// Step 2: Execute claimer program to claim the account with authentication
let (output, proof) = execute_and_prove(
vec![authorized_account.clone()],
Program::serialize_instruction(balance).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedInit {
ssk: shared_secret,
nsk: private_keys.nsk,
identifier: 0,
}],
&claimer_program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![],
vec![],
2026-01-21 17:27:23 -05:00
vec![(private_keys.npk(), private_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
// Claim should succeed
assert!(
state
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.is_ok()
);
// Verify the account is now initialized (nullifier exists)
let account_id = AccountId::from((&private_keys.npk(), 0));
let nullifier = Nullifier::for_account_initialization(&account_id);
assert!(state.private_state.1.contains(&nullifier));
// Prepare new state of account
let account_metadata = {
2026-03-09 18:27:56 +03:00
let mut acc = authorized_account;
acc.account.program_owner = Program::claimer().id();
acc
};
let noop_program = Program::noop();
let esk2 = [4; 32];
2026-01-21 17:27:23 -05:00
let shared_secret2 = SharedSecretKey::new(&esk2, &private_keys.vpk());
// Step 3: Try to execute noop program with authentication but without initialization
let res = execute_and_prove(
vec![account_metadata],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedInit {
ssk: shared_secret2,
nsk: private_keys.nsk,
identifier: 0,
}],
&noop_program.into(),
);
assert!(matches!(res, Err(NssaError::CircuitProvingError(_))));
}
#[test]
2026-03-04 18:42:33 +03:00
fn public_changer_claimer_no_data_change_no_claim_succeeds() {
let initial_data = [];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = Program::changer_claimer().id();
// Don't change data (None) and don't claim (false)
let instruction: (Option<Vec<u8>>, bool) = (None, false);
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Should succeed - no changes made, no claim needed
assert!(result.is_ok());
// Account should remain default/unclaimed
assert_eq!(state.get_account_by_id(account_id), Account::default());
}
#[test]
2026-03-04 18:42:33 +03:00
fn public_changer_claimer_data_change_no_claim_fails() {
let initial_data = [];
let mut state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let account_id = AccountId::new([1; 32]);
let program_id = Program::changer_claimer().id();
// Change data but don't claim (false) - should fail
let new_data = vec![1, 2, 3, 4, 5];
let instruction: (Option<Vec<u8>>, bool) = (Some(new_data), false);
let message =
public_transaction::Message::try_new(program_id, vec![account_id], vec![], instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Should fail - cannot modify data without claiming the account
assert!(matches!(
result,
Err(NssaError::InvalidProgramBehavior(
InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim {
account_id: err_account_id
}
)) if err_account_id == account_id
));
}
#[test]
2026-03-04 18:42:33 +03:00
fn private_changer_claimer_no_data_change_no_claim_succeeds() {
let program = Program::changer_claimer();
let sender_keys = test_private_account_keys_1();
let private_account =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), true, (&sender_keys.npk(), 0));
// Don't change data (None) and don't claim (false)
let instruction: (Option<Vec<u8>>, bool) = (None, false);
let result = execute_and_prove(
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[3; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
}],
&program.into(),
);
// Should succeed - no changes made, no claim needed
assert!(result.is_ok());
}
#[test]
2026-03-04 18:42:33 +03:00
fn private_changer_claimer_data_change_no_claim_fails() {
let program = Program::changer_claimer();
let sender_keys = test_private_account_keys_1();
let private_account =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), true, (&sender_keys.npk(), 0));
// Change data but don't claim (false) - should fail
let new_data = vec![1, 2, 3, 4, 5];
let instruction: (Option<Vec<u8>>, bool) = (Some(new_data), false);
let result = execute_and_prove(
vec![private_account],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: SharedSecretKey::new(&[3; 32], &sender_keys.vpk()),
nsk: sender_keys.nsk,
membership_proof: (0, vec![]),
identifier: 0,
}],
&program.into(),
);
// Should fail - cannot modify data without claiming the account
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
#[test]
2026-03-04 18:42:33 +03:00
fn malicious_authorization_changer_should_fail_in_privacy_preserving_circuit() {
// Arrange
let malicious_program = Program::malicious_authorization_changer();
let auth_transfers = Program::authenticated_transfer_program();
let sender_keys = test_public_account_keys_1();
let recipient_keys = test_private_account_keys_1();
let sender_account = AccountWithMetadata::new(
Account {
program_owner: auth_transfers.id(),
balance: 100,
..Default::default()
},
false,
sender_keys.account_id(),
);
let recipient_account =
2026-04-14 18:02:38 -03:00
AccountWithMetadata::new(Account::default(), true, (&recipient_keys.npk(), 0));
let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0));
let recipient_commitment =
Commitment::new(&recipient_account_id, &recipient_account.account);
let recipient_init_nullifier = Nullifier::for_account_initialization(&recipient_account_id);
let state = V03State::new_with_genesis_accounts(
&[(sender_account.account_id, sender_account.account.balance)],
2026-04-07 13:35:13 -03:00
vec![(recipient_commitment.clone(), recipient_init_nullifier)],
0,
)
.with_test_programs();
2026-03-04 18:42:33 +03:00
let balance_to_transfer = 10_u128;
let instruction = (balance_to_transfer, auth_transfers.id());
let recipient_esk = [3; 32];
2026-01-21 17:27:23 -05:00
let recipient = SharedSecretKey::new(&recipient_esk, &recipient_keys.vpk());
let mut dependencies = HashMap::new();
dependencies.insert(auth_transfers.id(), auth_transfers);
let program_with_deps = ProgramWithDependencies::new(malicious_program, dependencies);
// Act - execute the malicious program - this should fail during proving
let result = execute_and_prove(
vec![sender_account, recipient_account],
Program::serialize_instruction(instruction).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: recipient,
nsk: recipient_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&recipient_commitment)
.expect("recipient's commitment must be in state"),
identifier: 0,
},
],
&program_with_deps,
);
// Assert - should fail because the malicious program tries to manipulate is_authorized
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
2026-03-19 15:03:45 -03:00
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_public_transactions(
2026-03-20 13:16:52 -03:00
validity_window: (Option<BlockId>, Option<BlockId>),
block_id: BlockId,
) {
2026-03-28 03:13:46 -03:00
let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap();
2026-03-19 15:03:45 -03:00
let validity_window_program = Program::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
2026-03-19 15:03:45 -03:00
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
let program_id = validity_window_program.id();
2026-03-28 03:54:57 -03:00
let instruction = (
block_validity_window,
TimestampValidityWindow::new_unbounded(),
);
2026-03-24 11:49:15 +01:00
let message =
public_transaction::Message::try_new(program_id, account_ids, nonces, instruction)
.unwrap();
2026-03-19 15:03:45 -03:00
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
};
let result = state.transition_from_public_transaction(&tx, block_id, 0);
2026-03-28 03:54:57 -03:00
let is_inside_validity_window =
match (block_validity_window.start(), block_validity_window.end()) {
(Some(s), Some(e)) => s <= block_id && block_id < e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id < e,
(None, None) => true,
};
2026-03-19 15:03:45 -03:00
if is_inside_validity_window {
assert!(result.is_ok());
} else {
2026-03-19 18:50:45 -03:00
assert!(matches!(result, Err(NssaError::OutOfValidityWindow)));
2026-03-19 15:03:45 -03:00
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn timestamp_validity_window_works_in_public_transactions(
validity_window: (Option<Timestamp>, Option<Timestamp>),
2026-03-28 03:47:25 -03:00
timestamp: Timestamp,
) {
2026-03-28 03:13:46 -03:00
let timestamp_validity_window: TimestampValidityWindow =
validity_window.try_into().unwrap();
let validity_window_program = Program::validity_window();
let account_keys = test_public_account_keys_1();
let pre = AccountWithMetadata::new(Account::default(), false, account_keys.account_id());
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let account_ids = vec![pre.account_id];
let nonces = vec![];
let program_id = validity_window_program.id();
2026-03-28 03:54:57 -03:00
let instruction = (
BlockValidityWindow::new_unbounded(),
timestamp_validity_window,
);
let message =
public_transaction::Message::try_new(program_id, account_ids, nonces, instruction)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
};
2026-03-28 03:47:25 -03:00
let result = state.transition_from_public_transaction(&tx, 1, timestamp);
2026-03-28 03:54:57 -03:00
let is_inside_validity_window = match (
timestamp_validity_window.start(),
timestamp_validity_window.end(),
) {
(Some(s), Some(e)) => s <= timestamp && timestamp < e,
(Some(s), None) => s <= timestamp,
(None, Some(e)) => timestamp < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(NssaError::OutOfValidityWindow)));
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn validity_window_works_in_privacy_preserving_transactions(
2026-03-20 13:16:52 -03:00
validity_window: (Option<BlockId>, Option<BlockId>),
block_id: BlockId,
) {
2026-03-28 03:13:46 -03:00
let block_validity_window: BlockValidityWindow = validity_window.try_into().unwrap();
let validity_window_program = Program::validity_window();
let account_keys = test_private_account_keys_1();
2026-04-14 18:02:38 -03:00
let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
2026-03-28 03:54:57 -03:00
let instruction = (
block_validity_window,
TimestampValidityWindow::new_unbounded(),
);
let (output, proof) = circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
npk: account_keys.npk(),
ssk: shared_secret,
identifier: 0,
}],
&validity_window_program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![],
vec![],
vec![(account_keys.npk(), account_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
};
let result = state.transition_from_privacy_preserving_transaction(&tx, block_id, 0);
let is_inside_validity_window =
match (block_validity_window.start(), block_validity_window.end()) {
(Some(s), Some(e)) => s <= block_id && block_id < e,
(Some(s), None) => s <= block_id,
(None, Some(e)) => block_id < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
assert!(matches!(result, Err(NssaError::OutOfValidityWindow)));
}
}
#[test_case::test_case((Some(1), Some(3)), 3; "at upper bound")]
#[test_case::test_case((Some(1), Some(3)), 2; "inside range")]
#[test_case::test_case((Some(1), Some(3)), 0; "below range")]
#[test_case::test_case((Some(1), Some(3)), 1; "at lower bound")]
#[test_case::test_case((Some(1), Some(3)), 4; "above range")]
#[test_case::test_case((Some(1), None), 1; "lower bound only - at bound")]
#[test_case::test_case((Some(1), None), 10; "lower bound only - above")]
#[test_case::test_case((Some(1), None), 0; "lower bound only - below")]
#[test_case::test_case((None, Some(3)), 3; "upper bound only - at bound")]
#[test_case::test_case((None, Some(3)), 0; "upper bound only - below")]
#[test_case::test_case((None, Some(3)), 4; "upper bound only - above")]
#[test_case::test_case((None, None), 0; "no bounds - always valid")]
#[test_case::test_case((None, None), 100; "no bounds - always valid 2")]
fn timestamp_validity_window_works_in_privacy_preserving_transactions(
validity_window: (Option<Timestamp>, Option<Timestamp>),
2026-03-28 03:47:25 -03:00
timestamp: Timestamp,
) {
2026-03-28 03:13:46 -03:00
let timestamp_validity_window: TimestampValidityWindow =
validity_window.try_into().unwrap();
let validity_window_program = Program::validity_window();
let account_keys = test_private_account_keys_1();
2026-04-14 18:02:38 -03:00
let pre = AccountWithMetadata::new(Account::default(), false, (&account_keys.npk(), 0));
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
let tx = {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &account_keys.vpk());
let epk = EphemeralPublicKey::from_scalar(esk);
2026-03-28 03:54:57 -03:00
let instruction = (
BlockValidityWindow::new_unbounded(),
timestamp_validity_window,
);
let (output, proof) = circuit::execute_and_prove(
vec![pre],
Program::serialize_instruction(instruction).unwrap(),
vec![InputAccountIdentity::PrivateUnauthorized {
npk: account_keys.npk(),
ssk: shared_secret,
identifier: 0,
}],
&validity_window_program.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![],
vec![],
vec![(account_keys.npk(), account_keys.vpk(), epk)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
PrivacyPreservingTransaction::new(message, witness_set)
};
2026-03-28 03:47:25 -03:00
let result = state.transition_from_privacy_preserving_transaction(&tx, 1, timestamp);
2026-03-28 03:54:57 -03:00
let is_inside_validity_window = match (
timestamp_validity_window.start(),
timestamp_validity_window.end(),
) {
(Some(s), Some(e)) => s <= timestamp && timestamp < e,
(Some(s), None) => s <= timestamp,
(None, Some(e)) => timestamp < e,
(None, None) => true,
};
if is_inside_validity_window {
assert!(result.is_ok());
} else {
2026-03-19 18:50:45 -03:00
assert!(matches!(result, Err(NssaError::OutOfValidityWindow)));
}
}
fn time_locked_transfer_transaction(
from: AccountId,
from_key: &PrivateKey,
from_nonce: u128,
to: AccountId,
clock_account_id: AccountId,
amount: u128,
deadline: u64,
) -> PublicTransaction {
let program_id = Program::time_locked_transfer().id();
let message = public_transaction::Message::try_new(
program_id,
vec![from, to, clock_account_id],
vec![Nonce(from_nonce)],
(amount, deadline),
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[from_key]);
PublicTransaction::new(message, witness_set)
}
#[test]
fn time_locked_transfer_succeeds_when_deadline_has_passed() {
let recipient_id = AccountId::new([42; 32]);
let genesis_timestamp = 500_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], genesis_timestamp)
.with_test_programs();
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1));
state.force_insert_account(
sender_id,
Account {
program_owner: Program::time_locked_transfer().id(),
balance: 100,
..Account::default()
},
);
let amount = 100_u128;
// Deadline in the past: transfer should succeed.
let deadline = 0_u64;
let tx = time_locked_transfer_transaction(
sender_id,
&key1,
0,
recipient_id,
CLOCK_01_PROGRAM_ACCOUNT_ID,
amount,
deadline,
);
let block_id = 1;
let timestamp = genesis_timestamp + 100;
state
.transition_from_public_transaction(&tx, block_id, timestamp)
.unwrap();
// Balances changed.
assert_eq!(state.get_account_by_id(sender_id).balance, 0);
assert_eq!(state.get_account_by_id(recipient_id).balance, 100);
}
#[test]
fn time_locked_transfer_fails_when_deadline_is_in_the_future() {
let recipient_id = AccountId::new([42; 32]);
let genesis_timestamp = 500_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(recipient_id, 0)], vec![], genesis_timestamp)
.with_test_programs();
let key1 = PrivateKey::try_new([1; 32]).unwrap();
let sender_id = AccountId::from(&PublicKey::new_from_private_key(&key1));
state.force_insert_account(
sender_id,
Account {
program_owner: Program::time_locked_transfer().id(),
balance: 100,
..Account::default()
},
);
let amount = 100_u128;
// Far-future deadline: program should panic.
let deadline = u64::MAX;
let tx = time_locked_transfer_transaction(
sender_id,
&key1,
0,
recipient_id,
CLOCK_01_PROGRAM_ACCOUNT_ID,
amount,
deadline,
);
let block_id = 1;
let timestamp = genesis_timestamp + 100;
let result = state.transition_from_public_transaction(&tx, block_id, timestamp);
assert!(
result.is_err(),
"Transfer should fail when deadline is in the future"
);
// Balances unchanged.
assert_eq!(state.get_account_by_id(sender_id).balance, 100);
assert_eq!(state.get_account_by_id(recipient_id).balance, 0);
}
fn pinata_cooldown_data(prize: u128, cooldown_ms: u64, last_claim_timestamp: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(32);
buf.extend_from_slice(&prize.to_le_bytes());
buf.extend_from_slice(&cooldown_ms.to_le_bytes());
buf.extend_from_slice(&last_claim_timestamp.to_le_bytes());
buf
}
fn pinata_cooldown_transaction(
pinata_id: AccountId,
winner_id: AccountId,
clock_account_id: AccountId,
) -> PublicTransaction {
let program_id = Program::pinata_cooldown().id();
let message = public_transaction::Message::try_new(
program_id,
vec![pinata_id, winner_id, clock_account_id],
vec![],
(),
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
PublicTransaction::new(message, witness_set)
}
#[test]
fn pinata_cooldown_claim_succeeds_after_cooldown() {
let winner_id = AccountId::new([11; 32]);
let pinata_id = AccountId::new([99; 32]);
let genesis_timestamp = 1000_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(winner_id, 0)], vec![], genesis_timestamp)
.with_test_programs();
let prize = 50_u128;
let cooldown_ms = 500_u64;
// Last claim was at genesis, so any timestamp >= genesis + cooldown should work.
let last_claim_timestamp = genesis_timestamp;
state.force_insert_account(
pinata_id,
Account {
program_owner: Program::pinata_cooldown().id(),
balance: 1000,
data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp)
.try_into()
.unwrap(),
..Account::default()
},
);
let tx = pinata_cooldown_transaction(pinata_id, winner_id, CLOCK_01_PROGRAM_ACCOUNT_ID);
let block_id = 1;
let block_timestamp = genesis_timestamp + cooldown_ms;
// Advance clock so the cooldown check reads an updated timestamp.
let clock_tx = clock_transaction(block_timestamp);
state
.transition_from_public_transaction(&clock_tx, block_id, block_timestamp)
.unwrap();
state
.transition_from_public_transaction(&tx, block_id, block_timestamp)
.unwrap();
assert_eq!(state.get_account_by_id(pinata_id).balance, 1000 - prize);
assert_eq!(state.get_account_by_id(winner_id).balance, prize);
}
#[test]
fn pinata_cooldown_claim_fails_during_cooldown() {
let winner_id = AccountId::new([11; 32]);
let pinata_id = AccountId::new([99; 32]);
let genesis_timestamp = 1000_u64;
let mut state =
V03State::new_with_genesis_accounts(&[(winner_id, 0)], vec![], genesis_timestamp)
.with_test_programs();
let prize = 50_u128;
let cooldown_ms = 500_u64;
let last_claim_timestamp = genesis_timestamp;
state.force_insert_account(
pinata_id,
Account {
balance: 1000,
data: pinata_cooldown_data(prize, cooldown_ms, last_claim_timestamp)
.try_into()
.unwrap(),
..Account::default()
},
);
let tx = pinata_cooldown_transaction(pinata_id, winner_id, CLOCK_01_PROGRAM_ACCOUNT_ID);
let block_id = 1;
// Timestamp is only 100ms after last claim, well within the 500ms cooldown.
let block_timestamp = genesis_timestamp + 100;
let clock_tx = clock_transaction(block_timestamp);
state
.transition_from_public_transaction(&clock_tx, block_id, block_timestamp)
.unwrap();
let result = state.transition_from_public_transaction(&tx, block_id, block_timestamp);
assert!(result.is_err(), "Claim should fail during cooldown period");
assert_eq!(state.get_account_by_id(pinata_id).balance, 1000);
assert_eq!(state.get_account_by_id(winner_id).balance, 0);
}
#[test]
fn state_serialization_roundtrip() {
let account_id_1 = AccountId::new([1; 32]);
let account_id_2 = AccountId::new([2; 32]);
let initial_data = [(account_id_1, 100_u128), (account_id_2, 151_u128)];
let state =
V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs();
let bytes = borsh::to_vec(&state).unwrap();
let state_from_bytes: V03State = borsh::from_slice(&bytes).unwrap();
assert_eq!(state, state_from_bytes);
}
2026-04-02 21:17:21 +02:00
#[test]
fn flash_swap_successful() {
let initiator = Program::flash_swap_initiator();
let callback = Program::flash_swap_callback();
let token = Program::authenticated_transfer_program();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32]));
2026-04-02 21:17:21 +02:00
let initial_balance: u128 = 1000;
let amount_out: u128 = 100;
let vault_account = Account {
program_owner: token.id(),
balance: initial_balance,
..Account::default()
};
let receiver_account = Account {
program_owner: token.id(),
balance: 0,
..Account::default()
};
2026-04-10 17:15:23 -03:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
2026-04-02 21:17:21 +02:00
state.force_insert_account(vault_id, vault_account);
state.force_insert_account(receiver_id, receiver_account);
// Callback instruction: return funds
let cb_instruction = CallbackInstruction {
return_funds: true,
token_program_id: token.id(),
amount: amount_out,
};
let cb_data = Program::serialize_instruction(cb_instruction).unwrap();
let instruction = FlashSwapInstruction::Initiate {
token_program_id: token.id(),
callback_program_id: callback.id(),
amount_out,
callback_instruction_data: cb_data,
};
let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(result.is_ok(), "flash swap should succeed: {result:?}");
// Vault balance restored, receiver back to 0
assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance);
assert_eq!(state.get_account_by_id(receiver_id).balance, 0);
}
#[test]
fn flash_swap_callback_keeps_funds_rollback() {
let initiator = Program::flash_swap_initiator();
let callback = Program::flash_swap_callback();
let token = Program::authenticated_transfer_program();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32]));
2026-04-02 21:17:21 +02:00
let initial_balance: u128 = 1000;
let amount_out: u128 = 100;
let vault_account = Account {
program_owner: token.id(),
balance: initial_balance,
..Account::default()
};
let receiver_account = Account {
program_owner: token.id(),
balance: 0,
..Account::default()
};
2026-04-10 17:15:23 -03:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
2026-04-02 21:17:21 +02:00
state.force_insert_account(vault_id, vault_account);
state.force_insert_account(receiver_id, receiver_account);
// Callback instruction: do NOT return funds
let cb_instruction = CallbackInstruction {
return_funds: false,
token_program_id: token.id(),
amount: amount_out,
};
let cb_data = Program::serialize_instruction(cb_instruction).unwrap();
let instruction = FlashSwapInstruction::Initiate {
token_program_id: token.id(),
callback_program_id: callback.id(),
amount_out,
callback_instruction_data: cb_data,
};
let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction);
let result = state.transition_from_public_transaction(&tx, 1, 0);
// Invariant check fails → entire tx rolls back
assert!(
result.is_err(),
"flash swap should fail when callback keeps funds"
);
2026-04-02 21:17:21 +02:00
// State unchanged (rollback)
assert_eq!(state.get_account_by_id(vault_id).balance, initial_balance);
assert_eq!(state.get_account_by_id(receiver_id).balance, 0);
}
#[test]
fn flash_swap_self_call_targets_correct_program() {
// Zero-amount flash swap: the invariant self-call still runs and succeeds
// because vault balance doesn't decrease.
let initiator = Program::flash_swap_initiator();
let callback = Program::flash_swap_callback();
let token = Program::authenticated_transfer_program();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32]));
2026-04-02 21:17:21 +02:00
let initial_balance: u128 = 1000;
let vault_account = Account {
program_owner: token.id(),
balance: initial_balance,
..Account::default()
};
let receiver_account = Account {
program_owner: token.id(),
balance: 0,
..Account::default()
};
2026-04-10 17:15:23 -03:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
2026-04-02 21:17:21 +02:00
state.force_insert_account(vault_id, vault_account);
state.force_insert_account(receiver_id, receiver_account);
let cb_instruction = CallbackInstruction {
return_funds: true,
token_program_id: token.id(),
amount: 0,
};
let cb_data = Program::serialize_instruction(cb_instruction).unwrap();
let instruction = FlashSwapInstruction::Initiate {
token_program_id: token.id(),
callback_program_id: callback.id(),
amount_out: 0,
callback_instruction_data: cb_data,
};
let tx = build_flash_swap_tx(&initiator, vault_id, receiver_id, instruction);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_ok(),
"zero-amount flash swap should succeed: {result:?}"
);
2026-04-02 21:17:21 +02:00
}
#[test]
fn flash_swap_standalone_invariant_check_rejected() {
// Calling InvariantCheck directly (not as a chained self-call) should fail
// because caller_program_id will be None.
let initiator = Program::flash_swap_initiator();
let token = Program::authenticated_transfer_program();
let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32]));
2026-04-02 21:17:21 +02:00
let vault_account = Account {
program_owner: token.id(),
balance: 1000,
..Account::default()
};
2026-04-10 17:15:23 -03:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
2026-04-02 21:17:21 +02:00
state.force_insert_account(vault_id, vault_account);
let instruction = FlashSwapInstruction::InvariantCheck {
min_vault_balance: 1000,
};
let message = public_transaction::Message::try_new(
initiator.id(),
vec![vault_id],
vec![],
instruction,
)
.unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_err(),
"standalone InvariantCheck should be rejected (caller_program_id is None)"
);
}
#[test]
fn malicious_self_program_id_rejected_in_public_execution() {
let program = Program::malicious_self_program_id();
let acc_id = AccountId::new([99; 32]);
let account = Account::default();
2026-04-10 17:15:23 -03:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
state.force_insert_account(acc_id, account);
let message =
public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_err(),
"program with wrong self_program_id in output should be rejected"
);
}
#[test]
fn malicious_caller_program_id_rejected_in_public_execution() {
let program = Program::malicious_caller_program_id();
let acc_id = AccountId::new([99; 32]);
let account = Account::default();
2026-04-10 17:15:23 -03:00
let mut state = V03State::new_with_genesis_accounts(&[], vec![], 0).with_test_programs();
state.force_insert_account(acc_id, account);
let message =
public_transaction::Message::try_new(program.id(), vec![acc_id], vec![], ()).unwrap();
let witness_set = public_transaction::WitnessSet::for_message(&message, &[]);
let tx = PublicTransaction::new(message, witness_set);
let result = state.transition_from_public_transaction(&tx, 1, 0);
assert!(
result.is_err(),
"program with spoofed caller_program_id in output should be rejected"
);
}
2026-05-07 00:54:01 -03:00
#[test]
fn two_private_pda_family_members_receive_and_spend() {
let funder_keys = test_public_account_keys_1();
let alice_keys = test_private_account_keys_1();
let alice_npk = alice_keys.npk();
let proxy = Program::auth_transfer_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let proxy_id = proxy.id();
let auth_transfer_id = auth_transfer.id();
let seed = PdaSeed::new([42; 32]);
let amount: u128 = 100;
let program_with_deps =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
let funder_id = funder_keys.account_id();
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0);
let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 1);
let recipient_id = test_public_account_keys_2().account_id();
let recipient_signing_key = test_public_account_keys_2().signing_key;
let mut state = V03State::new_with_genesis_accounts(&[(funder_id, 500)], vec![], 0);
let alice_pda_0_account = Account {
program_owner: auth_transfer_id,
balance: amount,
nonce: Nonce::private_account_nonce_init(&alice_pda_0_id),
..Account::default()
};
let alice_pda_1_account = Account {
program_owner: auth_transfer_id,
balance: amount,
nonce: Nonce::private_account_nonce_init(&alice_pda_1_id),
..Account::default()
};
let alice_shared_0 = SharedSecretKey::new(&[10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new(&[11; 32], &alice_keys.vpk());
2026-05-07 01:41:35 -03:00
// Fund alice_pda_0
2026-05-07 00:54:01 -03:00
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(funder_account, true, funder_id),
AccountWithMetadata::new(Account::default(), false, alice_pda_0_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, true)).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk: alice_npk,
ssk: alice_shared_0.clone(),
identifier: 0,
},
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
2026-05-07 01:41:35 -03:00
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
2026-05-07 00:54:01 -03:00
output,
)
.unwrap();
2026-05-07 01:41:35 -03:00
let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
2026-05-07 00:54:01 -03:00
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
1,
0,
)
.unwrap();
}
2026-05-07 01:41:35 -03:00
// Fund alice_pda_1
2026-05-07 00:54:01 -03:00
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(funder_account, true, funder_id),
AccountWithMetadata::new(Account::default(), false, alice_pda_1_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, true)).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk: alice_npk,
ssk: alice_shared_1.clone(),
identifier: 1,
},
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
2026-05-07 01:41:35 -03:00
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
2026-05-07 00:54:01 -03:00
output,
)
.unwrap();
2026-05-07 01:41:35 -03:00
let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
2026-05-07 00:54:01 -03:00
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
2,
0,
)
.unwrap();
}
let commitment_pda_0 = Commitment::new(&alice_pda_0_id, &alice_pda_0_account);
let commitment_pda_1 = Commitment::new(&alice_pda_1_id, &alice_pda_1_account);
assert!(state.get_proof_for_commitment(&commitment_pda_0).is_some());
assert!(state.get_proof_for_commitment(&commitment_pda_1).is_some());
// Alice spends alice_pda_0 into the public recipient.
{
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id),
AccountWithMetadata::new(recipient_account, true, recipient_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk: alice_shared_0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_0)
.expect("pda_0 must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![Nonce(0)],
2026-05-07 01:41:35 -03:00
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
2026-05-07 00:54:01 -03:00
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
3,
0,
)
.unwrap();
}
// Alice spends alice_pda_1 into the same public recipient.
{
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_1_account, true, alice_pda_1_id),
AccountWithMetadata::new(recipient_account, false, recipient_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk: alice_shared_1,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1)
.expect("pda_1 must be in state"),
identifier: 1,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![],
2026-05-07 01:41:35 -03:00
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
2026-05-07 00:54:01 -03:00
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
4,
0,
)
.unwrap();
}
assert_eq!(state.get_account_by_id(recipient_id).balance, 2 * amount);
}
2025-08-13 01:33:11 -03:00
}