This commit is contained in:
Sergio Chouhy 2026-05-07 01:41:35 -03:00
parent d4334c4694
commit f722d257a3
16 changed files with 190 additions and 99 deletions

View File

@ -12,14 +12,15 @@ use integration_tests::{
};
use log::info;
use nssa::{
AccountId, ProgramId,
privacy_preserving_transaction::circuit::ProgramWithDependencies,
AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies,
program::Program,
};
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey, program::PdaSeed};
use tokio::test;
use wallet::{PrivacyPreservingAccount, WalletCore};
use wallet::cli::{Command, account::AccountSubcommand};
use wallet::{
PrivacyPreservingAccount, WalletCore,
cli::{Command, account::AccountSubcommand},
};
/// Funds a private PDA via the proxy program with a chained call to auth_transfer.
///
@ -211,7 +212,10 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
verify_commitment_is_in_state(commitment_1.clone(), ctx.sequencer_client()).await,
"alice_pda_1 commitment not in state after receive"
);
assert_ne!(commitment_0, commitment_1, "distinct identifiers must yield distinct commitments");
assert_ne!(
commitment_0, commitment_1,
"distinct identifiers must yield distinct commitments"
);
// ── Spend ─────────────────────────────────────────────────────────────────────────────────────

View File

@ -116,7 +116,10 @@ impl KeyTreeNode for ChildKeysPrivate {
fn account_ids(&self) -> impl Iterator<Item = nssa::AccountId> {
let npk = self.value.0.nullifier_public_key;
self.value.1.iter().map(move |(kind, _)| nssa::AccountId::for_private_account(&npk, kind))
self.value
.1
.iter()
.map(move |(kind, _)| nssa::AccountId::for_private_account(&npk, kind))
}
}

View File

@ -66,9 +66,10 @@ impl NSSAUserData {
let mut check_res = true;
for (account_id, entry) in accounts_keys_map {
let npk = &entry.key_chain.nullifier_public_key;
let any_match = entry.accounts.iter().any(|(kind, _)| {
nssa::AccountId::for_private_account(npk, kind) == *account_id
});
let any_match = entry
.accounts
.iter()
.any(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == *account_id);
if !any_match {
println!("No matching entry found for account_id {account_id}");
check_res = false;
@ -170,8 +171,10 @@ impl NSSAUserData {
// Check default accounts
if let Some(entry) = self.default_user_private_accounts.get(&account_id) {
let npk = &entry.key_chain.nullifier_public_key;
if let Some((kind, account)) =
entry.accounts.iter().find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id)
if let Some((kind, account)) = entry
.accounts
.iter()
.find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id)
{
return Some((entry.key_chain.clone(), account.clone(), kind.identifier()));
}
@ -181,8 +184,11 @@ impl NSSAUserData {
if let Some(node) = self.private_key_tree.get_node(account_id) {
let key_chain = &node.value.0;
let npk = &key_chain.nullifier_public_key;
if let Some((kind, account)) =
node.value.1.iter().find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id)
if let Some((kind, account)) = node
.value
.1
.iter()
.find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id)
{
return Some((key_chain.clone(), account.clone(), kind.identifier()));
}

View File

@ -91,10 +91,12 @@ impl InputAccountIdentity {
#[must_use]
pub fn npk_if_private_pda(&self) -> Option<(NullifierPublicKey, Identifier)> {
match self {
Self::PrivatePdaInit { npk, identifier, .. } => Some((*npk, *identifier)),
Self::PrivatePdaUpdate { nsk, identifier, .. } => {
Some((NullifierPublicKey::from(nsk), *identifier))
}
Self::PrivatePdaInit {
npk, identifier, ..
} => Some((*npk, *identifier)),
Self::PrivatePdaUpdate {
nsk, identifier, ..
} => Some((NullifierPublicKey::from(nsk), *identifier)),
Self::Public
| Self::PrivateAuthorizedInit { .. }
| Self::PrivateAuthorizedUpdate { .. }

View File

@ -8,11 +8,7 @@ use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
pub use shared_key_derivation::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey};
use crate::{
Commitment,
account::Account,
program::PrivateAccountKind,
};
use crate::{Commitment, account::Account, program::PrivateAccountKind};
#[cfg(feature = "host")]
pub mod shared_key_derivation;
@ -126,7 +122,10 @@ impl EncryptionScheme {
#[cfg(test)]
mod tests {
use super::*;
use crate::{account::{Account, AccountId}, program::PdaSeed};
use crate::{
account::{Account, AccountId},
program::PdaSeed,
};
#[test]
fn encrypt_same_length_for_account_and_pda() {

View File

@ -11,8 +11,8 @@ pub use commitment::{
compute_digest_for_path,
};
pub use encryption::{EncryptionScheme, SharedSecretKey};
pub use program::PrivateAccountKind;
pub use nullifier::{Identifier, Nullifier, NullifierPublicKey, NullifierSecretKey};
pub use program::PrivateAccountKind;
pub mod account;
mod circuit_io;

View File

@ -84,7 +84,11 @@ impl PrivateAccountKind {
bytes[1..17].copy_from_slice(&identifier.to_le_bytes());
// bytes[17..81] are zero padding
}
Self::Pda { program_id, seed, identifier } => {
Self::Pda {
program_id,
seed,
identifier,
} => {
bytes[0] = 0x01;
for (i, &word) in program_id.iter().enumerate() {
bytes[1 + i * 4..1 + (i + 1) * 4].copy_from_slice(&word.to_le_bytes());
@ -107,13 +111,16 @@ impl PrivateAccountKind {
0x01 => {
let mut program_id = [0u32; 8];
for (i, word) in program_id.iter_mut().enumerate() {
*word = u32::from_le_bytes(
bytes[1 + i * 4..1 + (i + 1) * 4].try_into().unwrap(),
);
*word =
u32::from_le_bytes(bytes[1 + i * 4..1 + (i + 1) * 4].try_into().unwrap());
}
let seed = PdaSeed::new(bytes[33..65].try_into().unwrap());
let identifier = Identifier::from_le_bytes(bytes[65..81].try_into().unwrap());
Some(Self::Pda { program_id, seed, identifier })
Some(Self::Pda {
program_id,
seed,
identifier,
})
}
_ => None,
}
@ -180,9 +187,11 @@ impl AccountId {
pub fn for_private_account(npk: &NullifierPublicKey, kind: &PrivateAccountKind) -> Self {
match kind {
PrivateAccountKind::Regular(identifier) => Self::from((npk, *identifier)),
PrivateAccountKind::Pda { program_id, seed, identifier } => {
Self::for_private_pda(program_id, seed, npk, *identifier)
}
PrivateAccountKind::Pda {
program_id,
seed,
identifier,
} => Self::for_private_pda(program_id, seed, npk, *identifier),
}
}
}
@ -952,8 +961,8 @@ mod tests {
let npk = NullifierPublicKey([3; 32]);
let identifier: Identifier = u128::MAX;
let expected = AccountId::new([
59, 239, 182, 97, 14, 220, 96, 115, 238, 133, 143, 33, 234, 82, 237, 255, 148, 110,
54, 124, 98, 159, 245, 101, 146, 182, 150, 54, 37, 62, 25, 17,
59, 239, 182, 97, 14, 220, 96, 115, 238, 133, 143, 33, 234, 82, 237, 255, 148, 110, 54,
124, 98, 159, 245, 101, 146, 182, 150, 54, 37, 62, 25, 17,
]);
assert_eq!(
AccountId::for_private_pda(&program_id, &seed, &npk, identifier),

View File

@ -462,7 +462,11 @@ mod tests {
assert_eq!(
decrypt_kind(&output, &shared_secret, 0),
PrivateAccountKind::Pda { program_id: program.id(), seed, identifier },
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
@ -593,7 +597,10 @@ mod tests {
)
.unwrap();
assert_eq!(decrypt_kind(&output, &ssk, 0), PrivateAccountKind::Regular(identifier));
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts
@ -606,7 +613,11 @@ mod tests {
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let sender = AccountWithMetadata::new(
Account { program_owner: program.id(), balance: 1, ..Account::default() },
Account {
program_owner: program.id(),
balance: 1,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
@ -628,7 +639,10 @@ mod tests {
)
.unwrap();
assert_eq!(decrypt_kind(&output, &ssk, 0), PrivateAccountKind::Regular(identifier));
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts
@ -640,7 +654,11 @@ mod tests {
let identifier: u128 = 99;
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::from((&keys.npk(), identifier));
let account = Account { program_owner: program.id(), balance: 1, ..Account::default() };
let account = Account {
program_owner: program.id(),
balance: 1,
..Account::default()
};
let commitment = Commitment::new(&account_id, &account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&commitment));
@ -664,7 +682,10 @@ mod tests {
)
.unwrap();
assert_eq!(decrypt_kind(&output, &ssk, 0), PrivateAccountKind::Regular(identifier));
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts
@ -681,8 +702,11 @@ mod tests {
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pda_account =
Account { program_owner: auth_transfer_id, balance: 1, ..Account::default() };
let pda_account = Account {
program_owner: auth_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
@ -691,8 +715,10 @@ mod tests {
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps =
ProgramWithDependencies::new(program.clone(), [(auth_transfer_id, auth_transfer)].into());
let program_with_deps = ProgramWithDependencies::new(
program.clone(),
[(auth_transfer_id, auth_transfer)].into(),
);
let (output, _) = execute_and_prove(
vec![pda_pre, recipient_pre],
@ -712,7 +738,11 @@ mod tests {
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Pda { program_id: program.id(), seed, identifier },
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
}

View File

@ -253,7 +253,13 @@ pub mod tests {
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &vpk);
let epk = EphemeralPublicKey::from_scalar(esk);
let ciphertext = EncryptionScheme::encrypt(&account, &PrivateAccountKind::Regular(0), &shared_secret, &commitment, 2);
let ciphertext = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Regular(0),
&shared_secret,
&commitment,
2,
);
let encrypted_account_data =
EncryptedAccountData::new(ciphertext.clone(), &npk, &vpk, epk.clone());

View File

@ -168,7 +168,6 @@ impl Program {
elf: PINATA_TOKEN_ELF.to_vec(),
}
}
}
#[cfg(test)]
@ -343,7 +342,6 @@ mod tests {
}
}
#[must_use]
pub fn changer_claimer() -> Self {
use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID};

View File

@ -4341,7 +4341,7 @@ pub mod tests {
let alice_shared_0 = SharedSecretKey::new(&[10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new(&[11; 32], &alice_keys.vpk());
// Fund alice_pda_0
// Fund alice_pda_0
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
@ -4365,12 +4365,15 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(alice_npk, alice_keys.vpk(), EphemeralPublicKey::from_scalar([10; 32]))],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
output,
)
.unwrap();
let witness_set =
WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
@ -4380,7 +4383,7 @@ pub mod tests {
.unwrap();
}
// Fund alice_pda_1
// Fund alice_pda_1
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
@ -4404,12 +4407,15 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(alice_npk, alice_keys.vpk(), EphemeralPublicKey::from_scalar([11; 32]))],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
output,
)
.unwrap();
let witness_set =
WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
@ -4451,7 +4457,11 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![Nonce(0)],
vec![(alice_npk, alice_keys.vpk(), EphemeralPublicKey::from_scalar([10; 32]))],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
output,
)
.unwrap();
@ -4491,7 +4501,11 @@ pub mod tests {
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![],
vec![(alice_npk, alice_keys.vpk(), EphemeralPublicKey::from_scalar([11; 32]))],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
output,
)
.unwrap();

View File

@ -6,7 +6,7 @@ use std::{
use nssa_core::{
Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptionScheme, Identifier,
InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey,
PrivateAccountKind, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput,
PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, PrivateAccountKind,
SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce},
compute_digest_for_path,
@ -25,8 +25,8 @@ struct ExecutionState {
block_validity_window: BlockValidityWindow,
timestamp_validity_window: TimestampValidityWindow,
/// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound to
/// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk, identifier)`
/// check.
/// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk,
/// identifier)` check.
/// Two proof paths populate this set: a `Claim::Pda(seed)` in a program's `post_state` on
/// that `pre_state`, or a caller's `ChainedCall.pda_seeds` entry matching that `pre_state`
/// under the private derivation. Binding is an idempotent property, not an event: the same
@ -35,7 +35,8 @@ struct ExecutionState {
/// not `assert!(insert)`. After the main loop, every private-PDA position must appear in this
/// map; otherwise the npk is unbound and the circuit rejects.
/// The stored `(ProgramId, PdaSeed)` is the owner program and seed, used in
/// `compute_circuit_output` to construct `PrivateAccountKind::Pda { program_id, seed, identifier }`.
/// `compute_circuit_output` to construct `PrivateAccountKind::Pda { program_id, seed,
/// identifier }`.
private_pda_bound_positions: HashMap<usize, (ProgramId, PdaSeed)>,
/// 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
@ -210,7 +211,9 @@ impl ExecutionState {
for (pos, account_identity) in account_identities.iter().enumerate() {
if account_identity.is_private_pda() {
assert!(
execution_state.private_pda_bound_positions.contains_key(&pos),
execution_state
.private_pda_bound_positions
.contains_key(&pos),
"private PDA pre_state at position {pos} has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds"
);
}
@ -361,12 +364,14 @@ impl ExecutionState {
.expect(
"private PDA pre_state must have an npk in the position map",
);
let pda = AccountId::for_private_pda(&program_id, &seed, npk, *identifier);
let pda =
AccountId::for_private_pda(&program_id, &seed, npk, *identifier);
assert_eq!(
pre_account_id, pda,
"Invalid private PDA claim for account {pre_account_id}"
);
self.private_pda_bound_positions.insert(pre_state_position, (program_id, seed));
self.private_pda_bound_positions
.insert(pre_state_position, (program_id, seed));
assert_family_binding(
&mut self.pda_family_binding,
program_id,
@ -459,7 +464,8 @@ fn resolve_authorization_and_record_bindings(
if AccountId::for_public_pda(&caller, seed) == pre_account_id {
return Some((*seed, false, caller));
}
if let Some((npk, identifier)) = private_pda_npk_by_position.get(&pre_state_position)
if let Some((npk, identifier)) =
private_pda_npk_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, *identifier) == pre_account_id
{
return Some((*seed, true, caller));
@ -614,7 +620,11 @@ fn compute_circuit_output(
new_nonce,
);
}
InputAccountIdentity::PrivatePdaInit { npk: _, ssk, identifier } => {
InputAccountIdentity::PrivatePdaInit {
npk: _,
ssk,
identifier,
} => {
// The npk-to-account_id binding is established upstream in
// `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds`
// match. Here we only enforce the init pre-conditions. The supplied npk on

View File

@ -1,6 +1,9 @@
use nssa_core::{
account::AccountWithMetadata,
program::{AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput, read_nssa_inputs},
program::{
AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
},
};
use risc0_zkvm::serde::to_vec;
@ -8,14 +11,13 @@ use risc0_zkvm::serde::to_vec;
///
/// The `is_fund` flag selects the operating mode:
///
/// - `false` (Spend): pre_states = [pda (authorized), recipient].
/// Debits the PDA. The PDA-to-npk binding is established via `pda_seeds` in the chained
/// call to auth_transfer.
/// - `false` (Spend): pre_states = [pda (authorized), recipient]. Debits the PDA. The PDA-to-npk
/// binding is established via `pda_seeds` in the chained call to auth_transfer.
///
/// - `true` (Fund): pre_states = [sender (authorized), pda (foreign/uninitialized)].
/// Credits the PDA. A direct call to auth_transfer cannot bind the PDA because auth_transfer
/// uses `Claim::Authorized`, not `Claim::Pda`. Routing through this proxy establishes the
/// binding via `pda_seeds` in the chained call.
/// - `true` (Fund): pre_states = [sender (authorized), pda (foreign/uninitialized)]. Credits the
/// PDA. A direct call to auth_transfer cannot bind the PDA because auth_transfer uses
/// `Claim::Authorized`, not `Claim::Pda`. Routing through this proxy establishes the binding via
/// `pda_seeds` in the chained call.
type Instruction = (PdaSeed, u128, ProgramId, bool);
fn main() {

View File

@ -90,7 +90,10 @@ impl WalletChainStore {
data.account_id(),
UserPrivateAccountData {
key_chain: data.key_chain,
accounts: vec![(PrivateAccountKind::Regular(data.identifier), data.account)],
accounts: vec![(
PrivateAccountKind::Regular(data.identifier),
data.account,
)],
},
);
}

View File

@ -283,8 +283,11 @@ impl WalletCore {
.0
.nullifier_public_key;
let account_id = AccountId::from((&npk, identifier));
self.storage
.insert_private_account_data(account_id, &PrivateAccountKind::Regular(identifier), Account::default());
self.storage.insert_private_account_data(
account_id,
&PrivateAccountKind::Regular(identifier),
Account::default(),
);
(account_id, cci)
}
@ -545,9 +548,16 @@ impl WalletCore {
PrivateAccountKind::Regular(identifier) => {
nssa::AccountId::from((npk, *identifier))
}
PrivateAccountKind::Pda { program_id, seed, identifier } => {
nssa::AccountId::for_private_pda(program_id, seed, npk, *identifier)
}
PrivateAccountKind::Pda {
program_id,
seed,
identifier,
} => nssa::AccountId::for_private_pda(
program_id,
seed,
npk,
*identifier,
),
};
(account_id, kind, res_acc)
})

View File

@ -198,23 +198,19 @@ impl AccountManager {
.iter()
.map(|state| match state {
State::Public { .. } => InputAccountIdentity::Public,
State::Private(pre) if pre.is_pda => {
match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => {
InputAccountIdentity::PrivatePdaUpdate {
ssk: pre.ssk,
nsk,
membership_proof,
identifier: pre.identifier,
}
}
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
identifier: pre.identifier,
},
}
}
State::Private(pre) if pre.is_pda => match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => InputAccountIdentity::PrivatePdaUpdate {
ssk: pre.ssk,
nsk,
membership_proof,
identifier: pre.identifier,
},
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
identifier: pre.identifier,
},
},
State::Private(pre) => match (pre.nsk, pre.proof.clone()) {
(Some(nsk), Some(membership_proof)) => {
InputAccountIdentity::PrivateAuthorizedUpdate {
@ -321,4 +317,3 @@ async fn private_acc_preparation(
is_pda,
})
}