diff --git a/artifacts/test_program_methods/auth_transfer_proxy.bin b/artifacts/test_program_methods/auth_transfer_proxy.bin index 2d47f454..3dcdb1bf 100644 Binary files a/artifacts/test_program_methods/auth_transfer_proxy.bin and b/artifacts/test_program_methods/auth_transfer_proxy.bin differ diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs index df50784f..fe61d743 100644 --- a/integration_tests/tests/private_pda.rs +++ b/integration_tests/tests/private_pda.rs @@ -12,7 +12,7 @@ use integration_tests::{ }; use log::info; use nssa::{ - AccountId, + AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies, program::Program, }; @@ -21,10 +21,11 @@ use tokio::test; use wallet::{PrivacyPreservingAccount, WalletCore}; use wallet::cli::{Command, account::AccountSubcommand}; -/// Funds a private PDA via auth_transfer directly (no proxy). +/// Funds a private PDA via the proxy program with a chained call to auth_transfer. /// -/// 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. +/// A direct call to auth_transfer cannot establish the PDA-to-npk binding because it uses +/// `Claim::Authorized` rather than `Claim::Pda`. Routing through the proxy provides the binding +/// via `pda_seeds` in the chained call to auth_transfer. async fn fund_private_pda( wallet: &WalletCore, sender: AccountId, @@ -32,8 +33,10 @@ async fn fund_private_pda( npk: NullifierPublicKey, vpk: ViewingPublicKey, identifier: u128, + seed: PdaSeed, amount: u128, - auth_transfer: &ProgramWithDependencies, + proxy_program: &ProgramWithDependencies, + auth_transfer_id: ProgramId, ) -> Result<()> { wallet .send_privacy_preserving_tx( @@ -46,9 +49,9 @@ async fn fund_private_pda( identifier, }, ], - Program::serialize_instruction(amount) - .context("failed to serialize auth_transfer instruction")?, - auth_transfer, + Program::serialize_instruction((seed, amount, auth_transfer_id, true)) + .context("failed to serialize auth_transfer_proxy fund instruction")?, + proxy_program, ) .await .map_err(|e| anyhow::anyhow!("{e}"))?; @@ -78,7 +81,7 @@ async fn spend_private_pda( identifier: 0, }, ], - Program::serialize_instruction((seed, amount, auth_transfer_id)) + Program::serialize_instruction((seed, amount, auth_transfer_id, false)) .context("failed to serialize auth_transfer_proxy instruction")?, spend_program, ) @@ -124,7 +127,6 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { 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()); @@ -146,8 +148,10 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { alice_npk, alice_vpk.clone(), 0, + seed, amount, - &auth_transfer_program, + &spend_program, + auth_transfer_id, ) .await?; @@ -159,8 +163,10 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> { alice_npk, alice_vpk.clone(), 1, + seed, amount, - &auth_transfer_program, + &spend_program, + auth_transfer_id, ) .await?; diff --git a/test_program_methods/guest/src/bin/auth_transfer_proxy.rs b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs index bdc96e0e..592e9eb0 100644 --- a/test_program_methods/guest/src/bin/auth_transfer_proxy.rs +++ b/test_program_methods/guest/src/bin/auth_transfer_proxy.rs @@ -1,17 +1,22 @@ -use nssa_core::program::{ - AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, - read_nssa_inputs, +use nssa_core::{ + account::AccountWithMetadata, + 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. +/// Proxy for interacting with private PDAs via auth_transfer. /// -/// pre_states[0] = the private PDA (must be authorized) -/// pre_states[1] = the recipient +/// The `is_fund` flag selects the operating mode: /// -/// 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); +/// - `false` (Spend): pre_states = [pda (authorized), recipient]. +/// Debits the PDA. The PDA-to-npk binding is established via `pda_seeds` in the chained +/// call to auth_transfer. +/// +/// - `true` (Fund): pre_states = [sender (authorized), pda (foreign/uninitialized)]. +/// Credits the PDA. A direct call to auth_transfer cannot bind the PDA because auth_transfer +/// uses `Claim::Authorized`, not `Claim::Pda`. Routing through this proxy establishes the +/// binding via `pda_seeds` in the chained call. +type Instruction = (PdaSeed, u128, ProgramId, bool); fn main() { let ( @@ -19,24 +24,38 @@ fn main() { self_program_id, caller_program_id, pre_states, - instruction: (seed, amount, auth_transfer_id), + instruction: (seed, amount, auth_transfer_id, is_fund), }, instruction_words, ) = read_nssa_inputs::(); - let Ok([pda, recipient]) = <[_; 2]>::try_from(pre_states) else { + let Ok([first, second]) = <[_; 2]>::try_from(pre_states) else { return; }; - assert!(pda.is_authorized, "PDA must be authorized"); + assert!(first.is_authorized, "first pre_state must be authorized"); - let pda_post = AccountPostState::new(pda.account.clone()); - let recipient_post = AccountPostState::new(recipient.account.clone()); + // In Fund mode the PDA (second) starts unauthorized (PrivatePdaForeign). We pass it to the + // chained call with is_authorized=true so the value matches what the circuit will resolve via + // pda_seeds, satisfying the consistency assertion in validate_and_sync_states. + let chained_pre_states = if is_fund { + let pda_authorized = AccountWithMetadata { + account: second.account.clone(), + account_id: second.account_id, + is_authorized: true, + }; + vec![first.clone(), pda_authorized] + } else { + vec![first.clone(), second.clone()] + }; + + let first_post = AccountPostState::new(first.account.clone()); + let second_post = AccountPostState::new(second.account.clone()); let chained_call = ChainedCall { program_id: auth_transfer_id, instruction_data: to_vec(&amount).unwrap(), - pre_states: vec![pda.clone(), recipient.clone()], + pre_states: chained_pre_states, pda_seeds: vec![seed], }; @@ -44,8 +63,8 @@ fn main() { self_program_id, caller_program_id, instruction_words, - vec![pda, recipient], - vec![pda_post, recipient_post], + vec![first, second], + vec![first_post, second_post], ) .with_chained_calls(vec![chained_call]) .write();