Merge branch 'main' into Pravdyvy/indexer-ffi-spawns-rpc-for-communication

This commit is contained in:
Pravdyvy 2026-04-27 13:58:10 +03:00 committed by GitHub
commit 02949e961a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
62 changed files with 1293 additions and 175 deletions

4
Cargo.lock generated
View File

@ -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",

View File

@ -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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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.

View File

@ -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![];

View File

@ -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()

View File

@ -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(),

View File

@ -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<u8>,
/// Public keys of private accounts.
pub private_account_keys: Vec<(NullifierPublicKey, SharedSecretKey)>,

View File

@ -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 {

View File

@ -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<T> {
/// 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<AccountWithMetadata>,
/// 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<PdaSeed>,
}
@ -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<Item = u128>) -> Option<Self> {
pub fn from_balances(balances: impl Iterator<Item = u128>) -> Option<Self> {
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<u128> 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<ProgramId>,
pda_seeds: &[PdaSeed],
) -> HashSet<AccountId> {
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<T: DeserializeOwned>() -> (ProgramInput<T>, 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());
}
}

View File

@ -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<Account>,
actual: Box<Account>,
},
#[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<ProgramId>,
actual: Option<ProgramId>,
},
#[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 {

View File

@ -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)));
}

View File

@ -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::{

View File

@ -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(),

View File

@ -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 }
);
}

View File

@ -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<AccountId, Account>,
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<usize>,
/// 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<usize, NullifierPublicKey>,
}
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<ProgramOutput>,
) -> 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<usize, NullifierPublicKey> = 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<AccountId>,
caller_program_id: Option<ProgramId>,
caller_pda_seeds: &[PdaSeed],
pre_states: Vec<AccountWithMetadata>,
post_states: Vec<AccountPostState>,
) {
@ -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<usize>,
private_pda_npk_by_position: &HashMap<usize, NullifierPublicKey>,
pre_account_id: AccountId,
pre_state_position: usize,
caller_program_id: Option<ProgramId>,
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,

View File

@ -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]

View File

@ -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

View File

@ -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(),

View File

@ -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::<Instruction>();
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();
}

View File

@ -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::<Instruction>();
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();
}

View File

@ -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::<Instruction>();
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();
}

View File

@ -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::<Instruction>();
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();
}

View File

@ -169,7 +169,7 @@ pub fn initial_commitments() -> Vec<PrivateAccountPublicInitialData> {
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()

View File

@ -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::<Vec<_>>(),
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,
)

View File

@ -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(),
)],

View File

@ -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(),

View File

@ -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(),
)],