add tests

This commit is contained in:
Sergio Chouhy 2026-05-05 20:27:17 -03:00
parent 11949e9fa1
commit 1599fc655c
43 changed files with 564 additions and 6 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -26,6 +26,7 @@ pub mod test_context_ffi;
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin";
pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin";
pub const NSSA_PROGRAM_FOR_TEST_AUTH_TRANSFER_PROXY: &str = "auth_transfer_proxy.bin";
const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0";
const BEDROCK_SERVICE_PORT: u16 = 18080;

View File

@ -0,0 +1,291 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result};
use integration_tests::{
NSSA_PROGRAM_FOR_TEST_AUTH_TRANSFER_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext,
verify_commitment_is_in_state,
};
use log::info;
use nssa::{
AccountId,
privacy_preserving_transaction::circuit::ProgramWithDependencies,
program::Program,
};
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey, program::PdaSeed};
use tokio::test;
use wallet::{PrivacyPreservingAccount, WalletCore};
use wallet::cli::{Command, account::AccountSubcommand};
/// Funds a private PDA via auth_transfer directly (no proxy).
///
/// The PDA is foreign: the wallet knows its account_id/npk/vpk but not the nsk.
/// auth_transfer claims the uninitialized PDA with Claim::Authorized on the first receive.
async fn fund_private_pda(
wallet: &WalletCore,
sender: AccountId,
pda_account_id: AccountId,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: u128,
amount: u128,
auth_transfer: &ProgramWithDependencies,
) -> Result<()> {
wallet
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(sender),
PrivacyPreservingAccount::PrivatePdaForeign {
account_id: pda_account_id,
npk,
vpk,
identifier,
},
],
Program::serialize_instruction(amount)
.context("failed to serialize auth_transfer instruction")?,
auth_transfer,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
/// Spends from an owned private PDA to a fresh private-foreign recipient.
///
/// Alice must own the PDA in the wallet (i.e. it must have been synced after a receive).
async fn spend_private_pda(
wallet: &WalletCore,
pda_account_id: AccountId,
recipient_npk: NullifierPublicKey,
recipient_vpk: ViewingPublicKey,
seed: PdaSeed,
amount: u128,
spend_program: &ProgramWithDependencies,
auth_transfer_id: nssa::ProgramId,
) -> Result<()> {
wallet
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivatePdaOwned(pda_account_id),
PrivacyPreservingAccount::PrivateForeign {
npk: recipient_npk,
vpk: recipient_vpk,
identifier: 0,
},
],
Program::serialize_instruction((seed, amount, auth_transfer_id))
.context("failed to serialize auth_transfer_proxy instruction")?,
spend_program,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
/// Two private transfers go to distinct members of the same PDA family (same seed and npk,
/// but identifier=0 and identifier=1). Alice then spends from both PDAs.
///
/// This exercises the full identifier-diversified private PDA lifecycle:
/// receive(id=0), receive(id=1) → sync → spend(id=0), spend(id=1) → sync → assert.
#[test]
async fn private_pda_family_members_receive_and_spend() -> Result<()> {
let mut ctx = TestContext::new().await?;
// ── Build alice's key chain ──────────────────────────────────────────────────────────────────
let alice_chain_index = ctx.wallet_mut().create_private_accounts_key(None);
let (alice_npk, alice_vpk) = {
let node = ctx
.wallet()
.storage()
.user_data
.private_key_tree
.key_map
.get(&alice_chain_index)
.context("key node was just inserted")?;
let kc = &node.value.0;
(kc.nullifier_public_key, kc.viewing_public_key.clone())
};
let proxy = {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../artifacts/test_program_methods")
.join(NSSA_PROGRAM_FOR_TEST_AUTH_TRANSFER_PROXY);
Program::new(std::fs::read(&path).with_context(|| format!("reading {path:?}"))?)
.context("invalid auth_transfer_proxy binary")?
};
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 auth_transfer_program = ProgramWithDependencies::new(auth_transfer.clone(), [].into());
let spend_program =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
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);
// Use two different public senders to avoid nonce conflicts between the back-to-back txs.
let senders = ctx.existing_public_accounts();
let sender_0 = senders[0];
let sender_1 = senders[1];
// ── Receive ──────────────────────────────────────────────────────────────────────────────────
info!("Sending to alice_pda_0 (identifier=0)");
fund_private_pda(
ctx.wallet(),
sender_0,
alice_pda_0_id,
alice_npk,
alice_vpk.clone(),
0,
amount,
&auth_transfer_program,
)
.await?;
info!("Sending to alice_pda_1 (identifier=1)");
fund_private_pda(
ctx.wallet(),
sender_1,
alice_pda_1_id,
alice_npk,
alice_vpk.clone(),
1,
amount,
&auth_transfer_program,
)
.await?;
info!("Waiting for block");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync so alice's wallet discovers and stores both PDAs.
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
// Both PDAs must be discoverable and have the correct balance.
let pda_0_account = ctx
.wallet()
.get_account_private(alice_pda_0_id)
.context("alice_pda_0 not found after sync")?;
assert_eq!(pda_0_account.balance, amount);
let pda_1_account = ctx
.wallet()
.get_account_private(alice_pda_1_id)
.context("alice_pda_1 not found after sync")?;
assert_eq!(pda_1_account.balance, amount);
// Commitments for both PDAs must be in the sequencer's state.
let commitment_0 = ctx
.wallet()
.get_private_account_commitment(alice_pda_0_id)
.context("commitment for alice_pda_0 missing")?;
assert!(
verify_commitment_is_in_state(commitment_0.clone(), ctx.sequencer_client()).await,
"alice_pda_0 commitment not in state after receive"
);
let commitment_1 = ctx
.wallet()
.get_private_account_commitment(alice_pda_1_id)
.context("commitment for alice_pda_1 missing")?;
assert!(
verify_commitment_is_in_state(commitment_1.clone(), ctx.sequencer_client()).await,
"alice_pda_1 commitment not in state after receive"
);
assert_ne!(commitment_0, commitment_1, "distinct identifiers must yield distinct commitments");
// ── Spend ─────────────────────────────────────────────────────────────────────────────────────
// Fresh recipients — hardcoded npks not in any wallet.
let recipient_npk_0 = NullifierPublicKey([0xAA; 32]);
let recipient_vpk_0 = ViewingPublicKey::from_scalar(recipient_npk_0.0);
let recipient_npk_1 = NullifierPublicKey([0xBB; 32]);
let recipient_vpk_1 = ViewingPublicKey::from_scalar(recipient_npk_1.0);
let amount_spend_0: u128 = 13;
let amount_spend_1: u128 = 37;
info!("Alice spending from alice_pda_0");
spend_private_pda(
ctx.wallet(),
alice_pda_0_id,
recipient_npk_0,
recipient_vpk_0,
seed,
amount_spend_0,
&spend_program,
auth_transfer_id,
)
.await?;
info!("Alice spending from alice_pda_1");
spend_private_pda(
ctx.wallet(),
alice_pda_1_id,
recipient_npk_1,
recipient_vpk_1,
seed,
amount_spend_1,
&spend_program,
auth_transfer_id,
)
.await?;
info!("Waiting for block");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
// After spending, PDAs should have the remaining balance.
let pda_0_spent = ctx
.wallet()
.get_account_private(alice_pda_0_id)
.context("alice_pda_0 not found after spend sync")?;
assert_eq!(pda_0_spent.balance, amount - amount_spend_0);
let pda_1_spent = ctx
.wallet()
.get_account_private(alice_pda_1_id)
.context("alice_pda_1 not found after spend sync")?;
assert_eq!(pda_1_spent.balance, amount - amount_spend_1);
// Post-spend commitments must be in state.
let post_spend_commitment_0 = ctx
.wallet()
.get_private_account_commitment(alice_pda_0_id)
.context("post-spend commitment for alice_pda_0 missing")?;
assert!(
verify_commitment_is_in_state(post_spend_commitment_0, ctx.sequencer_client()).await,
"alice_pda_0 post-spend commitment not in state"
);
let post_spend_commitment_1 = ctx
.wallet()
.get_private_account_commitment(alice_pda_1_id)
.context("post-spend commitment for alice_pda_1 missing")?;
assert!(
verify_commitment_is_in_state(post_spend_commitment_1, ctx.sequencer_client()).await,
"alice_pda_1 post-spend commitment not in state"
);
info!("Private PDA family member receive-and-spend test passed");
Ok(())
}

View File

@ -923,6 +923,23 @@ mod tests {
);
}
/// Different identifiers produce different addresses for the same (program_id, seed, npk),
/// confirming that each (program_id, seed, npk) controls a family of 2^128 addresses.
#[test]
fn for_private_pda_differs_for_different_identifier() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, 1),
);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX),
);
}
/// A private PDA at the same (program, seed) has a different address than a public PDA,
/// because the private formula uses a different prefix and includes npk.
#[test]

View File

@ -186,6 +186,8 @@ mod tests {
use nssa_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::PrivateAccountKind,
program::PdaSeed,
};
use super::*;
@ -411,4 +413,192 @@ mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// A private PDA claimed with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_claim_with_custom_identifier_encrypts_correct_kind() {
let program = Program::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let (output, _proof) = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![3],
vec![(npk, identifier, shared_secret.clone())],
vec![],
vec![None],
&program.clone().into(),
)
.unwrap();
let commitment = output.new_commitments[0].clone();
let (kind, _account) =
EncryptionScheme::decrypt(&output.ciphertexts[0], &shared_secret, &commitment, 0)
.unwrap();
assert_eq!(
kind,
PrivateAccountKind::Pda { program_id: program.id(), seed, identifier },
);
}
/// A private PDA family has two members (identifier=0 and identifier=1, same seed/npk).
/// Each is funded in a separate transaction; commitments must be distinct and ciphertexts
/// must carry the correct `PrivateAccountKind::Pda { identifier }`. Alice then spends both.
#[test]
fn two_private_pda_family_members_receive_and_spend() {
let alice_keys = test_private_account_keys_1();
let alice_npk = alice_keys.npk();
let recipient_keys = test_private_account_keys_2();
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 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);
// Public funder account: already owned by auth_transfer so its balance can be debited.
let funder = AccountWithMetadata::new(
Account { program_owner: auth_transfer_id, balance: 500, ..Account::default() },
true,
AccountId::new([0xAB; 32]),
);
let alice_shared_0 = SharedSecretKey::new(&[10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new(&[11; 32], &alice_keys.vpk());
// ── Receive 0: fund alice_pda_0 (identifier = 0) ────────────────────────────────────────
let (output_recv_0, _) = execute_and_prove(
vec![
AccountWithMetadata::new(Account::default(), false, alice_pda_0_id),
funder.clone(),
],
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
vec![3, 0],
vec![(alice_npk, 0, alice_shared_0.clone())],
vec![],
vec![None],
&program_with_deps,
)
.unwrap();
assert_eq!(output_recv_0.new_commitments.len(), 1);
let commitment_pda_0 = output_recv_0.new_commitments[0].clone();
let (kind_0, alice_pda_0_account) = EncryptionScheme::decrypt(
&output_recv_0.ciphertexts[0],
&alice_shared_0,
&commitment_pda_0,
0,
)
.unwrap();
assert_eq!(
kind_0,
PrivateAccountKind::Pda { program_id: proxy_id, seed, identifier: 0 },
);
assert_eq!(alice_pda_0_account.balance, amount);
// ── Receive 1: fund alice_pda_1 (identifier = 1, same seed) ─────────────────────────────
let (output_recv_1, _) = execute_and_prove(
vec![
AccountWithMetadata::new(Account::default(), false, alice_pda_1_id),
funder.clone(),
],
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
vec![3, 0],
vec![(alice_npk, 1, alice_shared_1.clone())],
vec![],
vec![None],
&program_with_deps,
)
.unwrap();
assert_eq!(output_recv_1.new_commitments.len(), 1);
let commitment_pda_1 = output_recv_1.new_commitments[0].clone();
let (kind_1, alice_pda_1_account) = EncryptionScheme::decrypt(
&output_recv_1.ciphertexts[0],
&alice_shared_1,
&commitment_pda_1,
0,
)
.unwrap();
assert_eq!(
kind_1,
PrivateAccountKind::Pda { program_id: proxy_id, seed, identifier: 1 },
);
assert_eq!(alice_pda_1_account.balance, amount);
// Different identifiers produce distinct commitments.
assert_ne!(commitment_pda_0, commitment_pda_1);
// ── Spend 0: alice spends alice_pda_0 ───────────────────────────────────────────────────
let mut cs_0 = CommitmentSet::with_capacity(1);
cs_0.extend(std::slice::from_ref(&commitment_pda_0));
let proof_pda_0 = cs_0.get_proof_for(&commitment_pda_0);
let recipient_0_id = AccountId::from((&recipient_keys.npk(), 0u128));
let (output_spend_0, _) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id),
AccountWithMetadata::new(Account::default(), false, recipient_0_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
vec![3, 2],
vec![
(alice_npk, 0, alice_shared_0.clone()),
(recipient_keys.npk(), 0, SharedSecretKey::new(&[20; 32], &recipient_keys.vpk())),
],
vec![alice_keys.nsk],
vec![proof_pda_0, None],
&program_with_deps,
)
.unwrap();
assert_eq!(output_spend_0.new_commitments.len(), 2);
assert_eq!(output_spend_0.new_nullifiers.len(), 2);
// ── Spend 1: alice spends alice_pda_1 ───────────────────────────────────────────────────
let mut cs_1 = CommitmentSet::with_capacity(1);
cs_1.extend(std::slice::from_ref(&commitment_pda_1));
let proof_pda_1 = cs_1.get_proof_for(&commitment_pda_1);
let recipient_1_id = AccountId::from((&recipient_keys.npk(), 1u128));
let (output_spend_1, _) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_1_account, true, alice_pda_1_id),
AccountWithMetadata::new(Account::default(), false, recipient_1_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
vec![3, 2],
vec![
(alice_npk, 1, alice_shared_1.clone()),
(recipient_keys.npk(), 1, SharedSecretKey::new(&[21; 32], &recipient_keys.vpk())),
],
vec![alice_keys.nsk],
vec![proof_pda_1, None],
&program_with_deps,
)
.unwrap();
assert_eq!(output_spend_1.new_commitments.len(), 2);
assert_eq!(output_spend_1.new_nullifiers.len(), 2);
}
}

View File

@ -168,6 +168,7 @@ impl Program {
elf: PINATA_TOKEN_ELF.to_vec(),
}
}
}
#[cfg(test)]
@ -322,6 +323,17 @@ mod tests {
}
}
#[must_use]
pub fn auth_transfer_proxy() -> Self {
use test_program_methods::{AUTH_TRANSFER_PROXY_ELF, AUTH_TRANSFER_PROXY_ID};
Self {
id: AUTH_TRANSFER_PROXY_ID,
elf: AUTH_TRANSFER_PROXY_ELF.to_vec(),
}
}
#[must_use]
pub fn changer_claimer() -> Self {
use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID};

View File

@ -356,12 +356,7 @@ impl ExecutionState {
},
3 => {
match claim {
Claim::Authorized => {
assert!(
pre_is_authorized,
"Cannot claim unauthorized private PDA {pre_account_id}"
);
}
Claim::Authorized => {}
Claim::Pda(seed) => {
let (npk, identifier) = self
.private_pda_npk_by_position

View File

@ -0,0 +1,52 @@
use nssa_core::program::{
AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
};
use risc0_zkvm::serde::to_vec;
/// Spends from a private PDA by proxying the debit through auth_transfer.
///
/// pre_states[0] = the private PDA (must be authorized)
/// pre_states[1] = the recipient
///
/// The PDA-to-npk binding is established via `pda_seeds` in the chained call to auth_transfer.
/// Funding a PDA is done by calling auth_transfer directly (no proxy needed).
type Instruction = (PdaSeed, u128, ProgramId);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (seed, amount, auth_transfer_id),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([pda, recipient]) = <[_; 2]>::try_from(pre_states) else {
return;
};
assert!(pda.is_authorized, "PDA must be authorized");
let pda_post = AccountPostState::new(pda.account.clone());
let recipient_post = AccountPostState::new(recipient.account.clone());
let chained_call = ChainedCall {
program_id: auth_transfer_id,
instruction_data: to_vec(&amount).unwrap(),
pre_states: vec![pda.clone(), recipient.clone()],
pda_seeds: vec![seed],
};
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![pda, recipient],
vec![pda_post, recipient_post],
)
.with_chained_calls(vec![chained_call])
.write();
}