diff --git a/artifacts/test_program_methods/private_pda_spender.bin b/artifacts/test_program_methods/private_pda_spender.bin index 70e4c5a0..cc602ee4 100644 Binary files a/artifacts/test_program_methods/private_pda_spender.bin and b/artifacts/test_program_methods/private_pda_spender.bin differ diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index 2526c700..913cbb29 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -418,12 +418,50 @@ mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } - /// Group PDA deposit: creates a new PDA and transfers balance from the - /// counterparty. Both accounts owned by `private_pda_spender`. + /// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership. + /// The `private_pda_spender` program chains to `authenticated_transfer` with `pda_seeds` + /// to establish authorization and the mask-3 binding. #[test] - fn group_pda_deposit() { + fn private_pda_init() { let program = Program::private_pda_spender(); - let noop = Program::noop(); + let auth_transfer = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + // PDA (new, mask 3) — AccountId derived from private_pda_spender's program ID + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); + + let auth_id = auth_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, auth_transfer)].into()); + + // is_withdraw=false triggers init path (1 pre-state) + let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, false)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre], + instruction, + vec![InputAccountIdentity::PrivatePdaInit { + npk, + ssk: shared_secret_pda, + }], + &program_with_deps, + ); + + let (output, _proof) = result.expect("PDA init should succeed"); + assert_eq!(output.new_commitments.len(), 1); + } + + /// PDA withdraw: chains to `authenticated_transfer` to move balance from PDA to recipient. + /// Uses a default PDA (amount=0) because testing with a pre-funded PDA requires a + /// two-tx sequence with membership proofs. + #[test] + fn private_pda_withdraw() { + let program = Program::private_pda_spender(); + let auth_transfer = Program::authenticated_transfer_program(); let keys = test_private_account_keys_1(); let npk = keys.npk(); let seed = PdaSeed::new([42; 32]); @@ -433,90 +471,90 @@ mod tests { let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - // Sender (mask 0, public, owned by this program, has balance) + // Recipient (mask 0, public) + let recipient_id = AccountId::new([88; 32]); + let recipient_pre = AccountWithMetadata::new( + Account { + program_owner: auth_transfer.id(), + balance: 10000, + ..Account::default() + }, + true, + recipient_id, + ); + + let auth_id = auth_transfer.id(); + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_id, auth_transfer)].into()); + + // is_withdraw=true, amount=0 (PDA has no balance yet) + let instruction = Program::serialize_instruction((seed, auth_id, 0_u128, true)).unwrap(); + + let result = execute_and_prove( + vec![pda_pre, recipient_pre], + instruction, + vec![ + InputAccountIdentity::PrivatePdaInit { + npk, + ssk: shared_secret_pda, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ); + + let (output, _proof) = result.expect("PDA withdraw should succeed"); + assert_eq!(output.new_commitments.len(), 1); + } + + /// Shared regular private account: receives funds via `authenticated_transfer` directly, + /// no custom program needed. This demonstrates the non-PDA shared account flow where + /// keys are derived from GMS via `derive_keys_for_shared_account`. The shared account + /// uses standard mask 2 (new unauthorized private) and works with auth-transfer's + /// transfer path like any other private account. + #[test] + fn shared_account_receives_via_auth_transfer() { + let program = Program::authenticated_transfer_program(); + let shared_keys = test_private_account_keys_1(); + let shared_npk = shared_keys.npk(); + let shared_identifier: u128 = 42; + let shared_secret = SharedSecretKey::new(&[55; 32], &shared_keys.vpk()); + + // Sender: public account with balance, owned by auth-transfer let sender_id = AccountId::new([99; 32]); - let sender_pre = AccountWithMetadata::new( + let sender = AccountWithMetadata::new( Account { program_owner: program.id(), - balance: 10000, + balance: 1000, ..Account::default() }, true, sender_id, ); - let noop_id = noop.id(); - let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); + // Recipient: shared private account (new, unauthorized, mask 2) + let shared_account_id = AccountId::from((&shared_npk, shared_identifier)); + let recipient = AccountWithMetadata::new(Account::default(), false, shared_account_id); - let instruction = Program::serialize_instruction((seed, noop_id, 500_u128, true)).unwrap(); - - // PDA is mask 3 (private PDA), sender is mask 0 (public). - // The noop chained call is required to establish the mask-3 (seed, npk) binding - // that the circuit enforces for private PDAs. Without a caller providing pda_seeds, - // the circuit's binding check rejects the account. - let result = execute_and_prove( - vec![pda_pre, sender_pre], - instruction, - vec![ - InputAccountIdentity::PrivatePdaInit { - npk, - ssk: shared_secret_pda, - }, - InputAccountIdentity::Public, - ], - &program_with_deps, - ); - - let (output, _proof) = result.expect("group PDA deposit should succeed"); - // Only PDA (mask 3) produces a commitment; sender (mask 0) is public. - assert_eq!(output.new_commitments.len(), 1); - } - - /// Group PDA spend binding: the noop chained call with `pda_seeds` establishes - /// the mask-3 binding for an existing-but-default PDA. Uses amount=0 because - /// testing with a pre-funded PDA requires a two-tx sequence with membership proofs. - #[test] - fn group_pda_spend_binding() { - let program = Program::private_pda_spender(); - let noop = Program::noop(); - let keys = test_private_account_keys_1(); - let npk = keys.npk(); - let seed = PdaSeed::new([42; 32]); - let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); - - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); - let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); - - let bob_id = AccountId::new([88; 32]); - let bob_pre = AccountWithMetadata::new( - Account { - program_owner: program.id(), - balance: 10000, - ..Account::default() - }, - true, - bob_id, - ); - - let noop_id = noop.id(); - let program_with_deps = ProgramWithDependencies::new(program, [(noop_id, noop)].into()); - - let instruction = Program::serialize_instruction((seed, noop_id, 0_u128, false)).unwrap(); + let balance_to_move: u128 = 100; + let instruction = Program::serialize_instruction(balance_to_move).unwrap(); let result = execute_and_prove( - vec![pda_pre, bob_pre], + vec![sender, recipient], instruction, vec![ - InputAccountIdentity::PrivatePdaInit { - npk, - ssk: shared_secret_pda, - }, InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: shared_npk, + ssk: shared_secret, + identifier: shared_identifier, + }, ], - &program_with_deps, + &program.into(), ); - let (output, _proof) = result.expect("group PDA spend binding should succeed"); + let (output, _proof) = result.expect("shared account receive should succeed"); + // Sender is public (no commitment), recipient is private (1 commitment) assert_eq!(output.new_commitments.len(), 1); } } diff --git a/test_program_methods/guest/src/bin/private_pda_spender.rs b/test_program_methods/guest/src/bin/private_pda_spender.rs index 04ef91a4..2f3b9c23 100644 --- a/test_program_methods/guest/src/bin/private_pda_spender.rs +++ b/test_program_methods/guest/src/bin/private_pda_spender.rs @@ -1,21 +1,25 @@ use nssa_core::program::{ - AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs, }; -/// Single program for group PDA operations. Owns and operates the PDA directly. +/// PDA authorization program that delegates balance operations to `authenticated_transfer`. /// -/// Instruction: `(pda_seed, noop_program_id, amount, is_deposit)`. -/// Pre-states: `[group_pda, counterparty]`. +/// The PDA is owned by `authenticated_transfer`, not by this program. This program's role +/// is solely to provide PDA authorization via `pda_seeds` in chained calls. /// -/// **Deposit** (`is_deposit = true`, new PDA): -/// Claims PDA via `Claim::Pda(seed)`, increases PDA balance, decreases counterparty. -/// Counterparty must be authorized and owned by this program (or uninitialized). +/// Instruction: `(pda_seed, auth_transfer_id, amount, is_withdraw)`. /// -/// **Spend** (`is_deposit = false`, existing PDA): -/// Decreases PDA balance (this program owns it), increases counterparty. -/// Chains to a noop callee with `pda_seeds` to establish the mask-3 binding -/// that the circuit requires for existing private PDAs. +/// **Init** (`is_withdraw = false`, 1 pre-state `[pda]`): +/// Chains to `authenticated_transfer` with `instruction=0` (init path) and `pda_seeds=[seed]` +/// to initialize the PDA under `authenticated_transfer`'s ownership. +/// +/// **Withdraw** (`is_withdraw = true`, 2 pre-states `[pda, recipient]`): +/// Chains to `authenticated_transfer` with the amount and `pda_seeds=[seed]` to authorize +/// the PDA for a balance transfer. The actual balance modification happens in +/// `authenticated_transfer`, not here. +/// +/// **Deposit**: done directly via `authenticated_transfer` (no need for this program). type Instruction = (PdaSeed, ProgramId, u128, bool); #[expect( @@ -32,77 +36,52 @@ fn main() { self_program_id, caller_program_id, pre_states, - instruction: (pda_seed, noop_id, amount, is_deposit), + instruction: (pda_seed, auth_transfer_id, amount, is_withdraw), }, instruction_words, ) = read_nssa_inputs::(); - let Ok([pda_pre, counterparty_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { - panic!("expected exactly 2 pre_states: [group_pda, counterparty]"); - }; + if is_withdraw { + let Ok([pda_pre, recipient_pre]) = <[_; 2]>::try_from(pre_states.clone()) else { + panic!("expected exactly 2 pre_states for withdraw: [pda, recipient]"); + }; - if is_deposit { - // Deposit: claim PDA, transfer balance from counterparty to PDA. - // Both accounts must be owned by this program (or uninitialized) for - // validate_execution to allow balance changes. - assert!( - counterparty_pre.is_authorized, - "Counterparty must be authorized to deposit" - ); + // Post-states stay unchanged in this program. The actual balance transfer + // happens in the chained call to authenticated_transfer. + let pda_post = AccountPostState::new(pda_pre.account.clone()); + let recipient_post = AccountPostState::new(recipient_pre.account.clone()); - let mut pda_account = pda_pre.account; - let mut counterparty_account = counterparty_pre.account; - - pda_account.balance = pda_account - .balance - .checked_add(amount) - .expect("PDA balance overflow"); - counterparty_account.balance = counterparty_account - .balance - .checked_sub(amount) - .expect("Counterparty has insufficient balance"); - - let pda_post = AccountPostState::new_claimed_if_default(pda_account, Claim::Pda(pda_seed)); - let counterparty_post = AccountPostState::new(counterparty_account); + // Chain to authenticated_transfer with pda_seeds to authorize the PDA. + // The circuit's resolve_authorization_and_record_bindings establishes the + // mask-3 (seed, npk) binding when pda_seeds match the private PDA derivation. + let mut auth_pda_pre = pda_pre; + auth_pda_pre.is_authorized = true; + let auth_call = + ChainedCall::new(auth_transfer_id, vec![auth_pda_pre, recipient_pre], &amount) + .with_pda_seeds(vec![pda_seed]); ProgramOutput::new( self_program_id, caller_program_id, instruction_words, pre_states, - vec![pda_post, counterparty_post], + vec![pda_post, recipient_post], ) + .with_chained_calls(vec![auth_call]) .write(); } else { - // Spend: decrease PDA balance (owned by this program), increase counterparty. - // Chain to noop with pda_seeds to establish the mask-3 binding for the - // existing PDA. The noop's pre_states must match our post_states. - // Authorization is enforced by the circuit's binding check, not here. + // Init: initialize the PDA under authenticated_transfer's ownership. + let Ok([pda_pre]) = <[_; 1]>::try_from(pre_states.clone()) else { + panic!("expected exactly 1 pre_state for init: [pda]"); + }; - let mut pda_account = pda_pre.account.clone(); - let mut counterparty_account = counterparty_pre.account.clone(); + let pda_post = AccountPostState::new(pda_pre.account.clone()); - pda_account.balance = pda_account - .balance - .checked_sub(amount) - .expect("PDA has insufficient balance"); - counterparty_account.balance = counterparty_account - .balance - .checked_add(amount) - .expect("Counterparty balance overflow"); - - let pda_post = AccountPostState::new(pda_account.clone()); - let counterparty_post = AccountPostState::new(counterparty_account.clone()); - - // Chain to noop solely to establish the mask-3 binding via pda_seeds. - let mut noop_pda_pre = pda_pre; - noop_pda_pre.account = pda_account; - noop_pda_pre.is_authorized = true; - - let mut noop_counterparty_pre = counterparty_pre; - noop_counterparty_pre.account = counterparty_account; - - let noop_call = ChainedCall::new(noop_id, vec![noop_pda_pre, noop_counterparty_pre], &()) + // Chain to authenticated_transfer with instruction=0 (init path) and pda_seeds + // to authorize the PDA. authenticated_transfer will claim it with Claim::Authorized. + let mut auth_pda_pre = pda_pre; + auth_pda_pre.is_authorized = true; + let auth_call = ChainedCall::new(auth_transfer_id, vec![auth_pda_pre], &0_u128) .with_pda_seeds(vec![pda_seed]); ProgramOutput::new( @@ -110,9 +89,9 @@ fn main() { caller_program_id, instruction_words, pre_states, - vec![pda_post, counterparty_post], + vec![pda_post], ) - .with_chained_calls(vec![noop_call]) + .with_chained_calls(vec![auth_call]) .write(); } }