diff --git a/Cargo.lock b/Cargo.lock index 3d46ad65..92c524bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7154,9 +7154,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.12" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "ring", "rustls-pki-types", diff --git a/Cargo.toml b/Cargo.toml index 3a98e700..96b06460 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,6 +154,14 @@ opt-level = 'z' lto = true codegen-units = 1 +# Keep backtraces but drop full DWARF type info to avoid LLD OOM/SIGBUS when +# linking large integration-test binaries on resource-constrained CI runners. +[profile.dev] +debug = "line-tables-only" + +[profile.test] +debug = "line-tables-only" + [workspace.lints.rust] warnings = "deny" diff --git a/artifacts/program_methods/amm.bin b/artifacts/program_methods/amm.bin index 775ec45f..148a9403 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 917e0dc5..46326067 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 cdce17b9..ad40805f 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 37a4d30f..e2a6f120 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 e18d5c2c..d0460713 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 f2115a68..b0f81f79 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 21cf0ddb..dcbee51a 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 ebb374f3..e0358fa4 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 new file mode 100644 index 00000000..9bd40a30 Binary files /dev/null and b/artifacts/test_program_methods/auth_asserting_noop.bin differ diff --git a/artifacts/test_program_methods/burner.bin b/artifacts/test_program_methods/burner.bin index e2fea8bd..0353d78f 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 d6670787..cd74cf3f 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 47c4200e..1f966bef 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 8b8bc140..8a48effd 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 2faa9b69..e08df712 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 2ade0385..37abf0f7 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 d0095d2b..ebd53621 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 f259c5b3..29c660cd 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 f1b67504..a560d477 100644 Binary files a/artifacts/test_program_methods/flash_swap_initiator.bin and b/artifacts/test_program_methods/flash_swap_initiator.bin differ diff --git a/artifacts/test_program_methods/malicious_authorization_changer.bin b/artifacts/test_program_methods/malicious_authorization_changer.bin index 75df8bec..c9d0facd 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 9907ba58..9b31fd7e 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 b530a0b3..c4a2c039 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 392aa2fa..42d2171d 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 92998b57..d2b99291 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 65475b18..f57ac2f1 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 809ed4ec..6b79e074 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 9c2fa8bc..eb89f4a9 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 new file mode 100644 index 00000000..092a2191 Binary files /dev/null and b/artifacts/test_program_methods/pda_claimer.bin differ diff --git a/artifacts/test_program_methods/pinata_cooldown.bin b/artifacts/test_program_methods/pinata_cooldown.bin index 36e60f9c..559adea4 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 new file mode 100644 index 00000000..d7e81a9f Binary files /dev/null and b/artifacts/test_program_methods/private_pda_delegator.bin differ diff --git a/artifacts/test_program_methods/program_owner_changer.bin b/artifacts/test_program_methods/program_owner_changer.bin index 4dbb34b8..880e03b1 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 df9bee1d..3a4e811f 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 8b3da3ea..eeb80385 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 new file mode 100644 index 00000000..b71d87ab Binary files /dev/null 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 009bb965..8d749f3c 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 cf9e8af5..109829d2 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/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md b/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md index 330ae909..7ed95e01 100644 --- a/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md +++ b/docs/LEZ testnet v0.1 tutorials/associated-token-accounts.md @@ -52,7 +52,7 @@ The derivation works as follows: ``` seed = SHA256(owner_id || definition_id) -ata_address = AccountId::from((ata_program_id, seed)) +ata_address = AccountId::for_public_pda(ata_program_id, seed) ``` Because the computation is pure, anyone who knows the owner and definition can reproduce the exact same ATA address — no network call required. diff --git a/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs b/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs index e6a8ca99..86c95ebf 100644 --- a/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs +++ b/examples/program_deployment/src/bin/run_hello_world_with_authorization_through_tail_call_with_pda.rs @@ -46,7 +46,7 @@ async fn main() { let program = Program::new(bytecode).unwrap(); // Compute the PDA to pass it as input account to the public execution - let pda = AccountId::from((&program.id(), &PDA_SEED)); + let pda = AccountId::for_public_pda(&program.id(), &PDA_SEED); let account_ids = vec![pda]; let instruction_data = (); let nonces = vec![]; diff --git a/integration_tests/src/config.rs b/integration_tests/src/config.rs index c4718f71..008f7700 100644 --- a/integration_tests/src/config.rs +++ b/integration_tests/src/config.rs @@ -121,7 +121,7 @@ impl InitialData { self.private_accounts .iter() .map(|(key_chain, account)| PrivateAccountPublicInitialData { - npk: key_chain.nullifier_public_key.clone(), + npk: key_chain.nullifier_public_key, account: account.clone(), }) .collect() diff --git a/integration_tests/tests/tps.rs b/integration_tests/tests/tps.rs index bd46849e..7d2a6d29 100644 --- a/integration_tests/tests/tps.rs +++ b/integration_tests/tests/tps.rs @@ -249,10 +249,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction { vec![sender_pre, recipient_pre], Program::serialize_instruction(balance_to_move).unwrap(), vec![1, 2], - vec![ - (sender_npk.clone(), sender_ss), - (recipient_npk.clone(), recipient_ss), - ], + vec![(sender_npk, sender_ss), (recipient_npk, recipient_ss)], vec![sender_nsk], vec![Some(proof)], &program.into(), diff --git a/nssa/core/src/circuit_io.rs b/nssa/core/src/circuit_io.rs index 998f6d71..215c7db8 100644 --- a/nssa/core/src/circuit_io.rs +++ b/nssa/core/src/circuit_io.rs @@ -17,6 +17,7 @@ pub struct PrivacyPreservingCircuitInput { /// - `0` - public account /// - `1` - private account with authentication /// - `2` - private account without authentication + /// - `3` - private PDA account pub visibility_mask: Vec, /// Public keys of private accounts. pub private_account_keys: Vec<(NullifierPublicKey, SharedSecretKey)>, diff --git a/nssa/core/src/nullifier.rs b/nssa/core/src/nullifier.rs index 0e15ec74..fd17b391 100644 --- a/nssa/core/src/nullifier.rs +++ b/nssa/core/src/nullifier.rs @@ -4,8 +4,8 @@ use serde::{Deserialize, Serialize}; use crate::{Commitment, account::AccountId}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] -#[cfg_attr(any(feature = "host", test), derive(Clone, Hash))] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(any(feature = "host", test), derive(Hash))] pub struct NullifierPublicKey(pub [u8; 32]); impl From<&NullifierPublicKey> for AccountId { diff --git a/nssa/core/src/program.rs b/nssa/core/src/program.rs index a08fb2b4..5091cdff 100644 --- a/nssa/core/src/program.rs +++ b/nssa/core/src/program.rs @@ -6,7 +6,7 @@ use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer}; use serde::{Deserialize, Serialize}; use crate::{ - BlockId, Timestamp, + BlockId, NullifierPublicKey, Timestamp, account::{Account, AccountId, AccountWithMetadata}, }; @@ -27,7 +27,7 @@ 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, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash, Serialize, Deserialize)] pub struct PdaSeed([u8; 32]); impl PdaSeed { @@ -37,8 +37,10 @@ impl PdaSeed { } } -impl From<(&ProgramId, &PdaSeed)> for AccountId { - fn from(value: (&ProgramId, &PdaSeed)) -> Self { +impl AccountId { + /// Derives an [`AccountId`] for a public PDA from the program ID and seed. + #[must_use] + pub fn for_public_pda(program_id: &ProgramId, seed: &PdaSeed) -> Self { use risc0_zkvm::sha::{Impl, Sha256 as _}; const PROGRAM_DERIVED_ACCOUNT_ID_PREFIX: &[u8; 32] = b"/NSSA/v0.2/AccountId/PDA/\x00\x00\x00\x00\x00\x00\x00"; @@ -46,9 +48,38 @@ impl From<(&ProgramId, &PdaSeed)> for AccountId { let mut bytes = [0; 96]; bytes[0..32].copy_from_slice(PROGRAM_DERIVED_ACCOUNT_ID_PREFIX); let program_id_bytes: &[u8] = - bytemuck::try_cast_slice(value.0).expect("ProgramId should be castable to &[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..].copy_from_slice(&value.1.0); + bytes[64..].copy_from_slice(&seed.0); + Self::new( + Impl::hash_bytes(&bytes) + .as_bytes() + .try_into() + .expect("Hash output must be exactly 32 bytes long"), + ) + } + + /// Derives an [`AccountId`] for a private PDA from the program ID, seed, and nullifier + /// public key. + /// + /// 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. + #[must_use] + pub fn for_private_pda( + program_id: &ProgramId, + seed: &PdaSeed, + npk: &NullifierPublicKey, + ) -> 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]; + 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()); Self::new( Impl::hash_bytes(&bytes) .as_bytes() @@ -65,6 +96,9 @@ pub struct ChainedCall { pub pre_states: Vec, /// The instruction data to pass. pub instruction_data: InstructionData, + /// PDA seeds authorized for the callee. For each seed, the callee is authorized to + /// mutate the `AccountId` derived from `(caller_program_id, seed)`, regardless of + /// whether the account is public or private. pub pda_seeds: Vec, } @@ -114,7 +148,9 @@ pub enum Claim { /// This will give no error if program had authorization in pre state and may be useful /// if program decides to give up authorization for a chained call. Authorized, - /// The program requests ownership of the account through a PDA. + /// The program requests ownership of the account through a PDA. The program emits the + /// seed; the `AccountId` is derived from `(program_id, seed)`, regardless of whether the + /// account is public or private. Pda(PdaSeed), } @@ -382,8 +418,8 @@ impl ProgramOutput { } /// Representation of a number as `lo + hi * 2^128`. -#[derive(PartialEq, Eq)] -struct WrappedBalanceSum { +#[derive(Debug, PartialEq, Eq)] +pub struct WrappedBalanceSum { lo: u128, hi: u128, } @@ -393,7 +429,7 @@ impl WrappedBalanceSum { /// /// Returns [`None`] if balance sum overflows `lo + hi * 2^128` representation, which is not /// expected in practical scenarios. - fn from_balances(balances: impl Iterator) -> Option { + pub fn from_balances(balances: impl Iterator) -> Option { let mut wrapped = Self { lo: 0, hi: 0 }; for balance in balances { @@ -408,19 +444,93 @@ impl WrappedBalanceSum { } } +impl std::fmt::Display for WrappedBalanceSum { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if self.hi == 0 { + write!(f, "{}", self.lo) + } else { + write!(f, "{} * 2^128 + {}", self.hi, self.lo) + } + } +} + +impl From for WrappedBalanceSum { + fn from(value: u128) -> Self { + Self { lo: value, hi: 0 } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum ExecutionValidationError { + #[error("Pre-state account IDs are not unique")] + PreStateAccountIdsNotUnique, + + #[error( + "Pre-state and post-state lengths do not match: pre-state length {pre_state_length}, post-state length {post_state_length}" + )] + MismatchedPreStatePostStateLength { + pre_state_length: usize, + post_state_length: usize, + }, + + #[error("Unallowed modification of nonce for account {account_id}")] + ModifiedNonce { account_id: AccountId }, + + #[error("Unallowed modification of program owner for account {account_id}")] + ModifiedProgramOwner { account_id: AccountId }, + + #[error( + "Trying to decrease balance of account {account_id} owned by {owner_program_id:?} in a program {executing_program_id:?} which is not the owner" + )] + UnauthorizedBalanceDecrease { + account_id: AccountId, + owner_program_id: ProgramId, + executing_program_id: ProgramId, + }, + + #[error( + "Unauthorized modification of data for account {account_id} which is not default and not owned by executing program {executing_program_id:?}" + )] + UnauthorizedDataModification { + account_id: AccountId, + executing_program_id: ProgramId, + }, + + #[error( + "Post-state for account {account_id} has default program owner but pre-state was not default" + )] + NonDefaultAccountWithDefaultOwner { account_id: AccountId }, + + #[error("Total balance across accounts overflowed 2^256 - 1")] + BalanceSumOverflow, + + #[error( + "Total balance across accounts is not preserved: total balance in pre-states {total_balance_pre_states}, total balance in post-states {total_balance_post_states}" + )] + MismatchedTotalBalance { + total_balance_pre_states: WrappedBalanceSum, + total_balance_post_states: WrappedBalanceSum, + }, +} + +/// Computes the set of public-PDA `AccountId`s the callee is authorized to mutate. +/// +/// Returns only public-form derivations, suitable for contexts where all accounts are public +/// (e.g. the public-execution path). The privacy circuit must additionally check each mask-3 +/// `pre_state` against [`AccountId::for_private_pda`] with the supplied npk for that +/// `pre_state`. #[must_use] -pub fn compute_authorized_pdas( +pub fn compute_public_authorized_pdas( caller_program_id: Option, pda_seeds: &[PdaSeed], ) -> HashSet { - caller_program_id - .map(|caller_program_id| { - pda_seeds - .iter() - .map(|pda_seed| AccountId::from((&caller_program_id, pda_seed))) - .collect() - }) - .unwrap_or_default() + let Some(caller) = caller_program_id else { + return HashSet::new(); + }; + pda_seeds + .iter() + .map(|seed| AccountId::for_public_pda(&caller, seed)) + .collect() } /// Reads the NSSA inputs from the guest environment. @@ -448,31 +558,39 @@ pub fn read_nssa_inputs() -> (ProgramInput, InstructionD /// - `pre_states`: The list of input accounts, each annotated with authorization metadata. /// - `post_states`: The list of resulting accounts after executing the program logic. /// - `executing_program_id`: The identifier of the program that was executed. -#[must_use] pub fn validate_execution( pre_states: &[AccountWithMetadata], post_states: &[AccountPostState], executing_program_id: ProgramId, -) -> bool { +) -> Result<(), ExecutionValidationError> { // 1. Check account ids are all different if !validate_uniqueness_of_account_ids(pre_states) { - return false; + return Err(ExecutionValidationError::PreStateAccountIdsNotUnique); } // 2. Lengths must match if pre_states.len() != post_states.len() { - return false; + return Err( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length: pre_states.len(), + post_state_length: post_states.len(), + }, + ); } for (pre, post) in pre_states.iter().zip(post_states) { // 3. Nonce must remain unchanged if pre.account.nonce != post.account.nonce { - return false; + return Err(ExecutionValidationError::ModifiedNonce { + account_id: pre.account_id, + }); } // 4. Program ownership changes are not allowed if pre.account.program_owner != post.account.program_owner { - return false; + return Err(ExecutionValidationError::ModifiedProgramOwner { + account_id: pre.account_id, + }); } let account_program_owner = pre.account.program_owner; @@ -481,7 +599,11 @@ pub fn validate_execution( if post.account.balance < pre.account.balance && account_program_owner != executing_program_id { - return false; + return Err(ExecutionValidationError::UnauthorizedBalanceDecrease { + account_id: pre.account_id, + owner_program_id: account_program_owner, + executing_program_id, + }); } // 6. Data changes only allowed if owned by executing program or if account pre state has @@ -490,13 +612,20 @@ pub fn validate_execution( && pre.account != Account::default() && account_program_owner != executing_program_id { - return false; + return Err(ExecutionValidationError::UnauthorizedDataModification { + account_id: pre.account_id, + executing_program_id, + }); } // 7. If a post state has default program owner, the pre state must have been a default // account if post.account.program_owner == DEFAULT_PROGRAM_ID && pre.account != Account::default() { - return false; + return Err( + ExecutionValidationError::NonDefaultAccountWithDefaultOwner { + account_id: pre.account_id, + }, + ); } } @@ -505,20 +634,23 @@ pub fn validate_execution( let Some(total_balance_pre_states) = WrappedBalanceSum::from_balances(pre_states.iter().map(|pre| pre.account.balance)) else { - return false; + return Err(ExecutionValidationError::BalanceSumOverflow); }; let Some(total_balance_post_states) = WrappedBalanceSum::from_balances(post_states.iter().map(|post| post.account.balance)) else { - return false; + return Err(ExecutionValidationError::BalanceSumOverflow); }; if total_balance_pre_states != total_balance_post_states { - return false; + return Err(ExecutionValidationError::MismatchedTotalBalance { + total_balance_pre_states, + total_balance_post_states, + }); } - true + Ok(()) } fn validate_uniqueness_of_account_ids(pre_states: &[AccountWithMetadata]) -> bool { @@ -709,4 +841,108 @@ mod tests { assert_eq!(account_post_state.account(), &account); assert_eq!(account_post_state.account_mut(), &mut account); } + + // ---- 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. + #[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 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, + ]); + assert_eq!( + AccountId::for_private_pda(&program_id, &seed, &npk), + expected + ); + } + + /// Two groups with different viewing keys at the same (program, seed) get different addresses. + #[test] + fn for_private_pda_differs_for_different_npk() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + 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), + ); + } + + /// Different seeds produce different addresses, even with the same program and npk. + #[test] + fn for_private_pda_differs_for_different_seed() { + let program_id: ProgramId = [1; 8]; + let seed_a = PdaSeed::new([2; 32]); + 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), + ); + } + + /// Different programs produce different addresses, even with the same seed and npk. + #[test] + fn for_private_pda_differs_for_different_program_id() { + let program_id_a: ProgramId = [1; 8]; + let program_id_b: ProgramId = [9; 8]; + 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), + ); + } + + /// A private PDA at the same (program, seed) has a different address than a public PDA, + /// because the private formula uses a different prefix and includes npk. + #[test] + fn for_private_pda_differs_from_public_pda() { + 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 public_id = AccountId::for_public_pda(&program_id, &seed); + assert_ne!(private_id, public_id); + } + + /// A private PDA address differs from a standard private account address at the same `npk`, + /// because the private PDA formula includes `program_id` and `seed`. + #[test] + fn for_private_pda_differs_from_standard_private() { + let program_id: ProgramId = [1; 8]; + let seed = PdaSeed::new([2; 32]); + let npk = NullifierPublicKey([3; 32]); + let private_pda_id = AccountId::for_private_pda(&program_id, &seed, &npk); + let standard_private_id = AccountId::from(&npk); + assert_ne!(private_pda_id, standard_private_id); + } + + // ---- compute_public_authorized_pdas tests ---- + + /// `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]; + let seed = PdaSeed::new([2; 32]); + let result = compute_public_authorized_pdas(Some(caller), &[seed]); + let expected = AccountId::for_public_pda(&caller, &seed); + assert!(result.contains(&expected)); + assert_eq!(result.len(), 1); + } + + /// With no caller (top-level call), the result is always empty. + #[test] + fn compute_public_authorized_pdas_no_caller_returns_empty() { + let seed = PdaSeed::new([2; 32]); + let result = compute_public_authorized_pdas(None, &[seed]); + assert!(result.is_empty()); + } } diff --git a/nssa/src/error.rs b/nssa/src/error.rs index 61966515..565e02ba 100644 --- a/nssa/src/error.rs +++ b/nssa/src/error.rs @@ -1,12 +1,16 @@ use std::io; +use nssa_core::{ + account::{Account, AccountId}, + program::ProgramId, +}; use thiserror::Error; #[macro_export] macro_rules! ensure { ($cond:expr, $err:expr) => { if !$cond { - return Err($err); + return Err($err.into()); } }; } @@ -17,7 +21,7 @@ pub enum NssaError { InvalidInput(String), #[error("Program violated execution rules")] - InvalidProgramBehavior, + InvalidProgramBehavior(#[from] InvalidProgramBehaviorError), #[error("Serialization error: {0}")] InstructionSerializationError(String), @@ -32,15 +36,15 @@ pub enum NssaError { InvalidPublicKey(#[source] k256::schnorr::Error), #[error("Invalid hex for public key")] - InvalidHexPublicKey(hex::FromHexError), + InvalidHexPublicKey(#[source] hex::FromHexError), - #[error("Risc0 error: {0}")] + #[error("Failed to write program input: {0}")] ProgramWriteInputFailed(String), - #[error("Risc0 error: {0}")] + #[error("Failed to execute program: {0}")] ProgramExecutionFailed(String), - #[error("Risc0 error: {0}")] + #[error("Failed to prove program: {0}")] ProgramProveFailed(String), #[error("Invalid transaction: {0}")] @@ -77,6 +81,61 @@ pub enum NssaError { OutOfValidityWindow, } +#[derive(Error, Debug)] +pub enum InvalidProgramBehaviorError { + #[error( + "Inconsistent pre-state for account {account_id} : expected {expected:?}, actual {actual:?}" + )] + InconsistentAccountPreState { + account_id: AccountId, + // Boxed to reduce the size of the error type + expected: Box, + actual: Box, + }, + + #[error( + "Inconsistent authorization for account {account_id} : expected {expected_authorization}, actual {actual_authorization}" + )] + InconsistentAccountAuthorization { + account_id: AccountId, + expected_authorization: bool, + actual_authorization: bool, + }, + + #[error("Program ID mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedProgramId { + expected: ProgramId, + actual: ProgramId, + }, + + #[error("Caller program ID mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedCallerProgramId { + expected: Option, + actual: Option, + }, + + #[error(transparent)] + ExecutionValidationFailed(#[from] nssa_core::program::ExecutionValidationError), + + #[error("Trying to claim account {account_id} which is not default")] + ClaimedNonDefaultAccount { account_id: AccountId }, + + #[error("Trying to claim account {account_id} which is not authorized")] + ClaimedUnauthorizedAccount { account_id: AccountId }, + + #[error("PDA claim mismatch: expected {expected:?}, actual {actual:?}")] + MismatchedPdaClaim { + expected: AccountId, + actual: AccountId, + }, + + #[error("Default account {account_id} was modified without being claimed")] + DefaultAccountModifiedWithoutClaim { account_id: AccountId }, + + #[error("Called program {program_id:?} which is not listed in dependencies")] + UndeclaredProgramDependency { program_id: ProgramId }, +} + #[cfg(test)] mod tests { diff --git a/nssa/src/privacy_preserving_transaction/circuit.rs b/nssa/src/privacy_preserving_transaction/circuit.rs index 6c174450..528bb372 100644 --- a/nssa/src/privacy_preserving_transaction/circuit.rs +++ b/nssa/src/privacy_preserving_transaction/circuit.rs @@ -10,7 +10,7 @@ use nssa_core::{ use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; use crate::{ - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, program::Program, program_methods::{PRIVACY_PRESERVING_CIRCUIT_ELF, PRIVACY_PRESERVING_CIRCUIT_ID}, state::MAX_NUMBER_CHAINED_CALLS, @@ -113,9 +113,11 @@ pub fn execute_and_prove( env_builder.add_assumption(inner_receipt); for new_call in program_output.chained_calls.into_iter().rev() { - let next_program = dependencies - .get(&new_call.program_id) - .ok_or(NssaError::InvalidProgramBehavior)?; + let next_program = dependencies.get(&new_call.program_id).ok_or( + InvalidProgramBehaviorError::UndeclaredProgramDependency { + program_id: new_call.program_id, + }, + )?; chained_calls.push_front((new_call, next_program, Some(chained_call.program_id))); } diff --git a/nssa/src/program.rs b/nssa/src/program.rs index 698032e2..b8c3fe77 100644 --- a/nssa/src/program.rs +++ b/nssa/src/program.rs @@ -292,6 +292,36 @@ mod tests { } } + #[must_use] + pub fn pda_claimer() -> Self { + use test_program_methods::{PDA_CLAIMER_ELF, PDA_CLAIMER_ID}; + + Self { + id: PDA_CLAIMER_ID, + elf: PDA_CLAIMER_ELF.to_vec(), + } + } + + #[must_use] + pub fn private_pda_delegator() -> Self { + use test_program_methods::{PRIVATE_PDA_DELEGATOR_ELF, PRIVATE_PDA_DELEGATOR_ID}; + + Self { + id: PRIVATE_PDA_DELEGATOR_ID, + elf: PRIVATE_PDA_DELEGATOR_ELF.to_vec(), + } + } + + #[must_use] + pub fn two_pda_claimer() -> Self { + use test_program_methods::{TWO_PDA_CLAIMER_ELF, TWO_PDA_CLAIMER_ID}; + + Self { + id: TWO_PDA_CLAIMER_ID, + elf: TWO_PDA_CLAIMER_ELF.to_vec(), + } + } + #[must_use] pub fn changer_claimer() -> Self { use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID}; @@ -312,6 +342,16 @@ mod tests { } } + #[must_use] + pub fn auth_asserting_noop() -> Self { + use test_program_methods::{AUTH_ASSERTING_NOOP_ELF, AUTH_ASSERTING_NOOP_ID}; + + Self { + id: AUTH_ASSERTING_NOOP_ID, + elf: AUTH_ASSERTING_NOOP_ELF.to_vec(), + } + } + #[must_use] pub fn malicious_authorization_changer() -> Self { use test_program_methods::{ diff --git a/nssa/src/state.rs b/nssa/src/state.rs index 17abc6d1..f86f429f 100644 --- a/nssa/src/state.rs +++ b/nssa/src/state.rs @@ -366,12 +366,15 @@ pub mod tests { Timestamp, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, encryption::{EphemeralPublicKey, Scalar, ViewingPublicKey}, - program::{BlockValidityWindow, PdaSeed, ProgramId, TimestampValidityWindow}, + program::{ + BlockValidityWindow, ExecutionValidationError, PdaSeed, ProgramId, + TimestampValidityWindow, WrappedBalanceSum, + }, }; use crate::{ PublicKey, PublicTransaction, V03State, - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, execute_and_prove, privacy_preserving_transaction::{ PrivacyPreservingTransaction, @@ -933,10 +936,11 @@ pub mod tests { #[test] fn program_should_fail_if_modifies_nonces() { - let initial_data = [(AccountId::new([1; 32]), 100)]; + let account_id = AccountId::new([1; 32]); + let initial_data = [(account_id, 100)]; let mut state = V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); - let account_ids = vec![AccountId::new([1; 32])]; + let account_ids = vec![account_id]; let program_id = Program::nonce_changer_program().id(); let message = public_transaction::Message::try_new(program_id, account_ids, vec![], ()).unwrap(); @@ -945,7 +949,14 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedNonce { account_id: err_account_id } + ) + )) if err_account_id == account_id + )); } #[test] @@ -962,7 +973,17 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length, + post_state_length + } + ) + )) if pre_state_length == 1 && post_state_length == 2 + )); } #[test] @@ -979,7 +1000,17 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedPreStatePostStateLength { + pre_state_length, + post_state_length + } + ) + )) if pre_state_length == 2 && post_state_length == 1 + )); } #[test] @@ -1003,7 +1034,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] @@ -1027,7 +1063,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] @@ -1051,7 +1092,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] @@ -1075,16 +1121,21 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::ModifiedProgramOwner { account_id: err_account_id } + ))) if err_account_id == account_id + )); } #[test] fn program_should_fail_if_transfers_balance_from_non_owned_account() { - let initial_data = [(AccountId::new([1; 32]), 100)]; - let mut state = - V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let sender_account_id = AccountId::new([1; 32]); let receiver_account_id = AccountId::new([2; 32]); + let initial_data = [(sender_account_id, 100)]; + let mut state = + V03State::new_with_genesis_accounts(&initial_data, vec![], 0).with_test_programs(); let balance_to_move: u128 = 1; let program_id = Program::simple_balance_transfer().id(); assert_ne!( @@ -1103,7 +1154,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedBalanceDecrease { account_id: err_account_id, owner_program_id, executing_program_id } + ))) if err_account_id == sender_account_id && owner_program_id != program_id && executing_program_id == program_id + )); } #[test] @@ -1128,7 +1184,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedDataModification { account_id: err_account_id, executing_program_id } + ))) if err_account_id == account_id && executing_program_id == program_id + )); } #[test] @@ -1146,7 +1207,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ))) if total_balance_pre_states == 0.into() && total_balance_post_states == 1.into() + )); } #[test] @@ -1175,7 +1241,12 @@ pub mod tests { let tx = PublicTransaction::new(message, witness_set); let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior(InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ))) if total_balance_pre_states == 100.into() && total_balance_post_states == 99.into() + )); } fn test_public_account_keys_1() -> TestPublicKeys { @@ -2243,9 +2314,16 @@ pub mod tests { assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); } + /// A mask-3 account that no program claims via `Claim::Pda` and no caller authorizes via + /// `ChainedCall.pda_seeds` has no binding between its supplied npk and its `account_id`, + /// so the circuit must reject. Here `simple_balance_transfer` emits no claim for the + /// second account, leaving position 1 unbound. #[test] - fn circuit_should_fail_with_invalid_visibility_mask_value() { + fn private_pda_without_binding_fails() { let program = Program::simple_balance_transfer(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); let public_account_1 = AccountWithMetadata::new( Account { program_owner: program.id(), @@ -2255,17 +2333,235 @@ pub mod tests { true, AccountId::new([0; 32]), ); - let public_account_2 = + let private_pda_account = AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32])); let visibility_mask = [0, 3]; let result = execute_and_prove( - vec![public_account_1, public_account_2], + vec![public_account_1, private_pda_account], Program::serialize_instruction(10_u128).unwrap(), visibility_mask.to_vec(), + vec![(npk, shared_secret)], vec![], + vec![None], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + /// Happy path: a program claims a new mask-3 account via `Claim::Pda(seed)`. The circuit + /// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s + /// position, derives `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`, and + /// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim + /// and binds the supplied npk to the `account_id`. + #[test] + fn private_pda_claim_succeeds() { + 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); + let pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![3], + vec![(npk, shared_secret)], vec![], + vec![None], + &program.into(), + ); + + let (output, _proof) = result.expect("mask-3 private PDA claim should succeed"); + assert_eq!(output.new_nullifiers.len(), 1); + assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.ciphertexts.len(), 1); + assert!(output.public_pre_states.is_empty()); + assert!(output.public_post_states.is_empty()); + } + + /// An npk is supplied that does not match the `pre_state`'s `account_id` under + /// `AccountId::for_private_pda(program, claim_seed, npk)`. The claim equality check rejects. + #[test] + fn private_pda_npk_mismatch_fails() { + // `keys_a` produces the `pre_state`'s `account_id` (the registered pair), `keys_b` is + // the mismatched pair supplied in `private_account_keys` for that pre_state. + let program = Program::pda_claimer(); + let keys_a = test_private_account_keys_1(); + let keys_b = test_private_account_keys_2(); + let npk_a = keys_a.npk(); + let npk_b = keys_b.npk(); + let seed = PdaSeed::new([42; 32]); + let shared_secret = SharedSecretKey::new(&[55; 32], &keys_b.vpk()); + + // `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 pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction(seed).unwrap(), + vec![3], + vec![(npk_b, shared_secret)], vec![], + vec![None], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + /// Happy path for the caller-seeds authorization of a mask-3 PDA. The delegator claims a + /// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same + /// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state`'s authorization + /// is established via the private derivation + /// `AccountId::for_private_pda(delegator, seed, npk) == pre.account_id`. + #[test] + fn caller_pda_seeds_authorize_private_pda_for_callee() { + let delegator = Program::private_pda_delegator(); + let callee = Program::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + 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 pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let callee_id = callee.id(); + let program_with_deps = + ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((seed, seed, callee_id)).unwrap(), + vec![3], + vec![(npk, shared_secret)], + vec![], + vec![None], + &program_with_deps, + ); + + let (output, _proof) = + result.expect("caller-seeds authorization of mask-3 private PDA should succeed"); + assert_eq!(output.new_commitments.len(), 1); + assert_eq!(output.new_nullifiers.len(), 1); + } + + /// The delegator chains with a different seed than the one it claimed with. In the callee + /// step, neither public nor private caller-seeds authorization matches; `pre.is_authorized` + /// was set to `true` by the delegator but no proven source supports it, so the consistency + /// assertion rejects. + #[test] + fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() { + let delegator = Program::private_pda_delegator(); + let callee = Program::auth_asserting_noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let claim_seed = PdaSeed::new([77; 32]); + 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 pre_state = AccountWithMetadata::new(Account::default(), false, account_id); + + let callee_id = callee.id(); + let program_with_deps = + ProgramWithDependencies::new(delegator, [(callee_id, callee)].into()); + + let result = execute_and_prove( + vec![pre_state], + Program::serialize_instruction((claim_seed, wrong_delegated_seed, callee_id)).unwrap(), + vec![3], + vec![(npk, shared_secret)], + vec![], + vec![None], + &program_with_deps, + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + /// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of + /// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide + /// family-binding check, a program could claim `PDA_alice` (mask-3, `alice_npk`) and + /// `PDA_bob` (mask-3, `bob_npk`) under the same seed in one transaction, and once reuse + /// is supported a later chained call could delegate both to a callee via + /// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup + /// here: after the first claim records `(program, seed) → PDA_alice`, the second claim + /// tries to record `(program, seed) → PDA_bob` and panics. + #[test] + fn two_private_pda_claims_under_same_seed_are_rejected() { + let program = Program::two_pda_claimer(); + let keys_a = test_private_account_keys_1(); + let keys_b = test_private_account_keys_2(); + let seed = PdaSeed::new([55; 32]); + 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 pre_a = AccountWithMetadata::new(Account::default(), false, account_a); + let pre_b = AccountWithMetadata::new(Account::default(), false, account_b); + + let result = execute_and_prove( + vec![pre_a, pre_b], + Program::serialize_instruction(seed).unwrap(), + vec![3, 3], + vec![(keys_a.npk(), shared_a), (keys_b.npk(), shared_b)], + vec![], + vec![None, None], + &program.into(), + ); + + assert!(matches!(result, Err(NssaError::CircuitProvingError(_)))); + } + + /// Pins the current limitation: a mask-3 PDA that was claimed in a previous transaction + /// cannot be re-used in a new transaction as-is. This PR only binds supplied npks via a + /// fresh `Claim::Pda` or a caller's `ChainedCall.pda_seeds`, neither is present when a + /// program operates on an already-owned private PDA at top level. The reject site is the + /// post-loop `private_pda_bound_positions` assertion in + /// `privacy_preserving_circuit.rs`: `noop` emits no `Claim::Pda` and there is no caller + /// `ChainedCall.pda_seeds`, so position 0 is never bound and the assertion fires. + // TODO: a follow-up PR in the Private PDAs series needs to let the wallet supply a + // `(seed, original_owner_program_id)` side input per mask-3 `pre_state` so the circuit + // can re-verify `AccountId::for_private_pda(owner, seed, npk) == pre.account_id` without a + // claim. + #[test] + fn private_pda_top_level_reuse_rejected_by_binding_check() { + let program = Program::noop(); + let keys = test_private_account_keys_1(); + let npk = keys.npk(); + let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk()); + let seed = PdaSeed::new([99; 32]); + + // 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 owned_pre_state = AccountWithMetadata::new( + Account { + program_owner: program.id(), + ..Account::default() + }, + true, + account_id, + ); + + let result = execute_and_prove( + vec![owned_pre_state], + Program::serialize_instruction(()).unwrap(), + vec![3], + vec![(npk, shared_secret)], + vec![], + vec![None], &program.into(), ); @@ -2671,7 +2967,7 @@ pub mod tests { fn execution_that_requires_authentication_of_a_program_derived_account_id_succeeds() { let chain_caller = Program::chain_caller(); let pda_seed = PdaSeed::new([37; 32]); - let from = AccountId::from((&chain_caller.id(), &pda_seed)); + let from = AccountId::for_public_pda(&chain_caller.id(), &pda_seed); let to = AccountId::new([2; 32]); let initial_balance = 1000; let initial_data = [(from, initial_balance), (to, 0)]; @@ -2983,7 +3279,8 @@ pub mod tests { let pinata_definition_id = AccountId::new([1; 32]); let pinata_token_definition_id = AccountId::new([2; 32]); // Total supply of pinata token will be in an account under a PDA. - let pinata_token_holding_id = AccountId::from((&pinata_token.id(), &PdaSeed::new([0; 32]))); + let pinata_token_holding_id = + AccountId::for_public_pda(&pinata_token.id(), &PdaSeed::new([0; 32])); let winner_token_holding_id = AccountId::new([3; 32]); let expected_winner_account_holding = token_core::TokenHolding::Fungible { @@ -3088,7 +3385,12 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id: err_account_id } + )) if err_account_id == account_id + )); } /// This test ensures that even if a malicious program tries to perform overflow of balances @@ -3134,7 +3436,22 @@ pub mod tests { let witness_set = public_transaction::WitnessSet::for_message(&message, &[&sender_key]); let tx = PublicTransaction::new(message, witness_set); let res = state.transition_from_public_transaction(&tx, 1, 0); - assert!(matches!(res, Err(NssaError::InvalidProgramBehavior))); + let expected_total_balance_pre_states = WrappedBalanceSum::from_balances( + [sender_init_balance, recipient_init_balance].into_iter(), + ) + .unwrap(); + let expected_total_balance_post_states = WrappedBalanceSum::from_balances( + [sender_init_balance, recipient_init_balance, u128::MAX, 1].into_iter(), + ) + .unwrap(); + assert!(matches!( + res, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::MismatchedTotalBalance { total_balance_pre_states, total_balance_post_states } + ) + )) if total_balance_pre_states == expected_total_balance_pre_states && total_balance_post_states == expected_total_balance_post_states + )); let sender_post = state.get_account_by_id(sender_id); let recipient_post = state.get_account_by_id(recipient_id); @@ -3379,7 +3696,14 @@ pub mod tests { let result = state.transition_from_public_transaction(&tx, 1, 0); // Should fail - cannot modify data without claiming the account - assert!(matches!(result, Err(NssaError::InvalidProgramBehavior))); + assert!(matches!( + result, + Err(NssaError::InvalidProgramBehavior( + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { + account_id: err_account_id + } + )) if err_account_id == account_id + )); } #[test] @@ -3973,8 +4297,8 @@ pub mod tests { let callback = Program::flash_swap_callback(); let token = Program::authenticated_transfer_program(); - let vault_id = AccountId::from((&initiator.id(), &PdaSeed::new([0_u8; 32]))); - let receiver_id = AccountId::from((&callback.id(), &PdaSeed::new([1_u8; 32]))); + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); let initial_balance: u128 = 1000; let amount_out: u128 = 100; @@ -4024,8 +4348,8 @@ pub mod tests { let callback = Program::flash_swap_callback(); let token = Program::authenticated_transfer_program(); - let vault_id = AccountId::from((&initiator.id(), &PdaSeed::new([0_u8; 32]))); - let receiver_id = AccountId::from((&callback.id(), &PdaSeed::new([1_u8; 32]))); + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); let initial_balance: u128 = 1000; let amount_out: u128 = 100; @@ -4082,8 +4406,8 @@ pub mod tests { let callback = Program::flash_swap_callback(); let token = Program::authenticated_transfer_program(); - let vault_id = AccountId::from((&initiator.id(), &PdaSeed::new([0_u8; 32]))); - let receiver_id = AccountId::from((&callback.id(), &PdaSeed::new([1_u8; 32]))); + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); + let receiver_id = AccountId::for_public_pda(&callback.id(), &PdaSeed::new([1_u8; 32])); let initial_balance: u128 = 1000; @@ -4131,7 +4455,7 @@ pub mod tests { let initiator = Program::flash_swap_initiator(); let token = Program::authenticated_transfer_program(); - let vault_id = AccountId::from((&initiator.id(), &PdaSeed::new([0_u8; 32]))); + let vault_id = AccountId::for_public_pda(&initiator.id(), &PdaSeed::new([0_u8; 32])); let vault_account = Account { program_owner: token.id(), diff --git a/nssa/src/validated_state_diff.rs b/nssa/src/validated_state_diff.rs index 9614d1b7..455a13a6 100644 --- a/nssa/src/validated_state_diff.rs +++ b/nssa/src/validated_state_diff.rs @@ -8,13 +8,13 @@ use nssa_core::{ BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp, account::{Account, AccountId, AccountWithMetadata}, program::{ - ChainedCall, Claim, DEFAULT_PROGRAM_ID, compute_authorized_pdas, validate_execution, + ChainedCall, Claim, DEFAULT_PROGRAM_ID, compute_public_authorized_pdas, validate_execution, }, }; use crate::{ V03State, ensure, - error::NssaError, + error::{InvalidProgramBehaviorError, NssaError}, privacy_preserving_transaction::{ PrivacyPreservingTransaction, circuit::Proof, message::Message, }, @@ -129,7 +129,7 @@ impl ValidatedStateDiff { ); let authorized_pdas = - compute_authorized_pdas(caller_program_id, &chained_call.pda_seeds); + compute_public_authorized_pdas(caller_program_id, &chained_call.pda_seeds); let is_authorized = |account_id: &AccountId| { signer_account_ids.contains(account_id) || authorized_pdas.contains(account_id) @@ -145,39 +145,52 @@ impl ValidatedStateDiff { .unwrap_or_else(|| state.get_account_by_id(account_id)); ensure!( pre.account == expected_pre, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::InconsistentAccountPreState { + account_id, + expected: Box::new(expected_pre), + actual: Box::new(pre.account.clone()) + } ); // Check that authorization flags are consistent with the provided ones or // authorized by program through the PDA mechanism + let expected_is_authorized = is_authorized(&account_id); ensure!( - pre.is_authorized == is_authorized(&account_id), - NssaError::InvalidProgramBehavior + pre.is_authorized == expected_is_authorized, + InvalidProgramBehaviorError::InconsistentAccountAuthorization { + account_id, + expected_authorization: expected_is_authorized, + actual_authorization: pre.is_authorized + } ); } // Verify that the program output's self_program_id matches the expected program ID. ensure!( program_output.self_program_id == chained_call.program_id, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::MismatchedProgramId { + expected: chained_call.program_id, + actual: program_output.self_program_id + } ); // Verify that the program output's caller_program_id matches the actual caller. ensure!( program_output.caller_program_id == caller_program_id, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::MismatchedCallerProgramId { + expected: caller_program_id, + actual: program_output.caller_program_id, + } ); // Verify execution corresponds to a well-behaved program. // See the # Programs section for the definition of the `validate_execution` method. - ensure!( - validate_execution( - &program_output.pre_states, - &program_output.post_states, - chained_call.program_id, - ), - NssaError::InvalidProgramBehavior - ); + validate_execution( + &program_output.pre_states, + &program_output.post_states, + chained_call.program_id, + ) + .map_err(InvalidProgramBehaviorError::ExecutionValidationFailed)?; // Verify validity window ensure!( @@ -192,27 +205,34 @@ impl ValidatedStateDiff { let Some(claim) = post.required_claim() else { continue; }; + let account_id = program_output.pre_states[i].account_id; + // The invoked program can only claim accounts with default program id. ensure!( post.account().program_owner == DEFAULT_PROGRAM_ID, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id } ); - let account_id = program_output.pre_states[i].account_id; - match claim { Claim::Authorized => { // The program can only claim accounts that were authorized by the signer. ensure!( is_authorized(&account_id), - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id } ); } Claim::Pda(seed) => { // The program can only claim accounts that correspond to the PDAs it is - // authorized to claim. - let pda = AccountId::from((&chained_call.program_id, &seed)); - ensure!(account_id == pda, NssaError::InvalidProgramBehavior); + // authorized to claim. The public-execution path only sees public + // accounts, so the public-PDA derivation is the correct formula here. + let pda = AccountId::for_public_pda(&chained_call.program_id, &seed); + ensure!( + account_id == pda, + InvalidProgramBehaviorError::MismatchedPdaClaim { + expected: pda, + actual: account_id + } + ); } } @@ -238,7 +258,7 @@ impl ValidatedStateDiff { } // Check that all modified uninitialized accounts where claimed - for post in state_diff.iter().filter_map(|(account_id, post)| { + for (account_id, post) in state_diff.iter().filter_map(|(account_id, post)| { let pre = state.get_account_by_id(*account_id); if pre.program_owner != DEFAULT_PROGRAM_ID { return None; @@ -246,11 +266,11 @@ impl ValidatedStateDiff { if pre == *post { return None; } - Some(post) + Some((*account_id, post)) }) { ensure!( post.program_owner != DEFAULT_PROGRAM_ID, - NssaError::InvalidProgramBehavior + InvalidProgramBehaviorError::DefaultAccountModifiedWithoutClaim { account_id } ); } diff --git a/program_methods/guest/src/bin/privacy_preserving_circuit.rs b/program_methods/guest/src/bin/privacy_preserving_circuit.rs index 1d091e1c..8018cd80 100644 --- a/program_methods/guest/src/bin/privacy_preserving_circuit.rs +++ b/program_methods/guest/src/bin/privacy_preserving_circuit.rs @@ -11,7 +11,7 @@ use nssa_core::{ compute_digest_for_path, program::{ AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID, - MAX_NUMBER_CHAINED_CALLS, ProgramId, ProgramOutput, TimestampValidityWindow, + MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow, validate_execution, }, }; @@ -23,15 +23,62 @@ struct ExecutionState { post_states: HashMap, block_validity_window: BlockValidityWindow, timestamp_validity_window: TimestampValidityWindow, + /// Positions (in `pre_states`) of mask-3 accounts whose supplied npk has been bound to + /// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk)` + /// 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 mask-3 position must appear in this + /// set; otherwise the npk is unbound and the circuit rejects. + private_pda_bound_positions: HashSet, + /// 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 + /// a chained call could authorize multiple family members at once (different npks under the + /// same seed) and let a callee mix balances across them. Every claim and every + /// caller-authorization resolution is recorded here, either as a new `(program, seed)` → + /// `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 mask-3 `pre_state`'s position in `visibility_mask` to the npk supplied for + /// that position in `private_account_keys`. Built once in `derive_from_outputs` by walking + /// `visibility_mask` in lock-step with `private_account_keys`, used later by the claim and + /// caller-seeds authorization paths. + private_pda_npk_by_position: HashMap, } impl ExecutionState { /// Validate program outputs and derive the overall execution state. pub fn derive_from_outputs( visibility_mask: &[u8], + private_account_keys: &[(NullifierPublicKey, SharedSecretKey)], program_id: ProgramId, program_outputs: Vec, ) -> Self { + // Build position → npk map for mask-3 pre_states. `private_account_keys` is consumed in + // pre_state order across all masks 1/2/3, so walk `visibility_mask` in lock-step. The + // downstream `compute_circuit_output` also consumes the same iterator and its trailing + // assertions catch an over-supply of keys; under-supply surfaces here. + let mut private_pda_npk_by_position: HashMap = HashMap::new(); + { + let mut keys_iter = private_account_keys.iter(); + for (pos, &mask) in visibility_mask.iter().enumerate() { + if matches!(mask, 1..=3) { + let (npk, _) = keys_iter.next().unwrap_or_else(|| { + panic!( + "private_account_keys shorter than visibility_mask demands: no key for masked position {pos} (mask {mask})" + ) + }); + if mask == 3 { + private_pda_npk_by_position.insert(pos, *npk); + } + } + } + } + let block_valid_from = program_outputs .iter() .filter_map(|output| output.block_validity_window.start()) @@ -66,6 +113,9 @@ impl ExecutionState { post_states: HashMap::new(), block_validity_window, timestamp_validity_window, + private_pda_bound_positions: HashSet::new(), + pda_family_binding: HashMap::new(), + private_pda_npk_by_position, }; let Some(first_output) = program_outputs.first() else { @@ -125,25 +175,27 @@ impl ExecutionState { // Check that the program is well behaved. // See the # Programs section for the definition of the `validate_execution` method. - let execution_valid = validate_execution( + let validated_execution = validate_execution( &program_output.pre_states, &program_output.post_states, chained_call.program_id, ); - assert!(execution_valid, "Bad behaved program"); + if let Err(err) = validated_execution { + panic!( + "Invalid program behavior in program {:?}: {err}", + chained_call.program_id + ); + } for next_call in program_output.chained_calls.iter().rev() { chained_calls.push_front((next_call.clone(), Some(chained_call.program_id))); } - let authorized_pdas = nssa_core::program::compute_authorized_pdas( - caller_program_id, - &chained_call.pda_seeds, - ); execution_state.validate_and_sync_states( visibility_mask, chained_call.program_id, - &authorized_pdas, + caller_program_id, + &chained_call.pda_seeds, program_output.pre_states, program_output.post_states, ); @@ -157,6 +209,19 @@ impl ExecutionState { "Inner call without a chained call found", ); + // Every mask-3 pre_state must have had its npk bound to its account_id, either via a + // `Claim::Pda(seed)` in some program's post_state or via a caller's `pda_seeds` matching + // the private derivation. An unbound mask-3 pre_state has no cryptographic link between + // the supplied npk and the account_id, and must be rejected. + for (pos, &mask) in visibility_mask.iter().enumerate() { + if mask == 3 { + assert!( + execution_state.private_pda_bound_positions.contains(&pos), + "private PDA pre_state at position {pos} has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds" + ); + } + } + // Check that all modified uninitialized accounts were claimed for (account_id, post) in execution_state .pre_states @@ -186,7 +251,8 @@ impl ExecutionState { &mut self, visibility_mask: &[u8], program_id: ProgramId, - authorized_pdas: &HashSet, + caller_program_id: Option, + caller_pda_seeds: &[PdaSeed], pre_states: Vec, post_states: Vec, ) { @@ -213,19 +279,28 @@ impl ExecutionState { "Inconsistent pre state for account {pre_account_id}", ); - let previous_is_authorized = self + let (previous_is_authorized, pre_state_position) = self .pre_states .iter() - .find(|acc| acc.account_id == pre_account_id) + .enumerate() + .find(|(_, acc)| acc.account_id == pre_account_id) .map_or_else( || panic!( "Pre state must exist in execution state for account {pre_account_id}", ), - |acc| acc.is_authorized + |(pos, acc)| (acc.is_authorized, pos) ); - let is_authorized = - previous_is_authorized || authorized_pdas.contains(&pre_account_id); + let is_authorized = resolve_authorization_and_record_bindings( + &mut self.pda_family_binding, + &mut self.private_pda_bound_positions, + &self.private_pda_npk_by_position, + pre_account_id, + pre_state_position, + caller_program_id, + caller_pda_seeds, + previous_is_authorized, + ); assert_eq!( pre_is_authorized, is_authorized, @@ -252,9 +327,9 @@ impl ExecutionState { .position(|acc| acc.account_id == pre_account_id) .expect("Pre state must exist at this point"); - let is_public_account = visibility_mask[pre_state_position] == 0; - if is_public_account { - match claim { + let mask = visibility_mask[pre_state_position]; + match mask { + 0 => match claim { Claim::Authorized => { // Note: no need to check authorized pdas because we have already // checked consistency of authorization above. @@ -264,18 +339,52 @@ impl ExecutionState { ); } Claim::Pda(seed) => { - let pda = AccountId::from((&program_id, &seed)); + let pda = AccountId::for_public_pda(&program_id, &seed); assert_eq!( pre_account_id, pda, "Invalid PDA claim for account {pre_account_id} which does not match derived PDA {pda}" ); + assert_family_binding( + &mut self.pda_family_binding, + program_id, + seed, + pre_account_id, + ); + } + }, + 3 => { + match claim { + Claim::Authorized => { + assert!( + pre_is_authorized, + "Cannot claim unauthorized private PDA {pre_account_id}" + ); + } + Claim::Pda(seed) => { + let npk = 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); + assert_eq!( + pre_account_id, pda, + "Invalid private PDA claim for account {pre_account_id}" + ); + self.private_pda_bound_positions.insert(pre_state_position); + assert_family_binding( + &mut self.pda_family_binding, + program_id, + seed, + pre_account_id, + ); + } } } - } else { - // We don't care about the exact claim mechanism for private accounts. - // This is because the main reason to have it is to protect against PDA griefing - // attacks in public execution, while private PDA doesn't make much sense - // anyway. + _ => { + // Mask 1/2: standard private accounts don't enforce the claim semantics. + // Unauthorized private claiming is intentionally allowed since operating + // these accounts requires the npk/nsk keypair anyway. + } } post.account_mut().program_owner = program_id; @@ -299,6 +408,82 @@ impl ExecutionState { } } +/// Record or re-verify the `(program_id, seed) → account_id` family binding for the +/// transaction. Any claim or caller-seed authorization that resolves a `pre_state` under +/// `(program_id, seed)` must agree with every prior resolution of the same pair; otherwise a +/// single `pda_seeds: [seed]` entry could authorize multiple private-PDA family members at +/// once (different npks under the same seed) and let a callee mix balances across them. Free +/// function so callers can pass `&mut self.pda_family_binding` without holding a borrow on +/// the surrounding struct's other fields. +fn assert_family_binding( + bindings: &mut HashMap<(ProgramId, PdaSeed), AccountId>, + program_id: ProgramId, + seed: PdaSeed, + account_id: AccountId, +) { + match bindings.entry((program_id, seed)) { + Entry::Vacant(e) => { + e.insert(account_id); + } + Entry::Occupied(e) => { + assert_eq!( + *e.get(), + account_id, + "Two different accounts resolved under the same (program, seed) in one transaction: existing {}, new {account_id}", + e.get() + ); + } + } +} + +/// 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 +/// derivation). When a caller seed matches, also records the `(caller, seed) → account_id` +/// family binding and, for the private form, marks the position in +/// `private_pda_bound_positions`. Only reachable when `caller_program_id.is_some()`, +/// top-level flows have no caller-emitted seeds, so binding at top level must come from the +/// claim path. Free function so callers can pass individual `&mut self.*` field borrows +/// without holding a borrow on the surrounding struct's other fields. +#[expect( + clippy::too_many_arguments, + reason = "breaking out a context struct does not buy us anything here" +)] +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, + pre_account_id: AccountId, + pre_state_position: usize, + caller_program_id: Option, + caller_pda_seeds: &[PdaSeed], + previous_is_authorized: bool, +) -> bool { + let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> = + caller_program_id.and_then(|caller| { + caller_pda_seeds.iter().find_map(|seed| { + 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 + { + return Some((*seed, true, caller)); + } + None + }) + }); + + 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); + } + } + + previous_is_authorized || matched_caller_seed.is_some() +} + fn compute_circuit_output( execution_state: ExecutionState, visibility_mask: &[u8], @@ -434,6 +619,88 @@ fn compute_circuit_output( .checked_add(1) .unwrap_or_else(|| panic!("Too many private accounts, output index overflow")); } + 3 => { + // Private PDA account. The supplied npk has already been bound to + // `pre_state.account_id` upstream in `validate_and_sync_states`, either via a + // `Claim::Pda(seed)` match or via a caller `pda_seeds` match, both of which + // assert `AccountId::for_private_pda(owner, seed, npk) == account_id`. The + // post-loop assertion in `derive_from_outputs` (see the + // `private_pda_bound_positions` check) guarantees that every mask-3 + // position has been through at least one such binding, so this + // branch can safely use the wallet npk without re-verifying. + let Some((npk, shared_secret)) = private_keys_iter.next() else { + panic!("Missing private account key"); + }; + + let (new_nullifier, new_nonce) = if pre_state.is_authorized { + // Existing private PDA with authentication (like mask 1) + let Some(nsk) = private_nsks_iter.next() else { + panic!("Missing private account nullifier secret key"); + }; + assert_eq!( + npk, + &NullifierPublicKey::from(nsk), + "Nullifier public key mismatch" + ); + + let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { + panic!("Missing membership proof"); + }; + + let new_nullifier = compute_nullifier_and_set_digest( + membership_proof_opt.as_ref(), + &pre_state.account, + npk, + nsk, + ); + let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk); + (new_nullifier, new_nonce) + } else { + // New private PDA (like mask 2). The default + unauthorized requirement + // here rules out use cases like a fully-private multisig, which would need + // a non-default, non-authorized private PDA input account. + // TODO(private-pdas-pr-2/3): relax this once the wallet can supply a + // `(seed, owner)` side input so the npk-to-account_id binding can be + // re-verified for an existing private PDA without a `Claim::Pda` or caller + // `pda_seeds` match. + assert_eq!( + pre_state.account, + Account::default(), + "New private PDA must be default" + ); + + let Some(membership_proof_opt) = private_membership_proofs_iter.next() else { + panic!("Missing membership proof"); + }; + assert!( + membership_proof_opt.is_none(), + "Membership proof must be None for new accounts" + ); + + let nullifier = Nullifier::for_account_initialization(npk); + let new_nonce = Nonce::private_account_nonce_init(npk); + ((nullifier, DUMMY_COMMITMENT_HASH), new_nonce) + }; + output.new_nullifiers.push(new_nullifier); + + let mut post_with_updated_nonce = post_state; + post_with_updated_nonce.nonce = new_nonce; + + let commitment_post = Commitment::new(npk, &post_with_updated_nonce); + + let encrypted_account = EncryptionScheme::encrypt( + &post_with_updated_nonce, + shared_secret, + &commitment_post, + output_index, + ); + + output.new_commitments.push(commitment_post); + output.ciphertexts.push(encrypted_account); + output_index = output_index + .checked_add(1) + .unwrap_or_else(|| panic!("Too many private accounts, output index overflow")); + } _ => panic!("Invalid visibility mask value"), } } @@ -496,8 +763,12 @@ fn main() { program_id, } = env::read(); - let execution_state = - ExecutionState::derive_from_outputs(&visibility_mask, program_id, program_outputs); + let execution_state = ExecutionState::derive_from_outputs( + &visibility_mask, + &private_account_keys, + program_id, + program_outputs, + ); let output = compute_circuit_output( execution_state, diff --git a/programs/amm/core/src/lib.rs b/programs/amm/core/src/lib.rs index 017f14ff..6e005c9e 100644 --- a/programs/amm/core/src/lib.rs +++ b/programs/amm/core/src/lib.rs @@ -135,10 +135,10 @@ pub fn compute_pool_pda( definition_token_a_id: AccountId, definition_token_b_id: AccountId, ) -> AccountId { - AccountId::from(( + AccountId::for_public_pda( &amm_program_id, &compute_pool_pda_seed(definition_token_a_id, definition_token_b_id), - )) + ) } #[must_use] @@ -175,10 +175,10 @@ pub fn compute_vault_pda( pool_id: AccountId, definition_token_id: AccountId, ) -> AccountId { - AccountId::from(( + AccountId::for_public_pda( &amm_program_id, &compute_vault_pda_seed(pool_id, definition_token_id), - )) + ) } #[must_use] @@ -199,7 +199,7 @@ pub fn compute_vault_pda_seed(pool_id: AccountId, definition_token_id: AccountId #[must_use] pub fn compute_liquidity_token_pda(amm_program_id: ProgramId, pool_id: AccountId) -> AccountId { - AccountId::from((&amm_program_id, &compute_liquidity_token_pda_seed(pool_id))) + AccountId::for_public_pda(&amm_program_id, &compute_liquidity_token_pda_seed(pool_id)) } #[must_use] diff --git a/programs/associated_token_account/core/src/lib.rs b/programs/associated_token_account/core/src/lib.rs index 994c632b..8fe6e267 100644 --- a/programs/associated_token_account/core/src/lib.rs +++ b/programs/associated_token_account/core/src/lib.rs @@ -61,7 +61,7 @@ pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSee } pub fn get_associated_token_account_id(ata_program_id: &ProgramId, seed: &PdaSeed) -> AccountId { - AccountId::from((ata_program_id, seed)) + AccountId::for_public_pda(ata_program_id, seed) } /// Verify the ATA's address matches `(ata_program_id, owner, definition)` and return diff --git a/sequencer/core/src/lib.rs b/sequencer/core/src/lib.rs index cbf8e910..47037fbd 100644 --- a/sequencer/core/src/lib.rs +++ b/sequencer/core/src/lib.rs @@ -1094,7 +1094,7 @@ mod tests { // Start a sequencer from config with a preconfigured private genesis account let mut config = setup_sequencer_config(); config.initial_private_accounts = Some(vec![PrivateAccountPublicInitialData { - npk: npk.clone(), + npk, account: genesis_account, }]); @@ -1110,7 +1110,7 @@ mod tests { vec![AccountWithMetadata::new(Account::default(), true, &npk)], Program::serialize_instruction(0_u128).unwrap(), vec![1], - vec![(npk.clone(), shared_secret)], + vec![(npk, shared_secret)], vec![nsk], vec![None], &Program::authenticated_transfer_program().into(), diff --git a/test_program_methods/guest/src/bin/auth_asserting_noop.rs b/test_program_methods/guest/src/bin/auth_asserting_noop.rs new file mode 100644 index 00000000..0b6d9176 --- /dev/null +++ b/test_program_methods/guest/src/bin/auth_asserting_noop.rs @@ -0,0 +1,40 @@ +use nssa_core::program::{AccountPostState, ProgramInput, ProgramOutput, read_nssa_inputs}; + +/// A variant of `noop` that asserts every `pre_state.is_authorized == true` before echoing +/// the `post_states`. Any unauthorized `pre_state` panics the guest, failing the whole +/// circuit proof. Used as a callee in private-PDA delegation tests to actually exercise the +/// authorization propagated through `ChainedCall.pda_seeds`. +type Instruction = (); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + .. + }, + instruction_words, + ) = read_nssa_inputs::(); + + for pre in &pre_states { + assert!( + pre.is_authorized, + "auth_asserting_noop: pre_state {} is not authorized", + pre.account_id + ); + } + + let post_states = pre_states + .iter() + .map(|account| AccountPostState::new(account.account.clone())) + .collect(); + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + pre_states, + post_states, + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/pda_claimer.rs b/test_program_methods/guest/src/bin/pda_claimer.rs new file mode 100644 index 00000000..5dec4da4 --- /dev/null +++ b/test_program_methods/guest/src/bin/pda_claimer.rs @@ -0,0 +1,32 @@ +use nssa_core::program::{ + AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +type Instruction = PdaSeed; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: seed, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + let account_post = AccountPostState::new_claimed(pre.account.clone(), Claim::Pda(seed)); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![account_post], + ) + .write(); +} diff --git a/test_program_methods/guest/src/bin/private_pda_delegator.rs b/test_program_methods/guest/src/bin/private_pda_delegator.rs new file mode 100644 index 00000000..fe55045e --- /dev/null +++ b/test_program_methods/guest/src/bin/private_pda_delegator.rs @@ -0,0 +1,51 @@ +use nssa_core::program::{ + AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput, + read_nssa_inputs, +}; +use risc0_zkvm::serde::to_vec; + +/// Claims the sole `pre_state` as a PDA with `claim_seed`, then chains to `callee_program_id` +/// delegating authorization with `delegated_seed` in `pda_seeds`. When `claim_seed == +/// delegated_seed` this exercises the happy caller-seeds authorization path for mask-3 private +/// PDAs in `validate_and_sync_states`; when they differ, the callee's mask-3 `pre_state` has +/// no matching authorization source and the circuit must reject. +type Instruction = (PdaSeed, PdaSeed, ProgramId); + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: (claim_seed, delegated_seed, callee_program_id), + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre]) = <[_; 1]>::try_from(pre_states) else { + return; + }; + + let claimed = AccountPostState::new_claimed(pre.account.clone(), Claim::Pda(claim_seed)); + + let mut pre_for_callee = pre.clone(); + pre_for_callee.is_authorized = true; + pre_for_callee.account.program_owner = self_program_id; + + let chained_call = ChainedCall { + program_id: callee_program_id, + instruction_data: to_vec(&()).unwrap(), + pre_states: vec![pre_for_callee], + pda_seeds: vec![delegated_seed], + }; + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre], + vec![claimed], + ) + .with_chained_calls(vec![chained_call]) + .write(); +} diff --git a/test_program_methods/guest/src/bin/two_pda_claimer.rs b/test_program_methods/guest/src/bin/two_pda_claimer.rs new file mode 100644 index 00000000..53aae666 --- /dev/null +++ b/test_program_methods/guest/src/bin/two_pda_claimer.rs @@ -0,0 +1,37 @@ +use nssa_core::program::{ + AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs, +}; + +/// Claims two `pre_states` under the same `seed`. Used to exercise the tx-wide +/// `(program_id, seed) → AccountId` family-binding check: when both `pre_states` are mask-3 +/// with different npks, each `Claim::Pda(seed)` resolves to a different `AccountId` under the +/// same `(program, seed)` key, and the circuit must reject. +type Instruction = PdaSeed; + +fn main() { + let ( + ProgramInput { + self_program_id, + caller_program_id, + pre_states, + instruction: seed, + }, + instruction_words, + ) = read_nssa_inputs::(); + + let Ok([pre_a, pre_b]) = <[_; 2]>::try_from(pre_states) else { + return; + }; + + let claim_a = AccountPostState::new_claimed(pre_a.account.clone(), Claim::Pda(seed)); + let claim_b = AccountPostState::new_claimed(pre_b.account.clone(), Claim::Pda(seed)); + + ProgramOutput::new( + self_program_id, + caller_program_id, + instruction_words, + vec![pre_a, pre_b], + vec![claim_a, claim_b], + ) + .write(); +} diff --git a/testnet_initial_state/src/lib.rs b/testnet_initial_state/src/lib.rs index 91315eec..07d546fe 100644 --- a/testnet_initial_state/src/lib.rs +++ b/testnet_initial_state/src/lib.rs @@ -169,7 +169,7 @@ pub fn initial_commitments() -> Vec { initial_priv_accounts_private_keys() .into_iter() .map(|data| PrivateAccountPublicInitialData { - npk: data.key_chain.nullifier_public_key.clone(), + npk: data.key_chain.nullifier_public_key, account: data.account, }) .collect() diff --git a/wallet/src/lib.rs b/wallet/src/lib.rs index 63ea8611..460cfcfd 100644 --- a/wallet/src/lib.rs +++ b/wallet/src/lib.rs @@ -393,7 +393,7 @@ impl WalletCore { acc_manager.visibility_mask().to_vec(), private_account_keys .iter() - .map(|keys| (keys.npk.clone(), keys.ssk)) + .map(|keys| (keys.npk, keys.ssk)) .collect::>(), acc_manager.private_account_auth(), acc_manager.private_account_membership_proofs(), @@ -407,7 +407,7 @@ impl WalletCore { Vec::from_iter(acc_manager.public_account_nonces()), private_account_keys .iter() - .map(|keys| (keys.npk.clone(), keys.vpk.clone(), keys.epk.clone())) + .map(|keys| (keys.npk, keys.vpk.clone(), keys.epk.clone())) .collect(), output, ) diff --git a/wallet/src/pinata_interactions.rs b/wallet/src/pinata_interactions.rs index b883e7e6..77549772 100644 --- a/wallet/src/pinata_interactions.rs +++ b/wallet/src/pinata_interactions.rs @@ -59,7 +59,7 @@ impl WalletCore { &nssa::program::Program::serialize_instruction(solution).unwrap(), &[0, 1], &produce_random_nonces(1), - &[(winner_npk.clone(), shared_secret_winner.clone())], + &[(winner_npk, shared_secret_winner.clone())], &[(winner_nsk.unwrap())], &[winner_proof], &program.into(), @@ -71,7 +71,7 @@ impl WalletCore { vec![pinata_account_id], vec![], vec![( - winner_npk.clone(), + winner_npk, winner_vpk.clone(), eph_holder_winner.generate_ephemeral_public_key(), )], @@ -126,7 +126,7 @@ impl WalletCore { &nssa::program::Program::serialize_instruction(solution).unwrap(), &[0, 2], &produce_random_nonces(1), - &[(winner_npk.clone(), shared_secret_winner.clone())], + &[(winner_npk, shared_secret_winner.clone())], &[], &[], &program.into(), @@ -138,7 +138,7 @@ impl WalletCore { vec![pinata_account_id], vec![], vec![( - winner_npk.clone(), + winner_npk, winner_vpk.clone(), eph_holder_winner.generate_ephemeral_public_key(), )], diff --git a/wallet/src/privacy_preserving_tx.rs b/wallet/src/privacy_preserving_tx.rs index 04056111..14a805c7 100644 --- a/wallet/src/privacy_preserving_tx.rs +++ b/wallet/src/privacy_preserving_tx.rs @@ -138,7 +138,7 @@ impl AccountManager { let eph_holder = EphemeralKeyHolder::new(&pre.npk); Some(PrivateAccountKeys { - npk: pre.npk.clone(), + npk: pre.npk, ssk: eph_holder.calculate_shared_secret_sender(&pre.vpk), vpk: pre.vpk.clone(), epk: eph_holder.generate_ephemeral_public_key(), diff --git a/wallet/src/transaction_utils.rs b/wallet/src/transaction_utils.rs index 1bcb971f..f2def802 100644 --- a/wallet/src/transaction_utils.rs +++ b/wallet/src/transaction_utils.rs @@ -108,8 +108,8 @@ impl WalletCore { &[1, 1], &produce_random_nonces(2), &[ - (from_npk.clone(), shared_secret_from.clone()), - (to_npk.clone(), shared_secret_to.clone()), + (from_npk, shared_secret_from.clone()), + (to_npk, shared_secret_to.clone()), ], &[ (from_nsk.unwrap(), from_proof.unwrap()), @@ -124,12 +124,12 @@ impl WalletCore { vec![], vec![ ( - from_npk.clone(), + from_npk, from_vpk.clone(), eph_holder_from.generate_ephemeral_public_key(), ), ( - to_npk.clone(), + to_npk, to_vpk.clone(), eph_holder_to.generate_ephemeral_public_key(), ), @@ -185,8 +185,8 @@ impl WalletCore { &[1, 2], &produce_random_nonces(2), &[ - (from_npk.clone(), shared_secret_from.clone()), - (to_npk.clone(), shared_secret_to.clone()), + (from_npk, shared_secret_from.clone()), + (to_npk, shared_secret_to.clone()), ], &[(from_nsk.unwrap(), from_proof.unwrap())], &program.into(), @@ -198,12 +198,12 @@ impl WalletCore { vec![], vec![ ( - from_npk.clone(), + from_npk, from_vpk.clone(), eph_holder_from.generate_ephemeral_public_key(), ), ( - to_npk.clone(), + to_npk, to_vpk.clone(), eph_holder_to.generate_ephemeral_public_key(), ), @@ -255,8 +255,8 @@ impl WalletCore { &[1, 2], &produce_random_nonces(2), &[ - (from_npk.clone(), shared_secret_from.clone()), - (to_npk.clone(), shared_secret_to.clone()), + (from_npk, shared_secret_from.clone()), + (to_npk, shared_secret_to.clone()), ], &[(from_nsk.unwrap(), from_proof.unwrap())], &program.into(), @@ -268,12 +268,12 @@ impl WalletCore { vec![], vec![ ( - from_npk.clone(), + from_npk, from_vpk.clone(), eph_holder.generate_ephemeral_public_key(), ), ( - to_npk.clone(), + to_npk, to_vpk.clone(), eph_holder.generate_ephemeral_public_key(), ), @@ -324,7 +324,7 @@ impl WalletCore { &instruction_data, &[1, 0], &produce_random_nonces(1), - &[(from_npk.clone(), shared_secret.clone())], + &[(from_npk, shared_secret.clone())], &[(from_nsk.unwrap(), from_proof.unwrap())], &program.into(), ) @@ -334,7 +334,7 @@ impl WalletCore { vec![to], vec![], vec![( - from_npk.clone(), + from_npk, from_vpk.clone(), eph_holder.generate_ephemeral_public_key(), )], @@ -385,7 +385,7 @@ impl WalletCore { &instruction_data, &[0, 1], &produce_random_nonces(1), - &[(to_npk.clone(), shared_secret.clone())], + &[(to_npk, shared_secret.clone())], &[(to_nsk.unwrap(), to_proof)], &program.into(), ) @@ -395,7 +395,7 @@ impl WalletCore { vec![from], vec![from_acc.nonce], vec![( - to_npk.clone(), + to_npk, to_vpk.clone(), eph_holder.generate_ephemeral_public_key(), )], @@ -451,7 +451,7 @@ impl WalletCore { &instruction_data, &[0, 2], &produce_random_nonces(1), - &[(to_npk.clone(), shared_secret.clone())], + &[(to_npk, shared_secret.clone())], &[], &program.into(), ) @@ -461,7 +461,7 @@ impl WalletCore { vec![from], vec![from_acc.nonce], vec![( - to_npk.clone(), + to_npk, to_vpk.clone(), eph_holder.generate_ephemeral_public_key(), )], @@ -513,7 +513,7 @@ impl WalletCore { &instruction_data, &[0, 2], &produce_random_nonces(1), - &[(to_npk.clone(), shared_secret.clone())], + &[(to_npk, shared_secret.clone())], &[], &program.into(), ) @@ -523,7 +523,7 @@ impl WalletCore { vec![from], vec![from_acc.nonce], vec![( - to_npk.clone(), + to_npk, to_vpk.clone(), eph_holder.generate_ephemeral_public_key(), )], @@ -565,7 +565,7 @@ impl WalletCore { &Program::serialize_instruction(instruction).unwrap(), &[2], &produce_random_nonces(1), - &[(from_npk.clone(), shared_secret_from.clone())], + &[(from_npk, shared_secret_from.clone())], &[], &Program::authenticated_transfer_program().into(), ) @@ -575,7 +575,7 @@ impl WalletCore { vec![], vec![], vec![( - from_npk.clone(), + from_npk, from_vpk.clone(), eph_holder_from.generate_ephemeral_public_key(), )],