diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index a7ddba52..542fc93f 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 b0e5def5..d29bcf02 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 f6d9672c..928963dc 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 c24f463c..c2ce2594 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 415c8ce3..a097ad3d 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 9a292cbe..abc964b6 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 3580ef74..5bb93687 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 bf0f0571..615c12eb 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 7292d329..24fb426c 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 index 662f2d06..fa1306d0 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/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index 30fdcaee..59e8e43b 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 68edc95c..d1f22c57 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 5a71455c..afe2b1c2 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 42ca125b..df4466aa 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 3e84cd25..13d81c8f 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 705a1ec5..12d1ff64 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 9f077174..9ba00633 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 ec26c2ca..7a5962f9 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 73b3bb32..544a1718 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/group_pda_spender.bin b/artifacts/test_program_methods/group_pda_spender.bin new file mode 100644 index 00000000..16efb8a4 Binary files /dev/null and b/artifacts/test_program_methods/group_pda_spender.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index dba3f365..2034cbe7 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 4762d25d..514d3302 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 653ece66..45fa2e0b 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 a0144fce..623b25eb 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 cbf3e467..72feec32 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 e2b7fb47..92b61443 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 a99aa6ae..fed326c3 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 d7bffd5f..a4fcda58 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 b1dfd47f..53b91181 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/pda_fund_spend_proxy.bin b/artifacts/test_program_methods/pda_fund_spend_proxy.bin new file mode 100644 index 00000000..e377e6bf Binary files /dev/null and b/artifacts/test_program_methods/pda_fund_spend_proxy.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin index eaad2613..69f6b617 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 c1caf235..32dffd53 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/private_pda_spender.bin b/artifacts/test_program_methods/private_pda_spender.bin new file mode 100644 index 00000000..2db36680 Binary files /dev/null and b/artifacts/test_program_methods/private_pda_spender.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 2e22bfaa..a9dbb869 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 1f744230..5a4e7036 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 90723ae0..db2dc82d 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 05c17133..56a8bd70 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 fd6423fc..de524642 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 0c86a460..020f0c9b 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/indexer/core/src/lib.rs b/indexer/core/src/lib.rs index 3d57e540..eeb31ebb 100644 --- a/indexer/core/src/lib.rs +++ b/indexer/core/src/lib.rs @@ -48,7 +48,7 @@ impl IndexerCore { .iter() .map(|init_comm_data| { let npk = &init_comm_data.npk; - let account_id = nssa::AccountId::from((npk, 0)); + let account_id = nssa::AccountId::for_regular_private_account(npk, 0); let mut acc = init_comm_data.account.clone(); diff --git a/integration_tests/src/config.rs b/integration_tests/src/config.rs index 7b3825de..5c381e0e 100644 --- a/integration_tests/src/config.rs +++ b/integration_tests/src/config.rs @@ -59,12 +59,16 @@ impl InitialData { } let mut private_charlie_key_chain = KeyChain::new_os_random(); - let mut private_charlie_account_id = - AccountId::from((&private_charlie_key_chain.nullifier_public_key, 0)); + let mut private_charlie_account_id = AccountId::for_regular_private_account( + &private_charlie_key_chain.nullifier_public_key, + 0, + ); let mut private_david_key_chain = KeyChain::new_os_random(); - let mut private_david_account_id = - AccountId::from((&private_david_key_chain.nullifier_public_key, 0)); + let mut private_david_account_id = AccountId::for_regular_private_account( + &private_david_key_chain.nullifier_public_key, + 0, + ); // Ensure consistent ordering if private_charlie_account_id > private_david_account_id { diff --git a/integration_tests/src/lib.rs b/integration_tests/src/lib.rs index 2a9e7c67..7abd0897 100644 --- a/integration_tests/src/lib.rs +++ b/integration_tests/src/lib.rs @@ -29,6 +29,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_PDA_FUND_SPEND_PROXY: &str = "pda_fund_spend_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/auth_transfer/private.rs b/integration_tests/tests/auth_transfer/private.rs index 8db5f8d4..4d268b89 100644 --- a/integration_tests/tests/auth_transfer/private.rs +++ b/integration_tests/tests/auth_transfer/private.rs @@ -605,14 +605,14 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> { .await?; // Both accounts must be discovered with the correct balances. - let account_id_1 = AccountId::from((&npk, identifier_1)); + let account_id_1 = AccountId::for_regular_private_account(&npk, identifier_1); let acc_1 = ctx .wallet() .get_account_private(account_id_1) .context("account for identifier 1 not found after sync")?; assert_eq!(acc_1.balance, 100); - let account_id_2 = AccountId::from((&npk, identifier_2)); + let account_id_2 = AccountId::for_regular_private_account(&npk, identifier_2); let acc_2 = ctx .wallet() .get_account_private(account_id_2) diff --git a/integration_tests/tests/private_pda.rs b/integration_tests/tests/private_pda.rs new file mode 100644 index 00000000..518239e7 --- /dev/null +++ b/integration_tests/tests/private_pda.rs @@ -0,0 +1,309 @@ +#![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_PDA_FUND_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, + verify_commitment_is_in_state, +}; +use log::info; +use nssa::{ + AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies, + program::Program, +}; +use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey, program::PdaSeed}; +use tokio::test; +use wallet::{ + PrivacyPreservingAccount, WalletCore, + cli::{Command, account::AccountSubcommand}, +}; + +/// Funds a private PDA via the proxy program with a chained call to `auth_transfer`. +/// +/// 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`. +#[expect( + clippy::too_many_arguments, + reason = "test helper — grouping args would obscure intent" +)] +async fn fund_private_pda( + wallet: &WalletCore, + sender: AccountId, + pda_account_id: AccountId, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: u128, + seed: PdaSeed, + amount: u128, + proxy_program: &ProgramWithDependencies, + auth_transfer_id: ProgramId, +) -> Result<()> { + wallet + .send_privacy_preserving_tx( + vec![ + PrivacyPreservingAccount::Public(sender), + PrivacyPreservingAccount::PrivatePdaForeign { + account_id: pda_account_id, + npk, + vpk, + identifier, + }, + ], + Program::serialize_instruction((seed, amount, auth_transfer_id, true)) + .context("failed to serialize pda_fund_spend_proxy fund instruction")?, + proxy_program, + ) + .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). +#[expect( + clippy::too_many_arguments, + reason = "test helper — grouping args would obscure intent" +)] +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, false)) + .context("failed to serialize pda_fund_spend_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_PDA_FUND_SPEND_PROXY); + Program::new(std::fs::read(&path).with_context(|| format!("reading {path:?}"))?) + .context("invalid pda_fund_spend_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 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, + seed, + amount, + &spend_program, + auth_transfer_id, + ) + .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, + seed, + amount, + &spend_program, + auth_transfer_id, + ) + .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/integration_tests/tests/shared_accounts.rs b/integration_tests/tests/shared_accounts.rs index ecf3a4b4..c5d937e0 100644 --- a/integration_tests/tests/shared_accounts.rs +++ b/integration_tests/tests/shared_accounts.rs @@ -55,6 +55,7 @@ async fn group_create_and_shared_account_registration() -> Result<()> { pda: false, seed: None, program_id: None, + identifier: None, })); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; @@ -170,6 +171,7 @@ async fn fund_shared_account_from_public() -> Result<()> { pda: false, seed: None, program_id: None, + identifier: None, })); let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?; let SubcommandReturnValue::RegisterAccount { diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index df74daba..1f132932 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -220,7 +220,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { data: Data::default(), }, true, - AccountId::from((&sender_npk, 0)), + AccountId::for_regular_private_account(&sender_npk, 0), ); let recipient_nsk = [2; 32]; let recipient_vsk = [99; 32]; @@ -229,7 +229,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { let recipient_pre = AccountWithMetadata::new( Account::default(), false, - AccountId::from((&recipient_npk, 0)), + AccountId::for_regular_private_account(&recipient_npk, 0), ); let eph_holder_from = EphemeralKeyHolder::new(&sender_npk); diff --git a/integration_tests/tests/wallet_ffi.rs b/integration_tests/tests/wallet_ffi.rs index db84b066..a0904a9a 100644 --- a/integration_tests/tests/wallet_ffi.rs +++ b/integration_tests/tests/wallet_ffi.rs @@ -801,7 +801,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> { let (to, to_keys) = unsafe { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys); - let account_id = nssa::AccountId::from((&out_keys.npk(), 0_u128)); + let account_id = nssa::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); let to: FfiBytes32 = (&account_id).into(); (to, out_keys) }; @@ -935,7 +935,7 @@ fn test_wallet_ffi_transfer_private() -> Result<()> { let (to, to_keys) = unsafe { let mut out_keys = FfiPrivateAccountKeys::default(); wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys); - let account_id = nssa::AccountId::from((&out_keys.npk(), 0_u128)); + let account_id = nssa::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128); let to: FfiBytes32 = (&account_id).into(); (to, out_keys) }; diff --git a/key_protocol/src/key_management/group_key_holder.rs b/key_protocol/src/key_management/group_key_holder.rs index 609c45ed..8bc9ed13 100644 --- a/key_protocol/src/key_management/group_key_holder.rs +++ b/key_protocol/src/key_management/group_key_holder.rs @@ -339,7 +339,7 @@ mod tests { let npk = holder .derive_keys_for_pda(&TEST_PROGRAM_ID, &seed) .generate_nullifier_public_key(); - let account_id = AccountId::for_private_pda(&program_id, &seed, &npk); + let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX); let expected_npk = NullifierPublicKey([ 136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79, @@ -347,7 +347,8 @@ mod tests { ]); // AccountId is derived from (program_id, seed, npk), so it changes when npk changes. // We verify npk is pinned, and AccountId is deterministically derived from it. - let expected_account_id = AccountId::for_private_pda(&program_id, &seed, &expected_npk); + let expected_account_id = + AccountId::for_private_pda(&program_id, &seed, &expected_npk, u128::MAX); assert_eq!(npk, expected_npk); assert_eq!(account_id, expected_account_id); @@ -545,8 +546,8 @@ mod tests { .generate_nullifier_public_key(); assert_eq!(alice_npk, bob_npk); - let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk); - let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk); + let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk, 0); + let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, 0); assert_eq!(alice_account_id, bob_account_id); } diff --git a/key_protocol/src/key_management/key_tree/keys_private.rs b/key_protocol/src/key_management/key_tree/keys_private.rs index 6ffc8119..05a7c996 100644 --- a/key_protocol/src/key_management/key_tree/keys_private.rs +++ b/key_protocol/src/key_management/key_tree/keys_private.rs @@ -1,5 +1,5 @@ use k256::{Scalar, elliptic_curve::PrimeField as _}; -use nssa_core::{Identifier, NullifierPublicKey, encryption::ViewingPublicKey}; +use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey}; use serde::{Deserialize, Serialize}; use crate::key_management::{ @@ -10,7 +10,7 @@ use crate::key_management::{ #[derive(Debug, Serialize, Deserialize, Clone)] pub struct ChildKeysPrivate { - pub value: (KeyChain, Vec<(Identifier, nssa::Account)>), + pub value: (KeyChain, Vec<(PrivateAccountKind, nssa::Account)>), pub ccc: [u8; 32], /// Can be [`None`] if root. pub cci: Option, @@ -115,9 +115,11 @@ impl KeyTreeNode for ChildKeysPrivate { } fn account_ids(&self) -> impl Iterator { - self.value.1.iter().map(|(identifier, _)| { - nssa::AccountId::from((&self.value.0.nullifier_public_key, *identifier)) - }) + let npk = self.value.0.nullifier_public_key; + self.value + .1 + .iter() + .map(move |(kind, _)| nssa::AccountId::for_private_account(&npk, kind)) } } diff --git a/key_protocol/src/key_management/key_tree/mod.rs b/key_protocol/src/key_management/key_tree/mod.rs index 0ae0a52f..edf9dadd 100644 --- a/key_protocol/src/key_management/key_tree/mod.rs +++ b/key_protocol/src/key_management/key_tree/mod.rs @@ -274,7 +274,10 @@ impl KeyTree { identifier: Identifier, ) -> Option { let node = self.key_map.get(cci)?; - let account_id = nssa::AccountId::from((&node.value.0.nullifier_public_key, identifier)); + let account_id = nssa::AccountId::for_regular_private_account( + &node.value.0.nullifier_public_key, + identifier, + ); if self.account_id_map.contains_key(&account_id) { return None; } @@ -319,6 +322,7 @@ mod tests { use std::{collections::HashSet, str::FromStr as _}; use nssa::AccountId; + use nssa_core::PrivateAccountKind; use super::*; @@ -532,7 +536,7 @@ mod tests { .get_mut(&ChainIndex::from_str("/1").unwrap()) .unwrap(); acc.value.1.push(( - 0, + PrivateAccountKind::Regular(0), nssa::Account { balance: 2, ..nssa::Account::default() @@ -544,7 +548,7 @@ mod tests { .get_mut(&ChainIndex::from_str("/2").unwrap()) .unwrap(); acc.value.1.push(( - 0, + PrivateAccountKind::Regular(0), nssa::Account { balance: 3, ..nssa::Account::default() @@ -556,7 +560,7 @@ mod tests { .get_mut(&ChainIndex::from_str("/0/1").unwrap()) .unwrap(); acc.value.1.push(( - 0, + PrivateAccountKind::Regular(0), nssa::Account { balance: 5, ..nssa::Account::default() @@ -568,7 +572,7 @@ mod tests { .get_mut(&ChainIndex::from_str("/1/0").unwrap()) .unwrap(); acc.value.1.push(( - 0, + PrivateAccountKind::Regular(0), nssa::Account { balance: 6, ..nssa::Account::default() diff --git a/key_protocol/src/key_protocol_core/mod.rs b/key_protocol/src/key_protocol_core/mod.rs index 20bea342..e973655e 100644 --- a/key_protocol/src/key_protocol_core/mod.rs +++ b/key_protocol/src/key_protocol_core/mod.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use anyhow::Result; use k256::AffinePoint; use nssa::{Account, AccountId}; -use nssa_core::Identifier; +use nssa_core::{Identifier, PrivateAccountKind}; use serde::{Deserialize, Serialize}; use crate::key_management::{ @@ -18,7 +18,7 @@ pub type PublicKey = AffinePoint; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserPrivateAccountData { pub key_chain: KeyChain, - pub accounts: Vec<(Identifier, Account)>, + pub accounts: Vec<(PrivateAccountKind, Account)>, } /// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state. @@ -79,10 +79,11 @@ impl NSSAUserData { ) -> bool { let mut check_res = true; for (account_id, entry) in accounts_keys_map { - let any_match = entry.accounts.iter().any(|(identifier, _)| { - nssa::AccountId::from((&entry.key_chain.nullifier_public_key, *identifier)) - == *account_id - }); + let npk = &entry.key_chain.nullifier_public_key; + let any_match = entry + .accounts + .iter() + .any(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == *account_id); if !any_match { println!("No matching entry found for account_id {account_id}"); check_res = false; @@ -184,24 +185,27 @@ impl NSSAUserData { ) -> Option<(KeyChain, nssa_core::account::Account, Identifier)> { // Check default accounts if let Some(entry) = self.default_user_private_accounts.get(&account_id) { - for (identifier, account) in &entry.accounts { - let expected_id = - nssa::AccountId::from((&entry.key_chain.nullifier_public_key, *identifier)); - if expected_id == account_id { - return Some((entry.key_chain.clone(), account.clone(), *identifier)); - } + let npk = &entry.key_chain.nullifier_public_key; + if let Some((kind, account)) = entry + .accounts + .iter() + .find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id) + { + return Some((entry.key_chain.clone(), account.clone(), kind.identifier())); } return None; } // Check tree if let Some(node) = self.private_key_tree.get_node(account_id) { let key_chain = &node.value.0; - for (identifier, account) in &node.value.1 { - let expected_id = - nssa::AccountId::from((&key_chain.nullifier_public_key, *identifier)); - if expected_id == account_id { - return Some((key_chain.clone(), account.clone(), *identifier)); - } + let npk = &key_chain.nullifier_public_key; + if let Some((kind, account)) = node + .value + .1 + .iter() + .find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id) + { + return Some((key_chain.clone(), account.clone(), kind.identifier())); } } None diff --git a/nssa/core/src/circuit_io.rs b/nssa/core/src/circuit_io.rs index f52357ee..63c188ef 100644 --- a/nssa/core/src/circuit_io.rs +++ b/nssa/core/src/circuit_io.rs @@ -30,8 +30,8 @@ pub enum InputAccountIdentity { Public, /// Init of an authorized standalone private account: no membership proof. The `pre_state` /// must be `Account::default()`. The `account_id` is derived as - /// `AccountId::from((&NullifierPublicKey::from(nsk), identifier))` and matched against - /// `pre_state.account_id`. + /// `AccountId::for_regular_private_account(&NullifierPublicKey::from(nsk), identifier)` and + /// matched against `pre_state.account_id`. PrivateAuthorizedInit { ssk: SharedSecretKey, nsk: NullifierSecretKey, @@ -53,19 +53,22 @@ pub enum InputAccountIdentity { identifier: Identifier, }, /// Init of a private PDA, unauthorized. The npk-to-account_id binding is proven upstream - /// via `Claim::Pda(seed)` or a caller's `pda_seeds` match. Identifier is fixed by - /// convention to `PRIVATE_PDA_FIXED_IDENTIFIER` and not carried per-input. + /// via `Claim::Pda(seed)` or a caller's `pda_seeds` match. The identifier diversifies the + /// PDA within the `(program_id, seed, npk)` family: `AccountId::for_private_pda` uses it + /// as the 4th input. PrivatePdaInit { npk: NullifierPublicKey, ssk: SharedSecretKey, + identifier: Identifier, }, /// Update of an existing private PDA, authorized, with membership proof. `npk` is derived /// from `nsk`. Authorization is established upstream by a caller `pda_seeds` match or a - /// previously-seen authorization in a chained call. Identifier is fixed. + /// previously-seen authorization in a chained call. PrivatePdaUpdate { ssk: SharedSecretKey, nsk: NullifierSecretKey, membership_proof: MembershipProof, + identifier: Identifier, }, } @@ -83,13 +86,17 @@ impl InputAccountIdentity { ) } - /// For private PDA variants, return the nullifier public key. `Init` carries it directly; - /// `Update` derives it from `nsk`. For non-PDA variants returns `None`. + /// For private PDA variants, return the `(npk, identifier)` pair. `Init` carries both + /// directly; `Update` derives `npk` from `nsk`. For non-PDA variants returns `None`. #[must_use] - pub fn npk_if_private_pda(&self) -> Option { + pub fn npk_if_private_pda(&self) -> Option<(NullifierPublicKey, Identifier)> { match self { - Self::PrivatePdaInit { npk, .. } => Some(*npk), - Self::PrivatePdaUpdate { nsk, .. } => Some(NullifierPublicKey::from(nsk)), + Self::PrivatePdaInit { + npk, identifier, .. + } => Some((*npk, *identifier)), + Self::PrivatePdaUpdate { + nsk, identifier, .. + } => Some((NullifierPublicKey::from(nsk), *identifier)), Self::Public | Self::PrivateAuthorizedInit { .. } | Self::PrivateAuthorizedUpdate { .. } diff --git a/nssa/core/src/encryption/mod.rs b/nssa/core/src/encryption/mod.rs index 80d62f30..4b675d0e 100644 --- a/nssa/core/src/encryption/mod.rs +++ b/nssa/core/src/encryption/mod.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; #[cfg(feature = "host")] pub use shared_key_derivation::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey}; -use crate::{Commitment, Identifier, account::Account}; +use crate::{Commitment, account::Account, program::PrivateAccountKind}; #[cfg(feature = "host")] pub mod shared_key_derivation; @@ -40,13 +40,14 @@ impl EncryptionScheme { #[must_use] pub fn encrypt( account: &Account, - identifier: Identifier, + kind: &PrivateAccountKind, shared_secret: &SharedSecretKey, commitment: &Commitment, output_index: u32, ) -> Ciphertext { - // Plaintext: identifier (16 bytes, little-endian) || account bytes - let mut buffer = identifier.to_le_bytes().to_vec(); + // Plaintext: PrivateAccountKind::HEADER_LEN bytes header || account bytes. + // Both variants produce the same header length — see PrivateAccountKind::to_header_bytes. + let mut buffer = kind.to_header_bytes().to_vec(); buffer.extend_from_slice(&account.to_bytes()); Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index); Ciphertext(buffer) @@ -89,17 +90,19 @@ impl EncryptionScheme { shared_secret: &SharedSecretKey, commitment: &Commitment, output_index: u32, - ) -> Option<(Identifier, Account)> { + ) -> Option<(PrivateAccountKind, Account)> { use std::io::Cursor; let mut buffer = ciphertext.0.clone(); Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index); - if buffer.len() < 16 { + if buffer.len() < PrivateAccountKind::HEADER_LEN { return None; } - let identifier = Identifier::from_le_bytes(buffer[..16].try_into().unwrap()); + let header: &[u8; PrivateAccountKind::HEADER_LEN] = + buffer[..PrivateAccountKind::HEADER_LEN].try_into().unwrap(); + let kind = PrivateAccountKind::from_header_bytes(header)?; - let mut cursor = Cursor::new(&buffer[16..]); + let mut cursor = Cursor::new(&buffer[PrivateAccountKind::HEADER_LEN..]); Account::from_cursor(&mut cursor) .inspect_err(|err| { println!( @@ -112,6 +115,43 @@ impl EncryptionScheme { ); }) .ok() - .map(|account| (identifier, account)) + .map(|account| (kind, account)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + account::{Account, AccountId}, + program::PdaSeed, + }; + + #[test] + fn encrypt_same_length_for_account_and_pda() { + let account = Account::default(); + let secret = SharedSecretKey([0_u8; 32]); + let commitment = crate::Commitment::new(&AccountId::new([0_u8; 32]), &Account::default()); + + let account_ct = EncryptionScheme::encrypt( + &account, + &PrivateAccountKind::Regular(42), + &secret, + &commitment, + 0, + ); + let pda_ct = EncryptionScheme::encrypt( + &account, + &PrivateAccountKind::Pda { + program_id: [1_u32; 8], + seed: PdaSeed::new([2_u8; 32]), + identifier: 42, + }, + &secret, + &commitment, + 0, + ); + + assert_eq!(account_ct.0.len(), pda_ct.0.len()); } } diff --git a/nssa/core/src/lib.rs b/nssa/core/src/lib.rs index d660aed0..894b611f 100644 --- a/nssa/core/src/lib.rs +++ b/nssa/core/src/lib.rs @@ -12,6 +12,7 @@ pub use commitment::{ }; pub use encryption::{EncryptionScheme, SharedSecretKey}; pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey}; +pub use program::PrivateAccountKind; pub mod account; mod circuit_io; diff --git a/nssa/core/src/nullifier.rs b/nssa/core/src/nullifier.rs index aafe3f7c..ab23ddc0 100644 --- a/nssa/core/src/nullifier.rs +++ b/nssa/core/src/nullifier.rs @@ -12,10 +12,11 @@ pub type Identifier = u128; #[cfg_attr(any(feature = "host", test), derive(Hash))] pub struct NullifierPublicKey(pub [u8; 32]); -impl From<(&NullifierPublicKey, Identifier)> for AccountId { - fn from(value: (&NullifierPublicKey, Identifier)) -> Self { - let (npk, identifier) = value; - +impl AccountId { + /// Derives an [`AccountId`] for a regular (non-PDA) private account from the nullifier public + /// key and identifier. + #[must_use] + pub fn for_regular_private_account(npk: &NullifierPublicKey, identifier: Identifier) -> Self { // 32 bytes prefix || 32 bytes npk || 16 bytes identifier let mut bytes = [0; 80]; bytes[0..32].copy_from_slice(PRIVATE_ACCOUNT_ID_PREFIX); @@ -31,6 +32,12 @@ impl From<(&NullifierPublicKey, Identifier)> for AccountId { } } +impl From<(&NullifierPublicKey, Identifier)> for AccountId { + fn from((npk, identifier): (&NullifierPublicKey, Identifier)) -> Self { + Self::for_regular_private_account(npk, identifier) + } +} + impl AsRef<[u8]> for NullifierPublicKey { fn as_ref(&self) -> &[u8] { self.0.as_slice() @@ -155,7 +162,7 @@ mod tests { 253, 105, 164, 89, 84, 40, 191, 182, 119, 64, 255, 67, 142, ]); - let account_id = AccountId::from((&npk, 0)); + let account_id = AccountId::for_regular_private_account(&npk, 0); assert_eq!(account_id, expected_account_id); } @@ -172,7 +179,7 @@ mod tests { 56, 247, 99, 121, 165, 182, 234, 255, 19, 127, 191, 72, ]); - let account_id = AccountId::from((&npk, 1)); + let account_id = AccountId::for_regular_private_account(&npk, 1); assert_eq!(account_id, expected_account_id); } @@ -190,7 +197,7 @@ mod tests { 19, 245, 25, 214, 162, 209, 135, 252, 82, 27, 2, 174, 196, ]); - let account_id = AccountId::from((&npk, identifier)); + let account_id = AccountId::for_regular_private_account(&npk, identifier); assert_eq!(account_id, expected_account_id); } diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index e4e33932..275b40a6 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -1,12 +1,11 @@ use std::collections::HashSet; -#[cfg(any(feature = "host", test))] use borsh::{BorshDeserialize, BorshSerialize}; use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer}; use serde::{Deserialize, Serialize}; use crate::{ - BlockId, NullifierPublicKey, Timestamp, + BlockId, Identifier, NullifierPublicKey, Timestamp, account::{Account, AccountId, AccountWithMetadata}, }; @@ -27,7 +26,18 @@ pub struct ProgramInput { /// Each program can derive up to `2^256` unique account IDs by choosing different /// seeds. PDAs allow programs to control namespaced account identifiers without /// collisions between programs. -#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)] +#[derive( + Debug, + Clone, + Copy, + Eq, + PartialEq, + Hash, + Serialize, + Deserialize, + BorshSerialize, + BorshDeserialize, +)] pub struct PdaSeed([u8; 32]); impl PdaSeed { @@ -35,6 +45,11 @@ impl PdaSeed { pub const fn new(value: [u8; 32]) -> Self { Self(value) } + + #[must_use] + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } } impl AsRef<[u8]> for PdaSeed { @@ -43,6 +58,55 @@ impl AsRef<[u8]> for PdaSeed { } } +/// Discriminates the type of private account a ciphertext belongs to, carrying the data needed +/// to reconstruct the account's [`AccountId`] on the receiver side. +/// +/// [`AccountId`]: crate::account::AccountId +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +pub enum PrivateAccountKind { + Regular(Identifier), + Pda { + program_id: ProgramId, + seed: PdaSeed, + identifier: Identifier, + }, +} + +impl PrivateAccountKind { + /// Borsh layout (all integers little-endian, variant index is u8): + /// + /// ```text + /// Regular(ident): 0x00 || ident (16 LE) || [0u8; 64] + /// Pda { program_id, seed, ident }: 0x01 || program_id (32) || seed (32) || ident (16 LE) + /// ``` + /// + /// Both variants are zero-padded to the same length so all ciphertexts are the same size, + /// preventing observers from distinguishing `Regular` from `Pda` via ciphertext length. + /// `HEADER_LEN` equals the borsh size of the largest variant (`Pda`): 1 + 32 + 32 + 16 = 81. + pub const HEADER_LEN: usize = 81; + + #[must_use] + pub const fn identifier(&self) -> Identifier { + match self { + Self::Regular(identifier) | Self::Pda { identifier, .. } => *identifier, + } + } + + #[must_use] + pub fn to_header_bytes(&self) -> [u8; Self::HEADER_LEN] { + let mut bytes = [0_u8; Self::HEADER_LEN]; + let serialized = borsh::to_vec(self).expect("borsh serialization is infallible"); + bytes[..serialized.len()].copy_from_slice(&serialized); + bytes + } + + #[cfg(feature = "host")] + #[must_use] + pub fn from_header_bytes(bytes: &[u8; Self::HEADER_LEN]) -> Option { + BorshDeserialize::deserialize(&mut bytes.as_ref()).ok() + } +} + impl AccountId { /// Derives an [`AccountId`] for a public PDA from the program ID and seed. #[must_use] @@ -65,27 +129,31 @@ impl AccountId { ) } - /// Derives an [`AccountId`] for a private PDA from the program ID, seed, and nullifier - /// public key. + /// Derives an [`AccountId`] for a private PDA from the program ID, seed, nullifier public + /// key, and identifier. /// /// Unlike public PDAs ([`AccountId::for_public_pda`]), this includes the `npk` in the /// derivation, making the address unique per group of controllers sharing viewing keys. + /// The `identifier` further diversifies the address, so a single `(program_id, seed, npk)` + /// tuple controls a family of 2^128 addresses. #[must_use] pub fn for_private_pda( program_id: &ProgramId, seed: &PdaSeed, npk: &NullifierPublicKey, + identifier: Identifier, ) -> Self { use risc0_zkvm::sha::{Impl, Sha256 as _}; const PRIVATE_PDA_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/PrivatePDA/\x00"; - let mut bytes = [0_u8; 128]; + let mut bytes = [0_u8; 144]; bytes[0..32].copy_from_slice(PRIVATE_PDA_PREFIX); let program_id_bytes: &[u8] = bytemuck::try_cast_slice(program_id).expect("ProgramId should be castable to &[u8]"); bytes[32..64].copy_from_slice(program_id_bytes); bytes[64..96].copy_from_slice(&seed.0); bytes[96..128].copy_from_slice(&npk.to_byte_array()); + bytes[128..144].copy_from_slice(&identifier.to_le_bytes()); Self::new( Impl::hash_bytes(&bytes) .as_bytes() @@ -93,6 +161,21 @@ impl AccountId { .expect("Hash output must be exactly 32 bytes long"), ) } + + /// Derives the [`AccountId`] for a private account from the nullifier public key and kind. + #[must_use] + pub fn for_private_account(npk: &NullifierPublicKey, kind: &PrivateAccountKind) -> Self { + match kind { + PrivateAccountKind::Regular(identifier) => { + Self::for_regular_private_account(npk, *identifier) + } + PrivateAccountKind::Pda { + program_id, + seed, + identifier, + } => Self::for_private_pda(program_id, seed, npk, *identifier), + } + } } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] @@ -851,19 +934,20 @@ mod tests { // ---- AccountId::for_private_pda tests ---- /// Pins `AccountId::for_private_pda` against a hardcoded expected output for a specific - /// `(program_id, seed, npk)` triple. Any change to `PRIVATE_PDA_PREFIX`, byte ordering, - /// or the underlying hash breaks this test. + /// `(program_id, seed, npk, identifier)` tuple. Any change to `PRIVATE_PDA_PREFIX`, byte + /// ordering, or the underlying hash breaks this test. #[test] fn for_private_pda_matches_pinned_value() { let program_id: ProgramId = [1; 8]; let seed = PdaSeed::new([2; 32]); let npk = NullifierPublicKey([3; 32]); + let identifier: Identifier = u128::MAX; let expected = AccountId::new([ - 132, 198, 103, 173, 244, 211, 188, 217, 249, 99, 126, 205, 152, 120, 192, 47, 13, 53, - 133, 3, 17, 69, 92, 243, 140, 94, 182, 211, 218, 75, 215, 45, + 59, 239, 182, 97, 14, 220, 96, 115, 238, 133, 143, 33, 234, 82, 237, 255, 148, 110, 54, + 124, 98, 159, 245, 101, 146, 182, 150, 54, 37, 62, 25, 17, ]); assert_eq!( - AccountId::for_private_pda(&program_id, &seed, &npk), + AccountId::for_private_pda(&program_id, &seed, &npk, identifier), expected ); } @@ -876,8 +960,8 @@ mod tests { let npk_a = NullifierPublicKey([3; 32]); let npk_b = NullifierPublicKey([4; 32]); assert_ne!( - AccountId::for_private_pda(&program_id, &seed, &npk_a), - AccountId::for_private_pda(&program_id, &seed, &npk_b), + AccountId::for_private_pda(&program_id, &seed, &npk_a, u128::MAX), + AccountId::for_private_pda(&program_id, &seed, &npk_b, u128::MAX), ); } @@ -889,8 +973,8 @@ mod tests { let seed_b = PdaSeed::new([5; 32]); let npk = NullifierPublicKey([3; 32]); assert_ne!( - AccountId::for_private_pda(&program_id, &seed_a, &npk), - AccountId::for_private_pda(&program_id, &seed_b, &npk), + AccountId::for_private_pda(&program_id, &seed_a, &npk, u128::MAX), + AccountId::for_private_pda(&program_id, &seed_b, &npk, u128::MAX), ); } @@ -902,8 +986,25 @@ mod tests { let seed = PdaSeed::new([2; 32]); let npk = NullifierPublicKey([3; 32]); assert_ne!( - AccountId::for_private_pda(&program_id_a, &seed, &npk), - AccountId::for_private_pda(&program_id_b, &seed, &npk), + AccountId::for_private_pda(&program_id_a, &seed, &npk, u128::MAX), + AccountId::for_private_pda(&program_id_b, &seed, &npk, u128::MAX), + ); + } + + /// Different identifiers produce different addresses for the same `(program_id, seed, npk)`, + /// confirming that each `(program_id, seed, npk)` tuple 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), ); } @@ -914,14 +1015,62 @@ mod tests { let program_id: ProgramId = [1; 8]; let seed = PdaSeed::new([2; 32]); let npk = NullifierPublicKey([3; 32]); - let private_id = AccountId::for_private_pda(&program_id, &seed, &npk); + let private_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX); let public_id = AccountId::for_public_pda(&program_id, &seed); assert_ne!(private_id, public_id); } - // ---- compute_public_authorized_pdas tests ---- + #[cfg(feature = "host")] + #[test] + fn private_account_kind_header_round_trips() { + let regular = PrivateAccountKind::Regular(42); + let pda = PrivateAccountKind::Pda { + program_id: [1_u32; 8], + seed: PdaSeed::new([2_u8; 32]), + identifier: u128::MAX, + }; + assert_eq!( + PrivateAccountKind::from_header_bytes(®ular.to_header_bytes()), + Some(regular) + ); + assert_eq!( + PrivateAccountKind::from_header_bytes(&pda.to_header_bytes()), + Some(pda) + ); + } + + #[cfg(feature = "host")] + #[test] + fn private_account_kind_unknown_discriminant_returns_none() { + let mut bytes = [0_u8; PrivateAccountKind::HEADER_LEN]; + bytes[0] = 0xFF; + assert_eq!(PrivateAccountKind::from_header_bytes(&bytes), None); + } + + #[test] + fn for_private_account_dispatches_correctly() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let identifier: Identifier = 77; + + assert_eq!( + AccountId::for_private_account(&npk, &PrivateAccountKind::Regular(identifier)), + AccountId::for_regular_private_account(&npk, identifier), + ); + assert_eq!( + AccountId::for_private_account( + &npk, + &PrivateAccountKind::Pda { + program_id, + seed, + identifier + } + ), + AccountId::for_private_pda(&program_id, &seed, &npk, identifier), + ); + } - /// `compute_public_authorized_pdas` returns the public PDA addresses for the caller's seeds. #[test] fn compute_public_authorized_pdas_with_seeds() { let caller: ProgramId = [1; 8]; diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index ff23647e..09e37664 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -176,9 +176,10 @@ mod tests { #![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")] use nssa_core::{ - Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, SharedSecretKey, + Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, + PrivacyPreservingCircuitOutput, SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, - program::PdaSeed, + program::{PdaSeed, PrivateAccountKind}, }; use super::*; @@ -192,6 +193,21 @@ mod tests { }, }; + fn decrypt_kind( + output: &PrivacyPreservingCircuitOutput, + ssk: &SharedSecretKey, + idx: usize, + ) -> PrivateAccountKind { + let (kind, _) = EncryptionScheme::decrypt( + &output.ciphertexts[idx], + ssk, + &output.new_commitments[idx], + u32::try_from(idx).expect("idx fits in u32"), + ) + .unwrap(); + kind + } + #[test] fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() { let recipient_keys = test_private_account_keys_1(); @@ -206,7 +222,7 @@ mod tests { AccountId::new([0; 32]), ); - let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0)); + let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); let balance_to_move: u128 = 37; @@ -280,12 +296,12 @@ mod tests { data: Data::default(), }, true, - AccountId::from((&sender_keys.npk(), 0)), + AccountId::for_regular_private_account(&sender_keys.npk(), 0), ); - let sender_account_id = AccountId::from((&sender_keys.npk(), 0)); + let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); let commitment_sender = Commitment::new(&sender_account_id, &sender_pre.account); - let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0)); + let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id); let balance_to_move: u128 = 37; @@ -381,7 +397,7 @@ mod tests { let pre = AccountWithMetadata::new( Account::default(), false, - AccountId::from((&account_keys.npk(), 0)), + AccountId::for_regular_private_account(&account_keys.npk(), 0), ); let validity_window_chain_caller = Program::validity_window_chain_caller(); @@ -418,6 +434,42 @@ 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![InputAccountIdentity::PrivatePdaInit { + npk, + ssk: shared_secret, + identifier, + }], + &program.clone().into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &shared_secret, 0), + PrivateAccountKind::Pda { + program_id: program.id(), + seed, + identifier + }, + ); + } + /// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership. /// The `auth_transfer_proxy` program chains to `authenticated_transfer` with `pda_seeds` /// to establish authorization and the private PDA binding. @@ -430,8 +482,8 @@ mod tests { let seed = PdaSeed::new([42; 32]); let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); - // PDA (new, private PDA) — AccountId derived from auth_transfer_proxy's program ID - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + // PDA (new, mask 3) + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); let auth_id = auth_transfer.id(); @@ -447,6 +499,7 @@ mod tests { vec![InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret_pda, + identifier: 0, }], &program_with_deps, ); @@ -468,7 +521,7 @@ mod tests { let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk()); // PDA (new, private PDA) - let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0); let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id); // Recipient (public) @@ -497,6 +550,7 @@ mod tests { InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret_pda, + identifier: 0, }, InputAccountIdentity::Public, ], @@ -557,4 +611,246 @@ mod tests { // Sender is public (no commitment), recipient is private (1 commitment) assert_eq!(output.new_commitments.len(), 1); } + + /// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts + /// to `PrivateAccountKind::Regular` carrying the correct identifier. + #[test] + fn private_authorized_init_encrypts_regular_kind_with_identifier() { + let program = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk()); + let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier); + let pre = AccountWithMetadata::new(Account::default(), true, account_id); + + let (output, _) = execute_and_prove( + vec![pre], + Program::serialize_instruction(0_u128).unwrap(), + vec![InputAccountIdentity::PrivateAuthorizedInit { + ssk, + nsk: keys.nsk, + identifier, + }], + &program.into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Regular(identifier) + ); + } + + /// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts + /// to `PrivateAccountKind::Regular` carrying the correct identifier. + #[test] + fn private_unauthorized_init_encrypts_regular_kind_with_identifier() { + let program = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + let sender = AccountWithMetadata::new( + Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }, + true, + AccountId::new([0; 32]), + ); + let recipient_id = AccountId::for_regular_private_account(&keys.npk(), identifier); + let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id); + + let (output, _) = execute_and_prove( + vec![sender, recipient], + Program::serialize_instruction(1_u128).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::PrivateUnauthorized { + npk: keys.npk(), + ssk, + identifier, + }, + ], + &program.into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Regular(identifier) + ); + } + + /// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts + /// to `PrivateAccountKind::Regular` carrying the correct identifier. + #[test] + fn private_authorized_update_encrypts_regular_kind_with_identifier() { + let program = Program::authenticated_transfer_program(); + let keys = test_private_account_keys_1(); + let identifier: u128 = 99; + let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk()); + let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier); + let account = Account { + program_owner: program.id(), + balance: 1, + ..Account::default() + }; + let commitment = Commitment::new(&account_id, &account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&commitment)); + + let sender = AccountWithMetadata::new(account, true, account_id); + let recipient = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); + + let (output, _) = execute_and_prove( + vec![sender, recipient], + Program::serialize_instruction(1_u128).unwrap(), + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&commitment).unwrap(), + identifier, + }, + InputAccountIdentity::Public, + ], + &program.into(), + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Regular(identifier) + ); + } + + /// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts + /// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`. + #[test] + fn private_pda_update_encrypts_pda_kind_with_identifier() { + let program = Program::pda_fund_spend_proxy(); + 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 identifier: u128 = 99; + let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + let auth_transfer_id = auth_transfer.id(); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier); + let pda_account = Account { + program_owner: auth_transfer_id, + balance: 1, + ..Account::default() + }; + let pda_commitment = Commitment::new(&pda_id, &pda_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&pda_commitment)); + + let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); + let recipient_pre = + AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); + + let program_with_deps = ProgramWithDependencies::new( + program.clone(), + [(auth_transfer_id, auth_transfer)].into(), + ); + + let (output, _) = execute_and_prove( + vec![pda_pre, recipient_pre], + Program::serialize_instruction((seed, 1_u128, auth_transfer_id, false)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + ssk, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), + identifier, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .unwrap(); + + assert_eq!( + decrypt_kind(&output, &ssk, 0), + PrivateAccountKind::Pda { + program_id: program.id(), + seed, + identifier + }, + ); + } + + #[test] + fn private_pda_init_identifier_mismatch_fails() { + 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()); + + let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5); + 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: 99, + }], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + #[test] + fn private_pda_update_identifier_mismatch_fails() { + let program = Program::pda_fund_spend_proxy(); + 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 ssk = SharedSecretKey::new(&[55; 32], &keys.vpk()); + + let auth_transfer_id = auth_transfer.id(); + let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5); + let pda_account = Account { + program_owner: auth_transfer_id, + balance: 1, + ..Account::default() + }; + let pda_commitment = Commitment::new(&pda_id, &pda_account); + let mut commitment_set = CommitmentSet::with_capacity(1); + commitment_set.extend(std::slice::from_ref(&pda_commitment)); + + let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id); + let recipient_pre = + AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32])); + + let program_with_deps = + ProgramWithDependencies::new(program, [(auth_transfer_id, auth_transfer)].into()); + + let result = execute_and_prove( + vec![pda_pre, recipient_pre], + Program::serialize_instruction((seed, 1_u128, auth_transfer_id, false)).unwrap(), + vec![ + InputAccountIdentity::PrivatePdaUpdate { + ssk, + nsk: keys.nsk, + membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(), + identifier: 99, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } } diff --git a/nssa/src/privacy_preserving_transaction/message.rs b/nssa/src/privacy_preserving_transaction/message.rs index 697f66ac..d86273d8 100644 --- a/nssa/src/privacy_preserving_transaction/message.rs +++ b/nssa/src/privacy_preserving_transaction/message.rs @@ -140,7 +140,8 @@ impl Message { #[cfg(test)] pub mod tests { use nssa_core::{ - Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, SharedSecretKey, + Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind, + SharedSecretKey, account::{Account, AccountId, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, program::{BlockValidityWindow, TimestampValidityWindow}, @@ -168,10 +169,10 @@ pub mod tests { let encrypted_private_post_states = Vec::new(); - let account_id2 = nssa_core::account::AccountId::from((&npk2, 0)); + let account_id2 = nssa_core::account::AccountId::for_regular_private_account(&npk2, 0); let new_commitments = vec![Commitment::new(&account_id2, &account2)]; - let account_id1 = nssa_core::account::AccountId::from((&npk1, 0)); + let account_id1 = nssa_core::account::AccountId::for_regular_private_account(&npk1, 0); let old_commitment = Commitment::new(&account_id1, &account1); let new_nullifiers = vec![( Nullifier::for_account_update(&old_commitment, &nsk1), @@ -247,12 +248,18 @@ pub mod tests { let npk = NullifierPublicKey::from(&[1; 32]); let vpk = ViewingPublicKey::from_scalar([2; 32]); let account = Account::default(); - let account_id = nssa_core::account::AccountId::from((&npk, 0)); + let account_id = nssa_core::account::AccountId::for_regular_private_account(&npk, 0); let commitment = Commitment::new(&account_id, &account); let esk = [3; 32]; let shared_secret = SharedSecretKey::new(&esk, &vpk); let epk = EphemeralPublicKey::from_scalar(esk); - let ciphertext = EncryptionScheme::encrypt(&account, 0, &shared_secret, &commitment, 2); + let ciphertext = EncryptionScheme::encrypt( + &account, + &PrivateAccountKind::Regular(0), + &shared_secret, + &commitment, + 2, + ); let encrypted_account_data = EncryptedAccountData::new(ciphertext.clone(), &npk, &vpk, epk.clone()); diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 23003a92..059aa5ca 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -332,6 +332,16 @@ mod tests { } } + #[must_use] + pub fn pda_fund_spend_proxy() -> Self { + use test_program_methods::{PDA_FUND_SPEND_PROXY_ELF, PDA_FUND_SPEND_PROXY_ID}; + + Self { + id: PDA_FUND_SPEND_PROXY_ID, + elf: PDA_FUND_SPEND_PROXY_ELF.to_vec(), + } + } + #[must_use] pub fn changer_claimer() -> Self { use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID}; diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 9e4d8524..8cf1d68e 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -459,7 +459,7 @@ pub mod tests { #[must_use] pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self { - let account_id = AccountId::from((&keys.npk(), 0)); + let account_id = AccountId::for_regular_private_account(&keys.npk(), 0); let commitment = Commitment::new(&account_id, account); self.private_state.0.extend(&[commitment]); self @@ -618,8 +618,8 @@ pub mod tests { ..Account::default() }; - let account_id1 = AccountId::from((&keys1.npk(), 0)); - let account_id2 = AccountId::from((&keys2.npk(), 0)); + let account_id1 = AccountId::for_regular_private_account(&keys1.npk(), 0); + let account_id2 = AccountId::for_regular_private_account(&keys2.npk(), 0); let init_commitment1 = Commitment::new(&account_id1, &account); let init_commitment2 = Commitment::new(&account_id2, &account); @@ -1256,6 +1256,12 @@ pub mod tests { } } + fn test_public_account_keys_2() -> TestPublicKeys { + TestPublicKeys { + signing_key: PrivateKey::try_new([38; 32]).unwrap(), + } + } + pub fn test_private_account_keys_1() -> TestPrivateKeys { TestPrivateKeys { nsk: [13; 32], @@ -1326,7 +1332,7 @@ pub mod tests { state: &V03State, ) -> PrivacyPreservingTransaction { let program = Program::authenticated_transfer_program(); - let sender_account_id = AccountId::from((&sender_keys.npk(), 0)); + let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); let sender_commitment = Commitment::new(&sender_account_id, sender_private_account); let sender_pre = AccountWithMetadata::new( sender_private_account.clone(), @@ -1390,7 +1396,7 @@ pub mod tests { state: &V03State, ) -> PrivacyPreservingTransaction { let program = Program::authenticated_transfer_program(); - let sender_account_id = AccountId::from((&sender_keys.npk(), 0)); + let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); let sender_commitment = Commitment::new(&sender_account_id, sender_private_account); let sender_pre = AccountWithMetadata::new( sender_private_account.clone(), @@ -1505,8 +1511,8 @@ pub mod tests { &state, ); - let sender_account_id = AccountId::from((&sender_keys.npk(), 0)); - let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0)); + let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); + let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0); let expected_new_commitment_1 = Commitment::new( &sender_account_id, &Account { @@ -1584,7 +1590,7 @@ pub mod tests { &state, ); - let sender_account_id = AccountId::from((&sender_keys.npk(), 0)); + let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0); let expected_new_commitment = Commitment::new( &sender_account_id, &Account { @@ -2185,6 +2191,7 @@ pub mod tests { InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret, + identifier: u128::MAX, }, ], &program.into(), @@ -2206,7 +2213,7 @@ pub mod tests { let seed = PdaSeed::new([42; 32]); let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + 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( @@ -2215,6 +2222,7 @@ pub mod tests { vec![InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret, + identifier: u128::MAX, }], &program.into(), ); @@ -2244,7 +2252,7 @@ pub mod tests { // `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. - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk_a); + 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( @@ -2253,6 +2261,7 @@ pub mod tests { vec![InputAccountIdentity::PrivatePdaInit { npk: npk_b, ssk: shared_secret, + identifier: u128::MAX, }], &program.into(), ); @@ -2274,7 +2283,7 @@ pub mod tests { let seed = PdaSeed::new([77; 32]); let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); - let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk); + let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, u128::MAX); let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); let callee_id = callee.id(); @@ -2287,6 +2296,7 @@ pub mod tests { vec![InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret, + identifier: u128::MAX, }], &program_with_deps, ); @@ -2311,7 +2321,7 @@ pub mod tests { let wrong_delegated_seed = PdaSeed::new([88; 32]); let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); - let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk); + let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, u128::MAX); let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); let callee_id = callee.id(); @@ -2324,6 +2334,7 @@ pub mod tests { vec![InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret, + identifier: u128::MAX, }], &program_with_deps, ); @@ -2348,8 +2359,8 @@ pub mod tests { let shared_a = SharedSecretKey::new(&[66; 32], &keys_a.vpk()); let shared_b = SharedSecretKey::new(&[77; 32], &keys_b.vpk()); - let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk()); - let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk()); + 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); @@ -2361,10 +2372,12 @@ pub mod tests { 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(), @@ -2394,7 +2407,7 @@ pub mod tests { // Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = // true, account_id derived via the private formula. - let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk); + let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX); let owned_pre_state = AccountWithMetadata::new( Account { program_owner: program.id(), @@ -2410,6 +2423,7 @@ pub mod tests { vec![InputAccountIdentity::PrivatePdaInit { npk, ssk: shared_secret, + identifier: u128::MAX, }], &program.into(), ); @@ -2812,7 +2826,7 @@ pub mod tests { balance: 100, ..Account::default() }; - let sender_account_id = AccountId::from((&sender_keys.npk(), 0)); + let sender_account_id = AccountId::for_regular_private_account(&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( @@ -2905,8 +2919,8 @@ pub mod tests { (&to_keys.npk(), 0), ); - let from_account_id = AccountId::from((&from_keys.npk(), 0)); - let to_account_id = AccountId::from((&to_keys.npk(), 0)); + let from_account_id = AccountId::for_regular_private_account(&from_keys.npk(), 0); + let to_account_id = AccountId::for_regular_private_account(&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); @@ -3266,7 +3280,7 @@ pub mod tests { 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 account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0); let nullifier = Nullifier::for_account_initialization(&account_id); assert!(state.private_state.1.contains(&nullifier)); } @@ -3315,7 +3329,7 @@ pub mod tests { .transition_from_privacy_preserving_transaction(&tx, 1, 0) .unwrap(); - let account_id = AccountId::from((&private_keys.npk(), 0)); + let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0); let nullifier = Nullifier::for_account_initialization(&account_id); assert!(state.private_state.1.contains(&nullifier)); } @@ -3372,7 +3386,7 @@ pub mod tests { ); // Verify the account is now initialized (nullifier exists) - let account_id = AccountId::from((&private_keys.npk(), 0)); + let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0); let nullifier = Nullifier::for_account_initialization(&account_id); assert!(state.private_state.1.contains(&nullifier)); @@ -3527,7 +3541,7 @@ pub mod tests { let recipient_account = AccountWithMetadata::new(Account::default(), true, (&recipient_keys.npk(), 0)); - let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0)); + let recipient_account_id = AccountId::for_regular_private_account(&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); @@ -4286,4 +4300,225 @@ pub mod tests { "program with spoofed caller_program_id in output should be rejected" ); } + + #[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::pda_fund_spend_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()); + + // Fund alice_pda_0 + { + 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, + identifier: 0, + }, + ], + &program_with_deps, + ) + .unwrap(); + let message = Message::try_from_circuit_output( + vec![funder_id], + vec![funder_nonce], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([10; 32]), + )], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); + state + .transition_from_privacy_preserving_transaction( + &PrivacyPreservingTransaction::new(message, witness_set), + 1, + 0, + ) + .unwrap(); + } + + // Fund alice_pda_1 + { + 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, + identifier: 1, + }, + ], + &program_with_deps, + ) + .unwrap(); + let message = Message::try_from_circuit_output( + vec![funder_id], + vec![funder_nonce], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([11; 32]), + )], + output, + ) + .unwrap(); + let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]); + 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)], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([10; 32]), + )], + 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![], + vec![( + alice_npk, + alice_keys.vpk(), + EphemeralPublicKey::from_scalar([11; 32]), + )], + 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); + } } diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs index f658ea53..16ad56d2 100644 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -1,12 +1,13 @@ use std::{ - collections::{HashMap, HashSet, VecDeque, hash_map::Entry}, + collections::{HashMap, VecDeque, hash_map::Entry}, convert::Infallible, }; use nssa_core::{ Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptionScheme, Identifier, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, - PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, SharedSecretKey, + PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, PrivateAccountKind, + SharedSecretKey, account::{Account, AccountId, AccountWithMetadata, Nonce}, compute_digest_for_path, program::{ @@ -17,25 +18,26 @@ use nssa_core::{ }; use risc0_zkvm::{guest::env, serde::to_vec}; -const PRIVATE_PDA_FIXED_IDENTIFIER: Identifier = u128::MAX; - /// State of the involved accounts before and after program execution. struct ExecutionState { pre_states: Vec, post_states: HashMap, block_validity_window: BlockValidityWindow, timestamp_validity_window: TimestampValidityWindow, - /// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound - /// to their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk)` - /// check. + /// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound to + /// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk, + /// identifier)` check. /// Two proof paths populate this set: a `Claim::Pda(seed)` in a program's `post_state` on /// that `pre_state`, or a caller's `ChainedCall.pda_seeds` entry matching that `pre_state` /// under the private derivation. Binding is an idempotent property, not an event: the same /// position can legitimately be bound through both paths in the same tx (e.g. a program - /// claims a private PDA and then delegates it to a callee), and the set uses `contains`, - /// not `assert!(insert)`. After the main loop, every private-PDA position must appear in - /// this set; otherwise the npk is unbound and the circuit rejects. - private_pda_bound_positions: HashSet, + /// claims a private PDA and then delegates it to a callee), and the map uses `contains_key`, + /// not `assert!(insert)`. After the main loop, every private-PDA position must appear in this + /// map; otherwise the npk is unbound and the circuit rejects. + /// The stored `(ProgramId, PdaSeed)` is the owner program and seed, used in + /// `compute_circuit_output` to construct `PrivateAccountKind::Pda { program_id, seed, + /// identifier }`. + private_pda_bound_positions: HashMap, /// Across the whole transaction, each `(program_id, seed)` pair may resolve to at most one /// `AccountId`. A seed under a program can derive a family of accounts, one public PDA and /// one private PDA per distinct npk. Without this check, a single `pda_seeds: [S]` entry in @@ -45,12 +47,12 @@ struct ExecutionState { /// `AccountId` entry or as an equality check against the existing one, making the rule: one /// `(program, seed)` → one account per tx. pda_family_binding: HashMap<(ProgramId, PdaSeed), AccountId>, - /// Map from a private-PDA `pre_state`'s position in `account_identities` to the npk that - /// variant supplies for that position. Populated once in `derive_from_outputs` by walking + /// Map from a private-PDA `pre_state`'s position in `account_identities` to the (npk, + /// identifier) supplied for that position. Built once in `derive_from_outputs` by walking /// `account_identities` and consulting `npk_if_private_pda`. Used later by the claim and /// caller-seeds authorization paths to verify - /// `AccountId::for_private_pda(program_id, seed, npk) == pre_state.account_id`. - private_pda_npk_by_position: HashMap, + /// `AccountId::for_private_pda(program_id, seed, npk, identifier) == pre_state.account_id`. + private_pda_npk_by_position: HashMap, } impl ExecutionState { @@ -60,14 +62,15 @@ impl ExecutionState { program_id: ProgramId, program_outputs: Vec, ) -> Self { - // Build position → npk map for private-PDA pre_states, indexed by position in - // `account_identities`. The vec is documented as 1:1 with the program's pre_state order, - // so position here matches `pre_state_position` used downstream in + // Build position → (npk, identifier) map for private-PDA pre_states, indexed by position + // in `account_identities`. The vec is documented as 1:1 with the program's pre_state + // order, so position here matches `pre_state_position` used downstream in // `validate_and_sync_states`. - let mut private_pda_npk_by_position: HashMap = HashMap::new(); + let mut private_pda_npk_by_position: HashMap = + HashMap::new(); for (pos, account_identity) in account_identities.iter().enumerate() { - if let Some(npk) = account_identity.npk_if_private_pda() { - private_pda_npk_by_position.insert(pos, npk); + if let Some((npk, identifier)) = account_identity.npk_if_private_pda() { + private_pda_npk_by_position.insert(pos, (npk, identifier)); } } @@ -105,7 +108,7 @@ impl ExecutionState { post_states: HashMap::new(), block_validity_window, timestamp_validity_window, - private_pda_bound_positions: HashSet::new(), + private_pda_bound_positions: HashMap::new(), pda_family_binding: HashMap::new(), private_pda_npk_by_position, }; @@ -208,7 +211,9 @@ impl ExecutionState { for (pos, account_identity) in account_identities.iter().enumerate() { if account_identity.is_private_pda() { assert!( - execution_state.private_pda_bound_positions.contains(&pos), + execution_state + .private_pda_bound_positions + .contains_key(&pos), "private PDA pre_state at position {pos} has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds" ); } @@ -353,18 +358,24 @@ impl ExecutionState { ); } Claim::Pda(seed) => { - let npk = self + let (npk, identifier) = self .private_pda_npk_by_position .get(&pre_state_position) .expect( "private PDA pre_state must have an npk in the position map", ); - let pda = AccountId::for_private_pda(&program_id, &seed, npk); + let pda = + AccountId::for_private_pda(&program_id, &seed, npk, *identifier); assert_eq!( pre_account_id, pda, "Invalid private PDA claim for account {pre_account_id}" ); - self.private_pda_bound_positions.insert(pre_state_position); + bind_private_pda_position( + &mut self.private_pda_bound_positions, + pre_state_position, + program_id, + seed, + ); assert_family_binding( &mut self.pda_family_binding, program_id, @@ -428,6 +439,24 @@ fn assert_family_binding( } } +fn bind_private_pda_position( + map: &mut HashMap, + position: usize, + program_id: ProgramId, + seed: PdaSeed, +) { + match map.entry(position) { + Entry::Occupied(e) => assert_eq!( + *e.get(), + (program_id, seed), + "Duplicate binding at position {position}: conflicting (program_id, seed)" + ), + Entry::Vacant(e) => { + e.insert((program_id, seed)); + } + } +} + /// Resolve the authorization state of a `pre_state` seen again in a chained call and record /// any resulting bindings. Returns `true` if the `pre_state` is authorized through either a /// previously-seen authorization or a matching caller seed (under the public or private @@ -443,8 +472,8 @@ fn assert_family_binding( )] fn resolve_authorization_and_record_bindings( pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>, - private_pda_bound_positions: &mut HashSet, - private_pda_npk_by_position: &HashMap, + private_pda_bound_positions: &mut HashMap, + private_pda_npk_by_position: &HashMap, pre_account_id: AccountId, pre_state_position: usize, caller_program_id: Option, @@ -457,8 +486,9 @@ fn resolve_authorization_and_record_bindings( if AccountId::for_public_pda(&caller, seed) == pre_account_id { return Some((*seed, false, caller)); } - if let Some(npk) = private_pda_npk_by_position.get(&pre_state_position) - && AccountId::for_private_pda(&caller, seed, npk) == pre_account_id + if let Some((npk, identifier)) = + private_pda_npk_by_position.get(&pre_state_position) + && AccountId::for_private_pda(&caller, seed, npk, *identifier) == pre_account_id { return Some((*seed, true, caller)); } @@ -469,7 +499,12 @@ fn resolve_authorization_and_record_bindings( if let Some((seed, is_private_form, caller)) = matched_caller_seed { assert_family_binding(pda_family_binding, caller, seed, pre_account_id); if is_private_form { - private_pda_bound_positions.insert(pre_state_position); + bind_private_pda_position( + private_pda_bound_positions, + pre_state_position, + caller, + seed, + ); } } @@ -477,7 +512,7 @@ fn resolve_authorization_and_record_bindings( } fn compute_circuit_output( - execution_state: ExecutionState, + mut execution_state: ExecutionState, account_identities: &[InputAccountIdentity], ) -> PrivacyPreservingCircuitOutput { let mut output = PrivacyPreservingCircuitOutput { @@ -490,6 +525,7 @@ fn compute_circuit_output( timestamp_validity_window: execution_state.timestamp_validity_window, }; + let pda_seed_by_position = std::mem::take(&mut execution_state.private_pda_bound_positions); let states_iter = execution_state.into_states_iter(); assert_eq!( account_identities.len(), @@ -498,7 +534,9 @@ fn compute_circuit_output( ); let mut output_index = 0; - for (account_identity, (pre_state, post_state)) in account_identities.iter().zip(states_iter) { + for (pos, (account_identity, (pre_state, post_state))) in + account_identities.iter().zip(states_iter).enumerate() + { match account_identity { InputAccountIdentity::Public => { output.public_pre_states.push(pre_state); @@ -509,12 +547,8 @@ fn compute_circuit_output( nsk, identifier, } => { - assert_ne!( - *identifier, PRIVATE_PDA_FIXED_IDENTIFIER, - "Identifier must be different from {PRIVATE_PDA_FIXED_IDENTIFIER}. This is reserved for private PDA." - ); let npk = NullifierPublicKey::from(nsk); - let account_id = AccountId::from((&npk, *identifier)); + let account_id = AccountId::for_regular_private_account(&npk, *identifier); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); assert!( @@ -538,7 +572,7 @@ fn compute_circuit_output( &mut output_index, post_state, &account_id, - *identifier, + &PrivateAccountKind::Regular(*identifier), ssk, new_nullifier, new_nonce, @@ -550,12 +584,8 @@ fn compute_circuit_output( membership_proof, identifier, } => { - assert_ne!( - *identifier, PRIVATE_PDA_FIXED_IDENTIFIER, - "Identifier must be different from {PRIVATE_PDA_FIXED_IDENTIFIER}. This is reserved for private PDA." - ); let npk = NullifierPublicKey::from(nsk); - let account_id = AccountId::from((&npk, *identifier)); + let account_id = AccountId::for_regular_private_account(&npk, *identifier); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); assert!( @@ -576,7 +606,7 @@ fn compute_circuit_output( &mut output_index, post_state, &account_id, - *identifier, + &PrivateAccountKind::Regular(*identifier), ssk, new_nullifier, new_nonce, @@ -587,11 +617,7 @@ fn compute_circuit_output( ssk, identifier, } => { - assert_ne!( - *identifier, PRIVATE_PDA_FIXED_IDENTIFIER, - "Identifier must be different from {PRIVATE_PDA_FIXED_IDENTIFIER}. This is reserved for private PDA." - ); - let account_id = AccountId::from((npk, *identifier)); + let account_id = AccountId::for_regular_private_account(npk, *identifier); assert_eq!(account_id, pre_state.account_id, "AccountId mismatch"); assert_eq!( @@ -615,13 +641,17 @@ fn compute_circuit_output( &mut output_index, post_state, &account_id, - *identifier, + &PrivateAccountKind::Regular(*identifier), ssk, new_nullifier, new_nonce, ); } - InputAccountIdentity::PrivatePdaInit { npk: _, ssk } => { + InputAccountIdentity::PrivatePdaInit { + npk: _, + ssk, + identifier, + } => { // The npk-to-account_id binding is established upstream in // `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds` // match. Here we only enforce the init pre-conditions. The supplied npk on @@ -645,12 +675,19 @@ fn compute_circuit_output( let new_nonce = Nonce::private_account_nonce_init(&pre_state.account_id); let account_id = pre_state.account_id; + let (pda_program_id, seed) = pda_seed_by_position + .get(&pos) + .expect("PrivatePdaInit position must be in pda_seed_by_position"); emit_private_output( &mut output, &mut output_index, post_state, &account_id, - PRIVATE_PDA_FIXED_IDENTIFIER, + &PrivateAccountKind::Pda { + program_id: *pda_program_id, + seed: *seed, + identifier: *identifier, + }, ssk, new_nullifier, new_nonce, @@ -660,6 +697,7 @@ fn compute_circuit_output( ssk, nsk, membership_proof, + identifier, } => { // The npk binding is established upstream. Authorization must already be set; // an unauthorized PrivatePdaUpdate would mean the prover supplied an nsk for an @@ -679,12 +717,19 @@ fn compute_circuit_output( let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); let account_id = pre_state.account_id; + let (pda_program_id, seed) = pda_seed_by_position + .get(&pos) + .expect("PrivatePdaUpdate position must be in pda_seed_by_position"); emit_private_output( &mut output, &mut output_index, post_state, &account_id, - PRIVATE_PDA_FIXED_IDENTIFIER, + &PrivateAccountKind::Pda { + program_id: *pda_program_id, + seed: *seed, + identifier: *identifier, + }, ssk, new_nullifier, new_nonce, @@ -705,7 +750,7 @@ fn emit_private_output( output_index: &mut u32, post_state: Account, account_id: &AccountId, - identifier: Identifier, + kind: &PrivateAccountKind, shared_secret: &SharedSecretKey, new_nullifier: (Nullifier, CommitmentSetDigest), new_nonce: Nonce, @@ -718,7 +763,7 @@ fn emit_private_output( let commitment_post = Commitment::new(account_id, &post_with_updated_nonce); let encrypted_account = EncryptionScheme::encrypt( &post_with_updated_nonce, - identifier, + kind, shared_secret, &commitment_post, *output_index, diff --git a/programs/associated_token_account/core/src/lib.rs b/programs/associated_token_account/core/src/lib.rs index 8fe6e267..77900a2c 100644 --- a/programs/associated_token_account/core/src/lib.rs +++ b/programs/associated_token_account/core/src/lib.rs @@ -49,7 +49,7 @@ pub enum Instruction { pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSeed { use risc0_zkvm::sha::{Impl, Sha256}; - let mut bytes = [0u8; 64]; + let mut bytes = [0_u8; 64]; bytes[0..32].copy_from_slice(&owner_id.to_bytes()); bytes[32..64].copy_from_slice(&definition_id.to_bytes()); PdaSeed::new( diff --git a/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index bce8151f..df9aa87c 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -141,7 +141,7 @@ impl SequencerCore { .iter() .map(|init_comm_data| { let npk = &init_comm_data.npk; - let account_id = nssa::AccountId::from((npk, 0)); + let account_id = nssa::AccountId::for_regular_private_account(npk, 0); let mut acc = init_comm_data.account.clone(); diff --git a/test_program_methods/guest/src/bin/pda_fund_spend_proxy.rs b/test_program_methods/guest/src/bin/pda_fund_spend_proxy.rs new file mode 100644 index 00000000..c02261f9 --- /dev/null +++ b/test_program_methods/guest/src/bin/pda_fund_spend_proxy.rs @@ -0,0 +1,70 @@ +use nssa_core::{ + account::AccountWithMetadata, + program::{ + AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, + }, +}; +use risc0_zkvm::serde::to_vec; + +/// Proxy for interacting with private PDAs via `auth_transfer`. +/// +/// The `is_fund` flag selects the operating mode: +/// +/// - `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 ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (seed, amount, auth_transfer_id, is_fund), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([first, second]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + assert!(first.is_authorized, "first pre_state must be authorized"); + + 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: chained_pre_states, + pda_seeds: vec![seed], + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![first, second], + vec![first_post, second_post], + ) + .with_chained_calls(vec![chained_call]) + .write(); +} diff --git a/testnet_initial_state/src/lib.rs b/testnet_initial_state/src/lib.rs index f6f1e288..b5c91d4d 100644 --- a/testnet_initial_state/src/lib.rs +++ b/testnet_initial_state/src/lib.rs @@ -103,7 +103,10 @@ pub struct PrivateAccountPrivateInitialData { impl PrivateAccountPrivateInitialData { #[must_use] pub fn account_id(&self) -> nssa::AccountId { - nssa::AccountId::from((&self.key_chain.nullifier_public_key, self.identifier)) + nssa::AccountId::for_regular_private_account( + &self.key_chain.nullifier_public_key, + self.identifier, + ) } } @@ -208,7 +211,7 @@ pub fn initial_state() -> V03State { .iter() .map(|init_comm_data| { let npk = &init_comm_data.npk; - let account_id = nssa::AccountId::from((npk, 0)); + let account_id = nssa::AccountId::for_regular_private_account(npk, 0); let mut acc = init_comm_data.account.clone(); diff --git a/wallet/src/chain_storage.rs b/wallet/src/chain_storage.rs index 8d168d8e..88d64732 100644 --- a/wallet/src/chain_storage.rs +++ b/wallet/src/chain_storage.rs @@ -11,6 +11,7 @@ use key_protocol::{ }; use log::debug; use nssa::program::Program; +use nssa_core::PrivateAccountKind; use crate::config::{InitialAccountData, Label, PersistentAccountData, WalletConfig}; @@ -72,8 +73,8 @@ impl WalletChainStore { PersistentAccountData::Private(data) => { let npk = data.data.value.0.nullifier_public_key; let chain_index = data.chain_index; - for identifier in &data.identifiers { - let account_id = nssa::AccountId::from((&npk, *identifier)); + for kind in &data.kinds { + let account_id = nssa::AccountId::for_private_account(&npk, kind); private_tree .account_id_map .insert(account_id, chain_index.clone()); @@ -89,7 +90,10 @@ impl WalletChainStore { data.account_id(), UserPrivateAccountData { key_chain: data.key_chain, - accounts: vec![(data.identifier, data.account)], + accounts: vec![( + PrivateAccountKind::Regular(data.identifier), + data.account, + )], }, ); } @@ -135,7 +139,7 @@ impl WalletChainStore { account_id, UserPrivateAccountData { key_chain: data.key_chain, - accounts: vec![(data.identifier, account)], + accounts: vec![(PrivateAccountKind::Regular(data.identifier), account)], }, ); } @@ -190,7 +194,7 @@ impl WalletChainStore { pub fn insert_private_account_data( &mut self, account_id: nssa::AccountId, - identifier: nssa_core::Identifier, + kind: &PrivateAccountKind, account: nssa_core::account::Account, ) { debug!("inserting at address {account_id}, this account {account:?}"); @@ -202,10 +206,10 @@ impl WalletChainStore { .entry(account_id) { let entry = entry.get_mut(); - if let Some((_, acc)) = entry.accounts.iter_mut().find(|(id, _)| *id == identifier) { + if let Some((_, acc)) = entry.accounts.iter_mut().find(|(k, _)| k == kind) { *acc = account; } else { - entry.accounts.push((identifier, account)); + entry.accounts.push((kind.clone(), account)); } return; } @@ -228,24 +232,21 @@ impl WalletChainStore { .key_map .get_mut(&chain_index) { - if let Some((_, acc)) = node.value.1.iter_mut().find(|(id, _)| *id == identifier) { + if let Some((_, acc)) = node.value.1.iter_mut().find(|(k, _)| k == kind) { *acc = account; } else { - node.value.1.push((identifier, account)); + node.value.1.push((kind.clone(), account)); } } } else { // Node not yet in account_id_map — find it by checking all nodes for (ci, node) in &mut self.user_data.private_key_tree.key_map { - let expected_id = - nssa::AccountId::from((&node.value.0.nullifier_public_key, identifier)); - if expected_id == account_id { - if let Some((_, acc)) = - node.value.1.iter_mut().find(|(id, _)| *id == identifier) - { + let npk = &node.value.0.nullifier_public_key; + if nssa::AccountId::for_private_account(npk, kind) == account_id { + if let Some((_, acc)) = node.value.1.iter_mut().find(|(k, _)| k == kind) { *acc = account; } else { - node.value.1.push((identifier, account)); + node.value.1.push((kind.clone(), account)); } // Register in account_id_map self.user_data @@ -291,7 +292,7 @@ mod tests { data: public_data, }), PersistentAccountData::Private(Box::new(PersistentAccountDataPrivate { - identifiers: vec![], + kinds: vec![], chain_index: ChainIndex::root(), data: private_data, })), diff --git a/wallet/src/cli/account.rs b/wallet/src/cli/account.rs index 0e12e9a5..e58ea169 100644 --- a/wallet/src/cli/account.rs +++ b/wallet/src/cli/account.rs @@ -108,6 +108,10 @@ pub enum NewSubcommand { #[arg(long, requires = "pda")] /// Program ID as hex string. program_id: Option, + #[arg(long, requires = "pda")] + /// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family. + /// Defaults to a random value if not specified. + identifier: Option, }, /// Recommended for receiving from multiple senders: creates a key node (npk + vpk) without /// registering any account. @@ -208,6 +212,7 @@ impl WalletSubcommand for NewSubcommand { pda, seed, program_id, + identifier, } => { if let Some(label) = &label && wallet_core @@ -239,7 +244,12 @@ impl WalletSubcommand for NewSubcommand { pid[i] = u32::from_le_bytes(chunk.try_into().unwrap()); } - wallet_core.create_shared_pda_account(&group, pda_seed, pid)? + wallet_core.create_shared_pda_account( + &group, + pda_seed, + pid, + identifier.unwrap_or_else(rand::random), + )? } else { wallet_core.create_shared_regular_account(&group)? }; diff --git a/wallet/src/config.rs b/wallet/src/config.rs index 79a4e3c9..e5d43024 100644 --- a/wallet/src/config.rs +++ b/wallet/src/config.rs @@ -28,7 +28,7 @@ pub struct PersistentAccountDataPublic { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PersistentAccountDataPrivate { - pub identifiers: Vec, + pub kinds: Vec, pub chain_index: ChainIndex, pub data: ChildKeysPrivate, } diff --git a/wallet/src/helperfunctions.rs b/wallet/src/helperfunctions.rs index bc53edc0..6b7cfa00 100644 --- a/wallet/src/helperfunctions.rs +++ b/wallet/src/helperfunctions.rs @@ -166,10 +166,10 @@ pub fn produce_data_for_storage( } for (chain_index, node) in &user_data.private_key_tree.key_map { - let identifiers = node.value.1.iter().map(|(id, _)| *id).collect(); + let kinds = node.value.1.iter().map(|(kind, _)| kind.clone()).collect(); vec_for_storage.push( PersistentAccountDataPrivate { - identifiers, + kinds, chain_index: chain_index.clone(), data: node.clone(), } @@ -188,12 +188,12 @@ pub fn produce_data_for_storage( } for entry in user_data.default_user_private_accounts.values() { - for (identifier, account) in &entry.accounts { + for (kind, account) in &entry.accounts { vec_for_storage.push( InitialAccountData::Private(Box::new(PrivateAccountPrivateInitialData { account: account.clone(), key_chain: entry.key_chain.clone(), - identifier: *identifier, + identifier: kind.identifier(), })) .into(), ); diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 1d9c2c7e..24ac19b9 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -24,7 +24,8 @@ use nssa::{ }, }; use nssa_core::{ - Commitment, MembershipProof, SharedSecretKey, account::Nonce, program::InstructionData, + Commitment, MembershipProof, PrivateAccountKind, SharedSecretKey, account::Nonce, + program::InstructionData, }; pub use privacy_preserving_tx::PrivacyPreservingAccount; use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder}; @@ -297,9 +298,12 @@ impl WalletCore { .value .0 .nullifier_public_key; - let account_id = AccountId::from((&npk, identifier)); - self.storage - .insert_private_account_data(account_id, identifier, Account::default()); + let account_id = AccountId::for_regular_private_account(&npk, identifier); + self.storage.insert_private_account_data( + account_id, + &PrivateAccountKind::Regular(identifier), + Account::default(), + ); (account_id, cci) } @@ -341,15 +345,14 @@ impl WalletCore { .user_data .group_key_holder(&entry.group_label)?; - if let Some(pda_seed) = &entry.pda_seed { - let program_id = entry.pda_program_id?; - let keys = holder.derive_keys_for_pda(&program_id, pda_seed); - Some(PrivacyPreservingAccount::PrivatePda { + if let (Some(pda_seed), Some(program_id)) = (entry.pda_seed, entry.pda_program_id) { + let keys = holder.derive_keys_for_pda(&program_id, &pda_seed); + Some(PrivacyPreservingAccount::PrivatePdaShared { + account_id, nsk: keys.nullifier_secret_key, npk: keys.generate_nullifier_public_key(), vpk: keys.generate_viewing_public_key(), - program_id, - seed: *pda_seed, + identifier: entry.identifier, }) } else { let derivation_seed = { @@ -406,6 +409,7 @@ impl WalletCore { group_name: &str, pda_seed: nssa_core::program::PdaSeed, program_id: nssa_core::program::ProgramId, + identifier: nssa_core::Identifier, ) -> Result { let holder = self .storage @@ -416,12 +420,12 @@ impl WalletCore { let keys = holder.derive_keys_for_pda(&program_id, &pda_seed); let npk = keys.generate_nullifier_public_key(); let vpk = keys.generate_viewing_public_key(); - let account_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk); + let account_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk, identifier); self.register_shared_account( account_id, String::from(group_name), - u128::MAX, + identifier, Some(pda_seed), Some(program_id), ); @@ -536,7 +540,7 @@ impl WalletCore { let acc_ead = tx.message.encrypted_private_post_states[output_index].clone(); let acc_comm = tx.message.new_commitments[output_index].clone(); - let (identifier, res_acc) = nssa_core::EncryptionScheme::decrypt( + let (kind, res_acc) = nssa_core::EncryptionScheme::decrypt( &acc_ead.ciphertext, secret, &acc_comm, @@ -549,7 +553,7 @@ impl WalletCore { println!("Received new acc {res_acc:#?}"); self.storage - .insert_private_account_data(*acc_account_id, identifier, res_acc); + .insert_private_account_data(*acc_account_id, &kind, res_acc); } AccDecodeData::Skip => {} } @@ -717,24 +721,22 @@ impl WalletCore { .try_into() .expect("Ciphertext ID is expected to fit in u32"), ) - .map(|(identifier, res_acc)| { - let account_id = nssa::AccountId::from(( - &key_chain.nullifier_public_key, - identifier, - )); - (account_id, identifier, res_acc) + .map(|(kind, res_acc)| { + let npk = &key_chain.nullifier_public_key; + let account_id = nssa::AccountId::for_private_account(npk, &kind); + (account_id, kind, res_acc) }) }) .collect::>() }) .collect::>(); - for (affected_account_id, identifier, new_acc) in affected_accounts { + for (affected_account_id, kind, new_acc) in affected_accounts { info!( "Received new account for account_id {affected_account_id:#?} with account object {new_acc:#?}" ); self.storage - .insert_private_account_data(affected_account_id, identifier, new_acc); + .insert_private_account_data(affected_account_id, &kind, new_acc); } // Scan for updates to shared accounts (GMS-derived). @@ -792,7 +794,7 @@ impl WalletCore { let shared_secret = SharedSecretKey::new(&vsk, &encrypted_data.epk); let commitment = &tx.message.new_commitments[ciph_id]; - if let Some((_decrypted_identifier, new_acc)) = nssa_core::EncryptionScheme::decrypt( + if let Some((_kind, new_acc)) = nssa_core::EncryptionScheme::decrypt( &encrypted_data.ciphertext, &shared_secret, commitment, diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 5f35cde9..603fdd0c 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -6,7 +6,6 @@ use nssa_core::{ SharedSecretKey, account::{AccountWithMetadata, Nonce}, encryption::{EphemeralPublicKey, ViewingPublicKey}, - program::{PdaSeed, ProgramId}, }; use crate::{ExecutionFailureKind, WalletCore}; @@ -20,15 +19,16 @@ pub enum PrivacyPreservingAccount { vpk: ViewingPublicKey, identifier: Identifier, }, - /// A private PDA with externally-provided keys. The caller resolves the keys - /// (e.g. via `GroupKeyHolder::derive_keys_for_pda`) before constructing this variant. - /// The wallet computes the `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`. - PrivatePda { - nsk: NullifierSecretKey, + /// An owned private PDA: wallet holds the nsk/npk; `account_id` was derived via + /// [`AccountId::for_private_pda`]. + PrivatePdaOwned(AccountId), + /// A foreign private PDA: wallet knows the recipient's npk/vpk but not their nsk. + /// Uses a default (uninitialised) account. + PrivatePdaForeign { + account_id: AccountId, npk: NullifierPublicKey, vpk: ViewingPublicKey, - program_id: ProgramId, - seed: PdaSeed, + identifier: Identifier, }, /// A shared regular private account with externally-provided keys (e.g. from GMS). /// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private @@ -39,6 +39,15 @@ pub enum PrivacyPreservingAccount { vpk: ViewingPublicKey, identifier: Identifier, }, + /// A shared private PDA with externally-provided keys (e.g. from GMS). + /// `account_id` was derived via [`AccountId::for_private_pda`]. + PrivatePdaShared { + account_id: AccountId, + nsk: NullifierSecretKey, + npk: NullifierPublicKey, + vpk: ViewingPublicKey, + identifier: Identifier, + }, } impl PrivacyPreservingAccount { @@ -52,13 +61,11 @@ impl PrivacyPreservingAccount { matches!( &self, Self::PrivateOwned(_) - | Self::PrivateForeign { - npk: _, - vpk: _, - identifier: _, - } - | Self::PrivatePda { .. } + | Self::PrivateForeign { .. } + | Self::PrivatePdaOwned(_) + | Self::PrivatePdaForeign { .. } | Self::PrivateShared { .. } + | Self::PrivatePdaShared { .. } ) } } @@ -103,7 +110,7 @@ impl AccountManager { State::Public { account, sk } } PrivacyPreservingAccount::PrivateOwned(account_id) => { - let pre = private_acc_preparation(wallet, account_id).await?; + let pre = private_key_tree_acc_preparation(wallet, account_id, false).await?; State::Private(pre) } @@ -121,26 +128,42 @@ impl AccountManager { nsk: None, npk, identifier, - is_pda: false, vpk, pre_state: auth_acc, proof: None, ssk, epk, + is_pda: false, }; State::Private(pre) } - PrivacyPreservingAccount::PrivatePda { - nsk, + PrivacyPreservingAccount::PrivatePdaOwned(account_id) => { + let pre = private_key_tree_acc_preparation(wallet, account_id, true).await?; + State::Private(pre) + } + PrivacyPreservingAccount::PrivatePdaForeign { + account_id, npk, vpk, - program_id, - seed, + identifier, } => { - let pre = - private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?; - + let acc = nssa_core::account::Account::default(); + let auth_acc = AccountWithMetadata::new(acc, false, account_id); + let eph_holder = EphemeralKeyHolder::new(&npk); + let ssk = eph_holder.calculate_shared_secret_sender(&vpk); + let epk = eph_holder.generate_ephemeral_public_key(); + let pre = AccountPreparedData { + nsk: None, + npk, + identifier, + vpk, + pre_state: auth_acc, + proof: None, + ssk, + epk, + is_pda: true, + }; State::Private(pre) } PrivacyPreservingAccount::PrivateShared { @@ -149,7 +172,25 @@ impl AccountManager { vpk, identifier, } => { - let pre = private_shared_preparation(wallet, nsk, npk, vpk, identifier).await?; + let account_id = nssa::AccountId::from((&npk, identifier)); + let pre = private_shared_acc_preparation( + wallet, account_id, nsk, npk, vpk, identifier, false, + ) + .await?; + + State::Private(pre) + } + PrivacyPreservingAccount::PrivatePdaShared { + account_id, + nsk, + npk, + vpk, + identifier, + } => { + let pre = private_shared_acc_preparation( + wallet, account_id, nsk, npk, vpk, identifier, true, + ) + .await?; State::Private(pre) } @@ -210,10 +251,12 @@ impl AccountManager { ssk: pre.ssk, nsk, membership_proof, + identifier: pre.identifier, }, _ => InputAccountIdentity::PrivatePdaInit { npk: pre.npk, ssk: pre.ssk, + identifier: pre.identifier, }, }, State::Private(pre) => match (pre.nsk, pre.proof.clone()) { @@ -265,7 +308,6 @@ struct AccountPreparedData { nsk: Option, npk: NullifierPublicKey, identifier: Identifier, - is_pda: bool, vpk: ViewingPublicKey, pre_state: AccountWithMetadata, proof: Option, @@ -276,20 +318,23 @@ struct AccountPreparedData { ssk: SharedSecretKey, /// Cached ephemeral public key, paired with `ssk`. epk: EphemeralPublicKey, + /// True when this account is a private PDA (owned or foreign). Used by `account_identities()` + /// to select `PrivatePdaInit`/`PrivatePdaUpdate` rather than the standalone private variants. + is_pda: bool, } -async fn private_acc_preparation( +async fn private_key_tree_acc_preparation( wallet: &WalletCore, account_id: AccountId, + is_pda: bool, ) -> Result { - let Some((from_keys, from_acc, from_identifier)) = - wallet.storage.user_data.get_private_account(account_id) - else { - return Err(ExecutionFailureKind::KeyNotFoundError); - }; + let (from_keys, from_acc, from_identifier) = wallet + .storage + .user_data + .get_private_account(account_id) + .ok_or(ExecutionFailureKind::KeyNotFoundError)?; let nsk = from_keys.private_key_holder.nullifier_secret_key; - let from_npk = from_keys.nullifier_public_key; let from_vpk = from_keys.viewing_public_key; @@ -301,7 +346,7 @@ async fn private_acc_preparation( // TODO: Technically we could allow unauthorized owned accounts, but currently we don't have // support from that in the wallet. - let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, (&from_npk, from_identifier)); + let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, account_id); let eph_holder = EphemeralKeyHolder::new(&from_npk); let ssk = eph_holder.calculate_shared_secret_sender(&from_vpk); @@ -311,27 +356,24 @@ async fn private_acc_preparation( nsk: Some(nsk), npk: from_npk, identifier: from_identifier, - is_pda: false, vpk: from_vpk, pre_state: sender_pre, proof, ssk, epk, + is_pda, }) } -async fn private_pda_preparation( +async fn private_shared_acc_preparation( wallet: &WalletCore, + account_id: AccountId, nsk: NullifierSecretKey, npk: NullifierPublicKey, vpk: ViewingPublicKey, - program_id: &ProgramId, - seed: &PdaSeed, + identifier: Identifier, + is_pda: bool, ) -> Result { - let account_id = nssa::AccountId::for_private_pda(program_id, seed, &npk); - - // Check local cache first (private PDA state is encrypted on-chain, the sequencer - // only stores commitments). Fall back to default for new PDAs. let acc = wallet .storage .user_data @@ -340,11 +382,6 @@ async fn private_pda_preparation( .unwrap_or_default(); let exists = acc != nssa_core::account::Account::default(); - - // is_authorized tracks whether the account existed on-chain before this tx. - // NSK is only provided for existing accounts: the circuit consumes NSKs sequentially - // from an iterator and asserts none are left over, so supplying an NSK for a new - // (unauthorized) account would trigger the over-supply assertion. let pre_state = AccountWithMetadata::new(acc, exists, account_id); let proof = if exists { @@ -360,61 +397,16 @@ async fn private_pda_preparation( let ssk = eph_holder.calculate_shared_secret_sender(&vpk); let epk = eph_holder.generate_ephemeral_public_key(); - Ok(AccountPreparedData { - nsk: exists.then_some(nsk), - npk, - identifier: u128::MAX, - is_pda: true, - vpk, - pre_state, - proof, - ssk, - epk, - }) -} - -async fn private_shared_preparation( - wallet: &WalletCore, - nsk: NullifierSecretKey, - npk: NullifierPublicKey, - vpk: ViewingPublicKey, - identifier: Identifier, -) -> Result { - let account_id = nssa::AccountId::from((&npk, identifier)); - - let acc = wallet - .storage - .user_data - .shared_private_account(&account_id) - .map(|e| e.account.clone()) - .unwrap_or_default(); - - let exists = acc != nssa_core::account::Account::default(); - let pre_state = AccountWithMetadata::new(acc, exists, (&npk, identifier)); - - let proof = if exists { - wallet - .check_private_account_initialized(account_id) - .await - .unwrap_or(None) - } else { - None - }; - - let eph_holder = EphemeralKeyHolder::new(&npk); - let ssk = eph_holder.calculate_shared_secret_sender(&vpk); - let epk = eph_holder.generate_ephemeral_public_key(); - Ok(AccountPreparedData { nsk: exists.then_some(nsk), npk, identifier, - is_pda: false, vpk, pre_state, proof, ssk, epk, + is_pda, }) }