diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 002de9c4..7a3ba9a9 100644 Binary files a/artifacts/program_methods/amm.bin and b/artifacts/program_methods/amm.bin differ diff --git a/artifacts/program_methods/associated_token_account.bin b/artifacts/program_methods/associated_token_account.bin index 93fa5293..ebd5e406 100644 Binary files a/artifacts/program_methods/associated_token_account.bin and b/artifacts/program_methods/associated_token_account.bin differ diff --git a/artifacts/program_methods/authenticated_transfer.bin b/artifacts/program_methods/authenticated_transfer.bin index 8571c878..b8fc2da1 100644 Binary files a/artifacts/program_methods/authenticated_transfer.bin and b/artifacts/program_methods/authenticated_transfer.bin differ diff --git a/artifacts/program_methods/clock.bin b/artifacts/program_methods/clock.bin index 6053f4e9..84c4de29 100644 Binary files a/artifacts/program_methods/clock.bin and b/artifacts/program_methods/clock.bin differ diff --git a/artifacts/program_methods/pinata.bin b/artifacts/program_methods/pinata.bin index 0e68bd74..820bd198 100644 Binary files a/artifacts/program_methods/pinata.bin and b/artifacts/program_methods/pinata.bin differ diff --git a/artifacts/program_methods/pinata_token.bin b/artifacts/program_methods/pinata_token.bin index ccd0dfd6..a9a80ec5 100644 Binary files a/artifacts/program_methods/pinata_token.bin and b/artifacts/program_methods/pinata_token.bin differ diff --git a/artifacts/program_methods/privacy_preserving_circuit.bin b/artifacts/program_methods/privacy_preserving_circuit.bin index 66ca147c..ca97005f 100644 Binary files a/artifacts/program_methods/privacy_preserving_circuit.bin and b/artifacts/program_methods/privacy_preserving_circuit.bin differ diff --git a/artifacts/program_methods/token.bin b/artifacts/program_methods/token.bin index ccb8abde..496d7a21 100644 Binary files a/artifacts/program_methods/token.bin and b/artifacts/program_methods/token.bin differ diff --git a/artifacts/test_program_methods/auth_asserting_noop.bin b/artifacts/test_program_methods/auth_asserting_noop.bin index b4d78f9a..d4f867f9 100644 Binary files a/artifacts/test_program_methods/auth_asserting_noop.bin and b/artifacts/test_program_methods/auth_asserting_noop.bin differ diff --git a/artifacts/test_program_methods/auth_transfer_proxy.bin b/artifacts/test_program_methods/auth_transfer_proxy.bin new file mode 100644 index 00000000..f3131490 Binary files /dev/null and b/artifacts/test_program_methods/auth_transfer_proxy.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index db1f91fc..98238357 100644 Binary files a/artifacts/test_program_methods/burner.bin and b/artifacts/test_program_methods/burner.bin differ diff --git a/artifacts/test_program_methods/chain_caller.bin b/artifacts/test_program_methods/chain_caller.bin index 51552977..b51b516d 100644 Binary files a/artifacts/test_program_methods/chain_caller.bin and b/artifacts/test_program_methods/chain_caller.bin differ diff --git a/artifacts/test_program_methods/changer_claimer.bin b/artifacts/test_program_methods/changer_claimer.bin index fa3f10f7..e31d4f92 100644 Binary files a/artifacts/test_program_methods/changer_claimer.bin and b/artifacts/test_program_methods/changer_claimer.bin differ diff --git a/artifacts/test_program_methods/claimer.bin b/artifacts/test_program_methods/claimer.bin index 164fc2e1..05778e6d 100644 Binary files a/artifacts/test_program_methods/claimer.bin and b/artifacts/test_program_methods/claimer.bin differ diff --git a/artifacts/test_program_methods/clock_chain_caller.bin b/artifacts/test_program_methods/clock_chain_caller.bin index 17cf28a9..63f1896e 100644 Binary files a/artifacts/test_program_methods/clock_chain_caller.bin and b/artifacts/test_program_methods/clock_chain_caller.bin differ diff --git a/artifacts/test_program_methods/data_changer.bin b/artifacts/test_program_methods/data_changer.bin index 27a808d3..c431ea88 100644 Binary files a/artifacts/test_program_methods/data_changer.bin and b/artifacts/test_program_methods/data_changer.bin differ diff --git a/artifacts/test_program_methods/extra_output.bin b/artifacts/test_program_methods/extra_output.bin index 997d783b..3545111d 100644 Binary files a/artifacts/test_program_methods/extra_output.bin and b/artifacts/test_program_methods/extra_output.bin differ diff --git a/artifacts/test_program_methods/flash_swap_callback.bin b/artifacts/test_program_methods/flash_swap_callback.bin index bd1b0fb7..e4859678 100644 Binary files a/artifacts/test_program_methods/flash_swap_callback.bin and b/artifacts/test_program_methods/flash_swap_callback.bin differ diff --git a/artifacts/test_program_methods/flash_swap_initiator.bin b/artifacts/test_program_methods/flash_swap_initiator.bin index 751cea31..3c843722 100644 Binary files a/artifacts/test_program_methods/flash_swap_initiator.bin and b/artifacts/test_program_methods/flash_swap_initiator.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index 1d9e261d..f8589cea 100644 Binary files a/artifacts/test_program_methods/malicious_authorization_changer.bin and b/artifacts/test_program_methods/malicious_authorization_changer.bin differ diff --git a/artifacts/test_program_methods/malicious_caller_program_id.bin b/artifacts/test_program_methods/malicious_caller_program_id.bin index d8f2fab7..b3bdb233 100644 Binary files a/artifacts/test_program_methods/malicious_caller_program_id.bin and b/artifacts/test_program_methods/malicious_caller_program_id.bin differ diff --git a/artifacts/test_program_methods/malicious_self_program_id.bin b/artifacts/test_program_methods/malicious_self_program_id.bin index bf6f94fe..bade7a1f 100644 Binary files a/artifacts/test_program_methods/malicious_self_program_id.bin and b/artifacts/test_program_methods/malicious_self_program_id.bin differ diff --git a/artifacts/test_program_methods/minter.bin b/artifacts/test_program_methods/minter.bin index 8d230657..9920c6c4 100644 Binary files a/artifacts/test_program_methods/minter.bin and b/artifacts/test_program_methods/minter.bin differ diff --git a/artifacts/test_program_methods/missing_output.bin b/artifacts/test_program_methods/missing_output.bin index 9ffdad4d..d765178b 100644 Binary files a/artifacts/test_program_methods/missing_output.bin and b/artifacts/test_program_methods/missing_output.bin differ diff --git a/artifacts/test_program_methods/modified_transfer.bin b/artifacts/test_program_methods/modified_transfer.bin index c95729cd..635b15cd 100644 Binary files a/artifacts/test_program_methods/modified_transfer.bin and b/artifacts/test_program_methods/modified_transfer.bin differ diff --git a/artifacts/test_program_methods/nonce_changer.bin b/artifacts/test_program_methods/nonce_changer.bin index b5b9f2d0..77b71103 100644 Binary files a/artifacts/test_program_methods/nonce_changer.bin and b/artifacts/test_program_methods/nonce_changer.bin differ diff --git a/artifacts/test_program_methods/noop.bin b/artifacts/test_program_methods/noop.bin index d35aae8d..f6b2a34d 100644 Binary files a/artifacts/test_program_methods/noop.bin and b/artifacts/test_program_methods/noop.bin differ diff --git a/artifacts/test_program_methods/pda_claimer.bin b/artifacts/test_program_methods/pda_claimer.bin index 3d3a949e..0d902873 100644 Binary files a/artifacts/test_program_methods/pda_claimer.bin and b/artifacts/test_program_methods/pda_claimer.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin index 269a9323..9fe7b9e4 100644 Binary files a/artifacts/test_program_methods/pinata_cooldown.bin and b/artifacts/test_program_methods/pinata_cooldown.bin differ diff --git a/artifacts/test_program_methods/private_pda_delegator.bin b/artifacts/test_program_methods/private_pda_delegator.bin index b991dccc..d304fa39 100644 Binary files a/artifacts/test_program_methods/private_pda_delegator.bin and b/artifacts/test_program_methods/private_pda_delegator.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 2e4d8463..61527451 100644 Binary files a/artifacts/test_program_methods/program_owner_changer.bin and b/artifacts/test_program_methods/program_owner_changer.bin differ diff --git a/artifacts/test_program_methods/simple_balance_transfer.bin b/artifacts/test_program_methods/simple_balance_transfer.bin index 5775494e..bd13572b 100644 Binary files a/artifacts/test_program_methods/simple_balance_transfer.bin and b/artifacts/test_program_methods/simple_balance_transfer.bin differ diff --git a/artifacts/test_program_methods/time_locked_transfer.bin b/artifacts/test_program_methods/time_locked_transfer.bin index 7f06ad7f..0c5d39ef 100644 Binary files a/artifacts/test_program_methods/time_locked_transfer.bin and b/artifacts/test_program_methods/time_locked_transfer.bin differ diff --git a/artifacts/test_program_methods/two_pda_claimer.bin b/artifacts/test_program_methods/two_pda_claimer.bin index 7cbb3249..34476982 100644 Binary files a/artifacts/test_program_methods/two_pda_claimer.bin and b/artifacts/test_program_methods/two_pda_claimer.bin differ diff --git a/artifacts/test_program_methods/validity_window.bin b/artifacts/test_program_methods/validity_window.bin index 6c436b2f..f8f3c615 100644 Binary files a/artifacts/test_program_methods/validity_window.bin and b/artifacts/test_program_methods/validity_window.bin differ diff --git a/artifacts/test_program_methods/validity_window_chain_caller.bin b/artifacts/test_program_methods/validity_window_chain_caller.bin index a8f7cf41..b29561f1 100644 Binary files a/artifacts/test_program_methods/validity_window_chain_caller.bin and b/artifacts/test_program_methods/validity_window_chain_caller.bin differ diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index fcae2c71..89715dfb 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -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; diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs new file mode 100644 index 00000000..df50784f --- /dev/null +++ b/integration_tests/tests/private_pda.rs @@ -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(()) +} diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index 55c80b32..b9206492 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -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] diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index f5bd8cea..a0106344 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -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); + } } diff --git a/nssa/src/program.rs b/nssa/src/program.rs index b8c3fe77..db83a1c4 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -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}; diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs index 0313f424..ede43ae8 100644 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -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 diff --git a/test_program_methods/guest/src/bin/auth_transfer_proxy.rs b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs new file mode 100644 index 00000000..bdc96e0e --- /dev/null +++ b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs @@ -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::(); + + 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(); +}