Merge pull request #464 from logos-blockchain/schouhy/diversify-private-pdas-by-identifier

feat!: Diversify private pdas by identifier
This commit is contained in:
Sergio Chouhy 2026-05-12 14:30:49 -03:00 committed by GitHub
commit d38ca35337
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
71 changed files with 1526 additions and 324 deletions

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

@ -48,7 +48,7 @@ impl IndexerCore {
.iter()
.map(|init_comm_data| {
let npk = &init_comm_data.npk;
let account_id = nssa::AccountId::from((npk, 0));
let account_id = nssa::AccountId::for_regular_private_account(npk, 0);
let mut acc = init_comm_data.account.clone();

View File

@ -59,12 +59,16 @@ impl InitialData {
}
let mut private_charlie_key_chain = KeyChain::new_os_random();
let mut private_charlie_account_id =
AccountId::from((&private_charlie_key_chain.nullifier_public_key, 0));
let mut private_charlie_account_id = AccountId::for_regular_private_account(
&private_charlie_key_chain.nullifier_public_key,
0,
);
let mut private_david_key_chain = KeyChain::new_os_random();
let mut private_david_account_id =
AccountId::from((&private_david_key_chain.nullifier_public_key, 0));
let mut private_david_account_id = AccountId::for_regular_private_account(
&private_david_key_chain.nullifier_public_key,
0,
);
// Ensure consistent ordering
if private_charlie_account_id > private_david_account_id {

View File

@ -29,6 +29,7 @@ pub mod test_context_ffi;
pub const TIME_TO_WAIT_FOR_BLOCK_SECONDS: u64 = 12;
pub const NSSA_PROGRAM_FOR_TEST_DATA_CHANGER: &str = "data_changer.bin";
pub const NSSA_PROGRAM_FOR_TEST_NOOP: &str = "noop.bin";
pub const NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY: &str = "pda_fund_spend_proxy.bin";
const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0";
const BEDROCK_SERVICE_PORT: u16 = 18080;

View File

@ -605,14 +605,14 @@ async fn shielded_transfers_to_two_identifiers_same_npk() -> Result<()> {
.await?;
// Both accounts must be discovered with the correct balances.
let account_id_1 = AccountId::from((&npk, identifier_1));
let account_id_1 = AccountId::for_regular_private_account(&npk, identifier_1);
let acc_1 = ctx
.wallet()
.get_account_private(account_id_1)
.context("account for identifier 1 not found after sync")?;
assert_eq!(acc_1.balance, 100);
let account_id_2 = AccountId::from((&npk, identifier_2));
let account_id_2 = AccountId::for_regular_private_account(&npk, identifier_2);
let acc_2 = ctx
.wallet()
.get_account_private(account_id_2)

View File

@ -0,0 +1,309 @@
#![expect(
clippy::tests_outside_test_module,
reason = "We don't care about these in tests"
)]
use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result};
use integration_tests::{
NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext,
verify_commitment_is_in_state,
};
use log::info;
use nssa::{
AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies,
program::Program,
};
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey, program::PdaSeed};
use tokio::test;
use wallet::{
PrivacyPreservingAccount, WalletCore,
cli::{Command, account::AccountSubcommand},
};
/// Funds a private PDA via the proxy program with a chained call to `auth_transfer`.
///
/// A direct call to `auth_transfer` cannot establish the PDA-to-npk binding because it uses
/// `Claim::Authorized` rather than `Claim::Pda`. Routing through the proxy provides the binding
/// via `pda_seeds` in the chained call to `auth_transfer`.
#[expect(
clippy::too_many_arguments,
reason = "test helper — grouping args would obscure intent"
)]
async fn fund_private_pda(
wallet: &WalletCore,
sender: AccountId,
pda_account_id: AccountId,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: u128,
seed: PdaSeed,
amount: u128,
proxy_program: &ProgramWithDependencies,
auth_transfer_id: ProgramId,
) -> Result<()> {
wallet
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::Public(sender),
PrivacyPreservingAccount::PrivatePdaForeign {
account_id: pda_account_id,
npk,
vpk,
identifier,
},
],
Program::serialize_instruction((seed, amount, auth_transfer_id, true))
.context("failed to serialize pda_fund_spend_proxy fund instruction")?,
proxy_program,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
/// Spends from an owned private PDA to a fresh private-foreign recipient.
///
/// Alice must own the PDA in the wallet (i.e. it must have been synced after a receive).
#[expect(
clippy::too_many_arguments,
reason = "test helper — grouping args would obscure intent"
)]
async fn spend_private_pda(
wallet: &WalletCore,
pda_account_id: AccountId,
recipient_npk: NullifierPublicKey,
recipient_vpk: ViewingPublicKey,
seed: PdaSeed,
amount: u128,
spend_program: &ProgramWithDependencies,
auth_transfer_id: nssa::ProgramId,
) -> Result<()> {
wallet
.send_privacy_preserving_tx(
vec![
PrivacyPreservingAccount::PrivatePdaOwned(pda_account_id),
PrivacyPreservingAccount::PrivateForeign {
npk: recipient_npk,
vpk: recipient_vpk,
identifier: 0,
},
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false))
.context("failed to serialize pda_fund_spend_proxy instruction")?,
spend_program,
)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
/// Two private transfers go to distinct members of the same PDA family (same seed and npk,
/// but identifier=0 and identifier=1). Alice then spends from both PDAs.
///
/// This exercises the full identifier-diversified private PDA lifecycle:
/// receive(id=0), receive(id=1) → sync → spend(id=0), spend(id=1) → sync → assert.
#[test]
async fn private_pda_family_members_receive_and_spend() -> Result<()> {
let mut ctx = TestContext::new().await?;
// ── Build alice's key chain ──────────────────────────────────────────────────────────────────
let alice_chain_index = ctx.wallet_mut().create_private_accounts_key(None);
let (alice_npk, alice_vpk) = {
let node = ctx
.wallet()
.storage()
.user_data
.private_key_tree
.key_map
.get(&alice_chain_index)
.context("key node was just inserted")?;
let kc = &node.value.0;
(kc.nullifier_public_key, kc.viewing_public_key.clone())
};
let proxy = {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../artifacts/test_program_methods")
.join(NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY);
Program::new(std::fs::read(&path).with_context(|| format!("reading {path:?}"))?)
.context("invalid pda_fund_spend_proxy binary")?
};
let auth_transfer = Program::authenticated_transfer_program();
let proxy_id = proxy.id();
let auth_transfer_id = auth_transfer.id();
let seed = PdaSeed::new([42; 32]);
let amount: u128 = 100;
let spend_program =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0);
let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 1);
// Use two different public senders to avoid nonce conflicts between the back-to-back txs.
let senders = ctx.existing_public_accounts();
let sender_0 = senders[0];
let sender_1 = senders[1];
// ── Receive ──────────────────────────────────────────────────────────────────────────────────
info!("Sending to alice_pda_0 (identifier=0)");
fund_private_pda(
ctx.wallet(),
sender_0,
alice_pda_0_id,
alice_npk,
alice_vpk.clone(),
0,
seed,
amount,
&spend_program,
auth_transfer_id,
)
.await?;
info!("Sending to alice_pda_1 (identifier=1)");
fund_private_pda(
ctx.wallet(),
sender_1,
alice_pda_1_id,
alice_npk,
alice_vpk.clone(),
1,
seed,
amount,
&spend_program,
auth_transfer_id,
)
.await?;
info!("Waiting for block");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
// Sync so alice's wallet discovers and stores both PDAs.
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
// Both PDAs must be discoverable and have the correct balance.
let pda_0_account = ctx
.wallet()
.get_account_private(alice_pda_0_id)
.context("alice_pda_0 not found after sync")?;
assert_eq!(pda_0_account.balance, amount);
let pda_1_account = ctx
.wallet()
.get_account_private(alice_pda_1_id)
.context("alice_pda_1 not found after sync")?;
assert_eq!(pda_1_account.balance, amount);
// Commitments for both PDAs must be in the sequencer's state.
let commitment_0 = ctx
.wallet()
.get_private_account_commitment(alice_pda_0_id)
.context("commitment for alice_pda_0 missing")?;
assert!(
verify_commitment_is_in_state(commitment_0.clone(), ctx.sequencer_client()).await,
"alice_pda_0 commitment not in state after receive"
);
let commitment_1 = ctx
.wallet()
.get_private_account_commitment(alice_pda_1_id)
.context("commitment for alice_pda_1 missing")?;
assert!(
verify_commitment_is_in_state(commitment_1.clone(), ctx.sequencer_client()).await,
"alice_pda_1 commitment not in state after receive"
);
assert_ne!(
commitment_0, commitment_1,
"distinct identifiers must yield distinct commitments"
);
// ── Spend ─────────────────────────────────────────────────────────────────────────────────────
// Fresh recipients — hardcoded npks not in any wallet.
let recipient_npk_0 = NullifierPublicKey([0xAA; 32]);
let recipient_vpk_0 = ViewingPublicKey::from_scalar(recipient_npk_0.0);
let recipient_npk_1 = NullifierPublicKey([0xBB; 32]);
let recipient_vpk_1 = ViewingPublicKey::from_scalar(recipient_npk_1.0);
let amount_spend_0: u128 = 13;
let amount_spend_1: u128 = 37;
info!("Alice spending from alice_pda_0");
spend_private_pda(
ctx.wallet(),
alice_pda_0_id,
recipient_npk_0,
recipient_vpk_0,
seed,
amount_spend_0,
&spend_program,
auth_transfer_id,
)
.await?;
info!("Alice spending from alice_pda_1");
spend_private_pda(
ctx.wallet(),
alice_pda_1_id,
recipient_npk_1,
recipient_vpk_1,
seed,
amount_spend_1,
&spend_program,
auth_transfer_id,
)
.await?;
info!("Waiting for block");
tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await;
wallet::cli::execute_subcommand(
ctx.wallet_mut(),
Command::Account(AccountSubcommand::SyncPrivate {}),
)
.await?;
// After spending, PDAs should have the remaining balance.
let pda_0_spent = ctx
.wallet()
.get_account_private(alice_pda_0_id)
.context("alice_pda_0 not found after spend sync")?;
assert_eq!(pda_0_spent.balance, amount - amount_spend_0);
let pda_1_spent = ctx
.wallet()
.get_account_private(alice_pda_1_id)
.context("alice_pda_1 not found after spend sync")?;
assert_eq!(pda_1_spent.balance, amount - amount_spend_1);
// Post-spend commitments must be in state.
let post_spend_commitment_0 = ctx
.wallet()
.get_private_account_commitment(alice_pda_0_id)
.context("post-spend commitment for alice_pda_0 missing")?;
assert!(
verify_commitment_is_in_state(post_spend_commitment_0, ctx.sequencer_client()).await,
"alice_pda_0 post-spend commitment not in state"
);
let post_spend_commitment_1 = ctx
.wallet()
.get_private_account_commitment(alice_pda_1_id)
.context("post-spend commitment for alice_pda_1 missing")?;
assert!(
verify_commitment_is_in_state(post_spend_commitment_1, ctx.sequencer_client()).await,
"alice_pda_1 post-spend commitment not in state"
);
info!("Private PDA family member receive-and-spend test passed");
Ok(())
}

View File

@ -55,6 +55,7 @@ async fn group_create_and_shared_account_registration() -> Result<()> {
pda: false,
seed: None,
program_id: None,
identifier: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
@ -170,6 +171,7 @@ async fn fund_shared_account_from_public() -> Result<()> {
pda: false,
seed: None,
program_id: None,
identifier: None,
}));
let result = wallet::cli::execute_subcommand(ctx.wallet_mut(), command).await?;
let SubcommandReturnValue::RegisterAccount {

View File

@ -220,7 +220,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
data: Data::default(),
},
true,
AccountId::from((&sender_npk, 0)),
AccountId::for_regular_private_account(&sender_npk, 0),
);
let recipient_nsk = [2; 32];
let recipient_vsk = [99; 32];
@ -229,7 +229,7 @@ fn build_privacy_transaction() -> PrivacyPreservingTransaction {
let recipient_pre = AccountWithMetadata::new(
Account::default(),
false,
AccountId::from((&recipient_npk, 0)),
AccountId::for_regular_private_account(&recipient_npk, 0),
);
let eph_holder_from = EphemeralKeyHolder::new(&sender_npk);

View File

@ -801,7 +801,7 @@ fn test_wallet_ffi_transfer_shielded() -> Result<()> {
let (to, to_keys) = unsafe {
let mut out_keys = FfiPrivateAccountKeys::default();
wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys);
let account_id = nssa::AccountId::from((&out_keys.npk(), 0_u128));
let account_id = nssa::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128);
let to: FfiBytes32 = (&account_id).into();
(to, out_keys)
};
@ -935,7 +935,7 @@ fn test_wallet_ffi_transfer_private() -> Result<()> {
let (to, to_keys) = unsafe {
let mut out_keys = FfiPrivateAccountKeys::default();
wallet_ffi_create_private_accounts_key(wallet_ffi_handle, &raw mut out_keys);
let account_id = nssa::AccountId::from((&out_keys.npk(), 0_u128));
let account_id = nssa::AccountId::for_regular_private_account(&out_keys.npk(), 0_u128);
let to: FfiBytes32 = (&account_id).into();
(to, out_keys)
};

View File

@ -339,7 +339,7 @@ mod tests {
let npk = holder
.derive_keys_for_pda(&TEST_PROGRAM_ID, &seed)
.generate_nullifier_public_key();
let account_id = AccountId::for_private_pda(&program_id, &seed, &npk);
let account_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX);
let expected_npk = NullifierPublicKey([
136, 176, 234, 71, 208, 8, 143, 142, 126, 155, 132, 18, 71, 27, 88, 56, 100, 90, 79,
@ -347,7 +347,8 @@ mod tests {
]);
// AccountId is derived from (program_id, seed, npk), so it changes when npk changes.
// We verify npk is pinned, and AccountId is deterministically derived from it.
let expected_account_id = AccountId::for_private_pda(&program_id, &seed, &expected_npk);
let expected_account_id =
AccountId::for_private_pda(&program_id, &seed, &expected_npk, u128::MAX);
assert_eq!(npk, expected_npk);
assert_eq!(account_id, expected_account_id);
@ -545,8 +546,8 @@ mod tests {
.generate_nullifier_public_key();
assert_eq!(alice_npk, bob_npk);
let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk);
let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk);
let alice_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &alice_npk, 0);
let bob_account_id = AccountId::for_private_pda(&program_id, &pda_seed, &bob_npk, 0);
assert_eq!(alice_account_id, bob_account_id);
}

View File

@ -1,5 +1,5 @@
use k256::{Scalar, elliptic_curve::PrimeField as _};
use nssa_core::{Identifier, NullifierPublicKey, encryption::ViewingPublicKey};
use nssa_core::{NullifierPublicKey, PrivateAccountKind, encryption::ViewingPublicKey};
use serde::{Deserialize, Serialize};
use crate::key_management::{
@ -10,7 +10,7 @@ use crate::key_management::{
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ChildKeysPrivate {
pub value: (KeyChain, Vec<(Identifier, nssa::Account)>),
pub value: (KeyChain, Vec<(PrivateAccountKind, nssa::Account)>),
pub ccc: [u8; 32],
/// Can be [`None`] if root.
pub cci: Option<u32>,
@ -115,9 +115,11 @@ impl KeyTreeNode for ChildKeysPrivate {
}
fn account_ids(&self) -> impl Iterator<Item = nssa::AccountId> {
self.value.1.iter().map(|(identifier, _)| {
nssa::AccountId::from((&self.value.0.nullifier_public_key, *identifier))
})
let npk = self.value.0.nullifier_public_key;
self.value
.1
.iter()
.map(move |(kind, _)| nssa::AccountId::for_private_account(&npk, kind))
}
}

View File

@ -274,7 +274,10 @@ impl KeyTree<ChildKeysPrivate> {
identifier: Identifier,
) -> Option<nssa::AccountId> {
let node = self.key_map.get(cci)?;
let account_id = nssa::AccountId::from((&node.value.0.nullifier_public_key, identifier));
let account_id = nssa::AccountId::for_regular_private_account(
&node.value.0.nullifier_public_key,
identifier,
);
if self.account_id_map.contains_key(&account_id) {
return None;
}
@ -319,6 +322,7 @@ mod tests {
use std::{collections::HashSet, str::FromStr as _};
use nssa::AccountId;
use nssa_core::PrivateAccountKind;
use super::*;
@ -532,7 +536,7 @@ mod tests {
.get_mut(&ChainIndex::from_str("/1").unwrap())
.unwrap();
acc.value.1.push((
0,
PrivateAccountKind::Regular(0),
nssa::Account {
balance: 2,
..nssa::Account::default()
@ -544,7 +548,7 @@ mod tests {
.get_mut(&ChainIndex::from_str("/2").unwrap())
.unwrap();
acc.value.1.push((
0,
PrivateAccountKind::Regular(0),
nssa::Account {
balance: 3,
..nssa::Account::default()
@ -556,7 +560,7 @@ mod tests {
.get_mut(&ChainIndex::from_str("/0/1").unwrap())
.unwrap();
acc.value.1.push((
0,
PrivateAccountKind::Regular(0),
nssa::Account {
balance: 5,
..nssa::Account::default()
@ -568,7 +572,7 @@ mod tests {
.get_mut(&ChainIndex::from_str("/1/0").unwrap())
.unwrap();
acc.value.1.push((
0,
PrivateAccountKind::Regular(0),
nssa::Account {
balance: 6,
..nssa::Account::default()

View File

@ -3,7 +3,7 @@ use std::collections::BTreeMap;
use anyhow::Result;
use k256::AffinePoint;
use nssa::{Account, AccountId};
use nssa_core::Identifier;
use nssa_core::{Identifier, PrivateAccountKind};
use serde::{Deserialize, Serialize};
use crate::key_management::{
@ -18,7 +18,7 @@ pub type PublicKey = AffinePoint;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserPrivateAccountData {
pub key_chain: KeyChain,
pub accounts: Vec<(Identifier, Account)>,
pub accounts: Vec<(PrivateAccountKind, Account)>,
}
/// Metadata for a shared account (GMS-derived), stored alongside the cached plaintext state.
@ -79,10 +79,11 @@ impl NSSAUserData {
) -> bool {
let mut check_res = true;
for (account_id, entry) in accounts_keys_map {
let any_match = entry.accounts.iter().any(|(identifier, _)| {
nssa::AccountId::from((&entry.key_chain.nullifier_public_key, *identifier))
== *account_id
});
let npk = &entry.key_chain.nullifier_public_key;
let any_match = entry
.accounts
.iter()
.any(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == *account_id);
if !any_match {
println!("No matching entry found for account_id {account_id}");
check_res = false;
@ -184,24 +185,27 @@ impl NSSAUserData {
) -> Option<(KeyChain, nssa_core::account::Account, Identifier)> {
// Check default accounts
if let Some(entry) = self.default_user_private_accounts.get(&account_id) {
for (identifier, account) in &entry.accounts {
let expected_id =
nssa::AccountId::from((&entry.key_chain.nullifier_public_key, *identifier));
if expected_id == account_id {
return Some((entry.key_chain.clone(), account.clone(), *identifier));
}
let npk = &entry.key_chain.nullifier_public_key;
if let Some((kind, account)) = entry
.accounts
.iter()
.find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id)
{
return Some((entry.key_chain.clone(), account.clone(), kind.identifier()));
}
return None;
}
// Check tree
if let Some(node) = self.private_key_tree.get_node(account_id) {
let key_chain = &node.value.0;
for (identifier, account) in &node.value.1 {
let expected_id =
nssa::AccountId::from((&key_chain.nullifier_public_key, *identifier));
if expected_id == account_id {
return Some((key_chain.clone(), account.clone(), *identifier));
}
let npk = &key_chain.nullifier_public_key;
if let Some((kind, account)) = node
.value
.1
.iter()
.find(|(kind, _)| nssa::AccountId::for_private_account(npk, kind) == account_id)
{
return Some((key_chain.clone(), account.clone(), kind.identifier()));
}
}
None

View File

@ -30,8 +30,8 @@ pub enum InputAccountIdentity {
Public,
/// Init of an authorized standalone private account: no membership proof. The `pre_state`
/// must be `Account::default()`. The `account_id` is derived as
/// `AccountId::from((&NullifierPublicKey::from(nsk), identifier))` and matched against
/// `pre_state.account_id`.
/// `AccountId::for_regular_private_account(&NullifierPublicKey::from(nsk), identifier)` and
/// matched against `pre_state.account_id`.
PrivateAuthorizedInit {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
@ -53,19 +53,22 @@ pub enum InputAccountIdentity {
identifier: Identifier,
},
/// Init of a private PDA, unauthorized. The npk-to-account_id binding is proven upstream
/// via `Claim::Pda(seed)` or a caller's `pda_seeds` match. Identifier is fixed by
/// convention to `PRIVATE_PDA_FIXED_IDENTIFIER` and not carried per-input.
/// via `Claim::Pda(seed)` or a caller's `pda_seeds` match. The identifier diversifies the
/// PDA within the `(program_id, seed, npk)` family: `AccountId::for_private_pda` uses it
/// as the 4th input.
PrivatePdaInit {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
},
/// Update of an existing private PDA, authorized, with membership proof. `npk` is derived
/// from `nsk`. Authorization is established upstream by a caller `pda_seeds` match or a
/// previously-seen authorization in a chained call. Identifier is fixed.
/// previously-seen authorization in a chained call.
PrivatePdaUpdate {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
},
}
@ -83,13 +86,17 @@ impl InputAccountIdentity {
)
}
/// For private PDA variants, return the nullifier public key. `Init` carries it directly;
/// `Update` derives it from `nsk`. For non-PDA variants returns `None`.
/// For private PDA variants, return the `(npk, identifier)` pair. `Init` carries both
/// directly; `Update` derives `npk` from `nsk`. For non-PDA variants returns `None`.
#[must_use]
pub fn npk_if_private_pda(&self) -> Option<NullifierPublicKey> {
pub fn npk_if_private_pda(&self) -> Option<(NullifierPublicKey, Identifier)> {
match self {
Self::PrivatePdaInit { npk, .. } => Some(*npk),
Self::PrivatePdaUpdate { nsk, .. } => Some(NullifierPublicKey::from(nsk)),
Self::PrivatePdaInit {
npk, identifier, ..
} => Some((*npk, *identifier)),
Self::PrivatePdaUpdate {
nsk, identifier, ..
} => Some((NullifierPublicKey::from(nsk), *identifier)),
Self::Public
| Self::PrivateAuthorizedInit { .. }
| Self::PrivateAuthorizedUpdate { .. }

View File

@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
#[cfg(feature = "host")]
pub use shared_key_derivation::{EphemeralPublicKey, EphemeralSecretKey, ViewingPublicKey};
use crate::{Commitment, Identifier, account::Account};
use crate::{Commitment, account::Account, program::PrivateAccountKind};
#[cfg(feature = "host")]
pub mod shared_key_derivation;
@ -40,13 +40,14 @@ impl EncryptionScheme {
#[must_use]
pub fn encrypt(
account: &Account,
identifier: Identifier,
kind: &PrivateAccountKind,
shared_secret: &SharedSecretKey,
commitment: &Commitment,
output_index: u32,
) -> Ciphertext {
// Plaintext: identifier (16 bytes, little-endian) || account bytes
let mut buffer = identifier.to_le_bytes().to_vec();
// Plaintext: PrivateAccountKind::HEADER_LEN bytes header || account bytes.
// Both variants produce the same header length — see PrivateAccountKind::to_header_bytes.
let mut buffer = kind.to_header_bytes().to_vec();
buffer.extend_from_slice(&account.to_bytes());
Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index);
Ciphertext(buffer)
@ -89,17 +90,19 @@ impl EncryptionScheme {
shared_secret: &SharedSecretKey,
commitment: &Commitment,
output_index: u32,
) -> Option<(Identifier, Account)> {
) -> Option<(PrivateAccountKind, Account)> {
use std::io::Cursor;
let mut buffer = ciphertext.0.clone();
Self::symmetric_transform(&mut buffer, shared_secret, commitment, output_index);
if buffer.len() < 16 {
if buffer.len() < PrivateAccountKind::HEADER_LEN {
return None;
}
let identifier = Identifier::from_le_bytes(buffer[..16].try_into().unwrap());
let header: &[u8; PrivateAccountKind::HEADER_LEN] =
buffer[..PrivateAccountKind::HEADER_LEN].try_into().unwrap();
let kind = PrivateAccountKind::from_header_bytes(header)?;
let mut cursor = Cursor::new(&buffer[16..]);
let mut cursor = Cursor::new(&buffer[PrivateAccountKind::HEADER_LEN..]);
Account::from_cursor(&mut cursor)
.inspect_err(|err| {
println!(
@ -112,6 +115,43 @@ impl EncryptionScheme {
);
})
.ok()
.map(|account| (identifier, account))
.map(|account| (kind, account))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
account::{Account, AccountId},
program::PdaSeed,
};
#[test]
fn encrypt_same_length_for_account_and_pda() {
let account = Account::default();
let secret = SharedSecretKey([0_u8; 32]);
let commitment = crate::Commitment::new(&AccountId::new([0_u8; 32]), &Account::default());
let account_ct = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Regular(42),
&secret,
&commitment,
0,
);
let pda_ct = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Pda {
program_id: [1_u32; 8],
seed: PdaSeed::new([2_u8; 32]),
identifier: 42,
},
&secret,
&commitment,
0,
);
assert_eq!(account_ct.0.len(), pda_ct.0.len());
}
}

View File

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

View File

@ -12,10 +12,11 @@ pub type Identifier = u128;
#[cfg_attr(any(feature = "host", test), derive(Hash))]
pub struct NullifierPublicKey(pub [u8; 32]);
impl From<(&NullifierPublicKey, Identifier)> for AccountId {
fn from(value: (&NullifierPublicKey, Identifier)) -> Self {
let (npk, identifier) = value;
impl AccountId {
/// Derives an [`AccountId`] for a regular (non-PDA) private account from the nullifier public
/// key and identifier.
#[must_use]
pub fn for_regular_private_account(npk: &NullifierPublicKey, identifier: Identifier) -> Self {
// 32 bytes prefix || 32 bytes npk || 16 bytes identifier
let mut bytes = [0; 80];
bytes[0..32].copy_from_slice(PRIVATE_ACCOUNT_ID_PREFIX);
@ -31,6 +32,12 @@ impl From<(&NullifierPublicKey, Identifier)> for AccountId {
}
}
impl From<(&NullifierPublicKey, Identifier)> for AccountId {
fn from((npk, identifier): (&NullifierPublicKey, Identifier)) -> Self {
Self::for_regular_private_account(npk, identifier)
}
}
impl AsRef<[u8]> for NullifierPublicKey {
fn as_ref(&self) -> &[u8] {
self.0.as_slice()
@ -155,7 +162,7 @@ mod tests {
253, 105, 164, 89, 84, 40, 191, 182, 119, 64, 255, 67, 142,
]);
let account_id = AccountId::from((&npk, 0));
let account_id = AccountId::for_regular_private_account(&npk, 0);
assert_eq!(account_id, expected_account_id);
}
@ -172,7 +179,7 @@ mod tests {
56, 247, 99, 121, 165, 182, 234, 255, 19, 127, 191, 72,
]);
let account_id = AccountId::from((&npk, 1));
let account_id = AccountId::for_regular_private_account(&npk, 1);
assert_eq!(account_id, expected_account_id);
}
@ -190,7 +197,7 @@ mod tests {
19, 245, 25, 214, 162, 209, 135, 252, 82, 27, 2, 174, 196,
]);
let account_id = AccountId::from((&npk, identifier));
let account_id = AccountId::for_regular_private_account(&npk, identifier);
assert_eq!(account_id, expected_account_id);
}

View File

@ -1,12 +1,11 @@
use std::collections::HashSet;
#[cfg(any(feature = "host", test))]
use borsh::{BorshDeserialize, BorshSerialize};
use risc0_zkvm::{DeserializeOwned, guest::env, serde::Deserializer};
use serde::{Deserialize, Serialize};
use crate::{
BlockId, NullifierPublicKey, Timestamp,
BlockId, Identifier, NullifierPublicKey, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
};
@ -27,7 +26,18 @@ pub struct ProgramInput<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, Hash, Serialize, Deserialize)]
#[derive(
Debug,
Clone,
Copy,
Eq,
PartialEq,
Hash,
Serialize,
Deserialize,
BorshSerialize,
BorshDeserialize,
)]
pub struct PdaSeed([u8; 32]);
impl PdaSeed {
@ -35,6 +45,11 @@ impl PdaSeed {
pub const fn new(value: [u8; 32]) -> Self {
Self(value)
}
#[must_use]
pub const fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
impl AsRef<[u8]> for PdaSeed {
@ -43,6 +58,55 @@ impl AsRef<[u8]> for PdaSeed {
}
}
/// Discriminates the type of private account a ciphertext belongs to, carrying the data needed
/// to reconstruct the account's [`AccountId`] on the receiver side.
///
/// [`AccountId`]: crate::account::AccountId
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)]
pub enum PrivateAccountKind {
Regular(Identifier),
Pda {
program_id: ProgramId,
seed: PdaSeed,
identifier: Identifier,
},
}
impl PrivateAccountKind {
/// Borsh layout (all integers little-endian, variant index is u8):
///
/// ```text
/// Regular(ident): 0x00 || ident (16 LE) || [0u8; 64]
/// Pda { program_id, seed, ident }: 0x01 || program_id (32) || seed (32) || ident (16 LE)
/// ```
///
/// Both variants are zero-padded to the same length so all ciphertexts are the same size,
/// preventing observers from distinguishing `Regular` from `Pda` via ciphertext length.
/// `HEADER_LEN` equals the borsh size of the largest variant (`Pda`): 1 + 32 + 32 + 16 = 81.
pub const HEADER_LEN: usize = 81;
#[must_use]
pub const fn identifier(&self) -> Identifier {
match self {
Self::Regular(identifier) | Self::Pda { identifier, .. } => *identifier,
}
}
#[must_use]
pub fn to_header_bytes(&self) -> [u8; Self::HEADER_LEN] {
let mut bytes = [0_u8; Self::HEADER_LEN];
let serialized = borsh::to_vec(self).expect("borsh serialization is infallible");
bytes[..serialized.len()].copy_from_slice(&serialized);
bytes
}
#[cfg(feature = "host")]
#[must_use]
pub fn from_header_bytes(bytes: &[u8; Self::HEADER_LEN]) -> Option<Self> {
BorshDeserialize::deserialize(&mut bytes.as_ref()).ok()
}
}
impl AccountId {
/// Derives an [`AccountId`] for a public PDA from the program ID and seed.
#[must_use]
@ -65,27 +129,31 @@ impl AccountId {
)
}
/// Derives an [`AccountId`] for a private PDA from the program ID, seed, and nullifier
/// public key.
/// Derives an [`AccountId`] for a private PDA from the program ID, seed, nullifier public
/// key, and identifier.
///
/// Unlike public PDAs ([`AccountId::for_public_pda`]), this includes the `npk` in the
/// derivation, making the address unique per group of controllers sharing viewing keys.
/// The `identifier` further diversifies the address, so a single `(program_id, seed, npk)`
/// tuple controls a family of 2^128 addresses.
#[must_use]
pub fn for_private_pda(
program_id: &ProgramId,
seed: &PdaSeed,
npk: &NullifierPublicKey,
identifier: Identifier,
) -> Self {
use risc0_zkvm::sha::{Impl, Sha256 as _};
const PRIVATE_PDA_PREFIX: &[u8; 32] = b"/LEE/v0.3/AccountId/PrivatePDA/\x00";
let mut bytes = [0_u8; 128];
let mut bytes = [0_u8; 144];
bytes[0..32].copy_from_slice(PRIVATE_PDA_PREFIX);
let program_id_bytes: &[u8] =
bytemuck::try_cast_slice(program_id).expect("ProgramId should be castable to &[u8]");
bytes[32..64].copy_from_slice(program_id_bytes);
bytes[64..96].copy_from_slice(&seed.0);
bytes[96..128].copy_from_slice(&npk.to_byte_array());
bytes[128..144].copy_from_slice(&identifier.to_le_bytes());
Self::new(
Impl::hash_bytes(&bytes)
.as_bytes()
@ -93,6 +161,21 @@ impl AccountId {
.expect("Hash output must be exactly 32 bytes long"),
)
}
/// Derives the [`AccountId`] for a private account from the nullifier public key and kind.
#[must_use]
pub fn for_private_account(npk: &NullifierPublicKey, kind: &PrivateAccountKind) -> Self {
match kind {
PrivateAccountKind::Regular(identifier) => {
Self::for_regular_private_account(npk, *identifier)
}
PrivateAccountKind::Pda {
program_id,
seed,
identifier,
} => Self::for_private_pda(program_id, seed, npk, *identifier),
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
@ -851,19 +934,20 @@ mod tests {
// ---- AccountId::for_private_pda tests ----
/// Pins `AccountId::for_private_pda` against a hardcoded expected output for a specific
/// `(program_id, seed, npk)` triple. Any change to `PRIVATE_PDA_PREFIX`, byte ordering,
/// or the underlying hash breaks this test.
/// `(program_id, seed, npk, identifier)` tuple. Any change to `PRIVATE_PDA_PREFIX`, byte
/// ordering, or the underlying hash breaks this test.
#[test]
fn for_private_pda_matches_pinned_value() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let identifier: Identifier = u128::MAX;
let expected = AccountId::new([
132, 198, 103, 173, 244, 211, 188, 217, 249, 99, 126, 205, 152, 120, 192, 47, 13, 53,
133, 3, 17, 69, 92, 243, 140, 94, 182, 211, 218, 75, 215, 45,
59, 239, 182, 97, 14, 220, 96, 115, 238, 133, 143, 33, 234, 82, 237, 255, 148, 110, 54,
124, 98, 159, 245, 101, 146, 182, 150, 54, 37, 62, 25, 17,
]);
assert_eq!(
AccountId::for_private_pda(&program_id, &seed, &npk),
AccountId::for_private_pda(&program_id, &seed, &npk, identifier),
expected
);
}
@ -876,8 +960,8 @@ mod tests {
let npk_a = NullifierPublicKey([3; 32]);
let npk_b = NullifierPublicKey([4; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk_a),
AccountId::for_private_pda(&program_id, &seed, &npk_b),
AccountId::for_private_pda(&program_id, &seed, &npk_a, u128::MAX),
AccountId::for_private_pda(&program_id, &seed, &npk_b, u128::MAX),
);
}
@ -889,8 +973,8 @@ mod tests {
let seed_b = PdaSeed::new([5; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed_a, &npk),
AccountId::for_private_pda(&program_id, &seed_b, &npk),
AccountId::for_private_pda(&program_id, &seed_a, &npk, u128::MAX),
AccountId::for_private_pda(&program_id, &seed_b, &npk, u128::MAX),
);
}
@ -902,8 +986,25 @@ mod tests {
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id_a, &seed, &npk),
AccountId::for_private_pda(&program_id_b, &seed, &npk),
AccountId::for_private_pda(&program_id_a, &seed, &npk, u128::MAX),
AccountId::for_private_pda(&program_id_b, &seed, &npk, u128::MAX),
);
}
/// Different identifiers produce different addresses for the same `(program_id, seed, npk)`,
/// confirming that each `(program_id, seed, npk)` tuple controls a family of 2^128 addresses.
#[test]
fn for_private_pda_differs_for_different_identifier() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, 1),
);
assert_ne!(
AccountId::for_private_pda(&program_id, &seed, &npk, 0),
AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX),
);
}
@ -914,14 +1015,62 @@ mod tests {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let private_id = AccountId::for_private_pda(&program_id, &seed, &npk);
let private_id = AccountId::for_private_pda(&program_id, &seed, &npk, u128::MAX);
let public_id = AccountId::for_public_pda(&program_id, &seed);
assert_ne!(private_id, public_id);
}
// ---- compute_public_authorized_pdas tests ----
#[cfg(feature = "host")]
#[test]
fn private_account_kind_header_round_trips() {
let regular = PrivateAccountKind::Regular(42);
let pda = PrivateAccountKind::Pda {
program_id: [1_u32; 8],
seed: PdaSeed::new([2_u8; 32]),
identifier: u128::MAX,
};
assert_eq!(
PrivateAccountKind::from_header_bytes(&regular.to_header_bytes()),
Some(regular)
);
assert_eq!(
PrivateAccountKind::from_header_bytes(&pda.to_header_bytes()),
Some(pda)
);
}
#[cfg(feature = "host")]
#[test]
fn private_account_kind_unknown_discriminant_returns_none() {
let mut bytes = [0_u8; PrivateAccountKind::HEADER_LEN];
bytes[0] = 0xFF;
assert_eq!(PrivateAccountKind::from_header_bytes(&bytes), None);
}
#[test]
fn for_private_account_dispatches_correctly() {
let program_id: ProgramId = [1; 8];
let seed = PdaSeed::new([2; 32]);
let npk = NullifierPublicKey([3; 32]);
let identifier: Identifier = 77;
assert_eq!(
AccountId::for_private_account(&npk, &PrivateAccountKind::Regular(identifier)),
AccountId::for_regular_private_account(&npk, identifier),
);
assert_eq!(
AccountId::for_private_account(
&npk,
&PrivateAccountKind::Pda {
program_id,
seed,
identifier
}
),
AccountId::for_private_pda(&program_id, &seed, &npk, identifier),
);
}
/// `compute_public_authorized_pdas` returns the public PDA addresses for the caller's seeds.
#[test]
fn compute_public_authorized_pdas_with_seeds() {
let caller: ProgramId = [1; 8];

View File

@ -176,9 +176,10 @@ mod tests {
#![expect(clippy::shadow_unrelated, reason = "We don't care about it in tests")]
use nssa_core::{
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier, SharedSecretKey,
Commitment, DUMMY_COMMITMENT_HASH, EncryptionScheme, Nullifier,
PrivacyPreservingCircuitOutput, SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
program::PdaSeed,
program::{PdaSeed, PrivateAccountKind},
};
use super::*;
@ -192,6 +193,21 @@ mod tests {
},
};
fn decrypt_kind(
output: &PrivacyPreservingCircuitOutput,
ssk: &SharedSecretKey,
idx: usize,
) -> PrivateAccountKind {
let (kind, _) = EncryptionScheme::decrypt(
&output.ciphertexts[idx],
ssk,
&output.new_commitments[idx],
u32::try_from(idx).expect("idx fits in u32"),
)
.unwrap();
kind
}
#[test]
fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() {
let recipient_keys = test_private_account_keys_1();
@ -206,7 +222,7 @@ mod tests {
AccountId::new([0; 32]),
);
let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0));
let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let balance_to_move: u128 = 37;
@ -280,12 +296,12 @@ mod tests {
data: Data::default(),
},
true,
AccountId::from((&sender_keys.npk(), 0)),
AccountId::for_regular_private_account(&sender_keys.npk(), 0),
);
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let commitment_sender = Commitment::new(&sender_account_id, &sender_pre.account);
let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0));
let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_account_id);
let balance_to_move: u128 = 37;
@ -381,7 +397,7 @@ mod tests {
let pre = AccountWithMetadata::new(
Account::default(),
false,
AccountId::from((&account_keys.npk(), 0)),
AccountId::for_regular_private_account(&account_keys.npk(), 0),
);
let validity_window_chain_caller = Program::validity_window_chain_caller();
@ -418,6 +434,42 @@ mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// A private PDA claimed with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_claim_with_custom_identifier_encrypts_correct_kind() {
let program = Program::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let (output, _proof) = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier,
}],
&program.clone().into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &shared_secret, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
/// PDA init: initializes a new PDA under `authenticated_transfer`'s ownership.
/// The `auth_transfer_proxy` program chains to `authenticated_transfer` with `pda_seeds`
/// to establish authorization and the private PDA binding.
@ -430,8 +482,8 @@ mod tests {
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
// PDA (new, private PDA) — AccountId derived from auth_transfer_proxy's program ID
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
// PDA (new, mask 3)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
let auth_id = auth_transfer.id();
@ -447,6 +499,7 @@ mod tests {
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
identifier: 0,
}],
&program_with_deps,
);
@ -468,7 +521,7 @@ mod tests {
let shared_secret_pda = SharedSecretKey::new(&[55; 32], &keys.vpk());
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
// Recipient (public)
@ -497,6 +550,7 @@ mod tests {
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret_pda,
identifier: 0,
},
InputAccountIdentity::Public,
],
@ -557,4 +611,246 @@ mod tests {
// Sender is public (no commitment), recipient is private (1 commitment)
assert_eq!(output.new_commitments.len(), 1);
}
/// `PrivateAuthorizedInit` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_authorized_init_encrypts_regular_kind_with_identifier() {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let pre = AccountWithMetadata::new(Account::default(), true, account_id);
let (output, _) = execute_and_prove(
vec![pre],
Program::serialize_instruction(0_u128).unwrap(),
vec![InputAccountIdentity::PrivateAuthorizedInit {
ssk,
nsk: keys.nsk,
identifier,
}],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateUnauthorized` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_unauthorized_init_encrypts_regular_kind_with_identifier() {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let sender = AccountWithMetadata::new(
Account {
program_owner: program.id(),
balance: 1,
..Account::default()
},
true,
AccountId::new([0; 32]),
);
let recipient_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let recipient = AccountWithMetadata::new(Account::default(), false, recipient_id);
let (output, _) = execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(1_u128).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivateUnauthorized {
npk: keys.npk(),
ssk,
identifier,
},
],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivateAuthorizedUpdate` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Regular` carrying the correct identifier.
#[test]
fn private_authorized_update_encrypts_regular_kind_with_identifier() {
let program = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let identifier: u128 = 99;
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_regular_private_account(&keys.npk(), identifier);
let account = Account {
program_owner: program.id(),
balance: 1,
..Account::default()
};
let commitment = Commitment::new(&account_id, &account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&commitment));
let sender = AccountWithMetadata::new(account, true, account_id);
let recipient = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let (output, _) = execute_and_prove(
vec![sender, recipient],
Program::serialize_instruction(1_u128).unwrap(),
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&commitment).unwrap(),
identifier,
},
InputAccountIdentity::Public,
],
&program.into(),
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Regular(identifier)
);
}
/// `PrivatePdaUpdate` with a non-default identifier produces a ciphertext that decrypts
/// to `PrivateAccountKind::Pda` carrying the correct `(program_id, seed, identifier)`.
#[test]
fn private_pda_update_encrypts_pda_kind_with_identifier() {
let program = Program::pda_fund_spend_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, identifier);
let pda_account = Account {
program_owner: auth_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps = ProgramWithDependencies::new(
program.clone(),
[(auth_transfer_id, auth_transfer)].into(),
);
let (output, _) = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, auth_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
seed,
identifier
},
);
}
#[test]
fn private_pda_init_identifier_mismatch_fails() {
let program = Program::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: 99,
}],
&program.into(),
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
#[test]
fn private_pda_update_identifier_mismatch_fails() {
let program = Program::pda_fund_spend_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let ssk = SharedSecretKey::new(&[55; 32], &keys.vpk());
let auth_transfer_id = auth_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 5);
let pda_account = Account {
program_owner: auth_transfer_id,
balance: 1,
..Account::default()
};
let pda_commitment = Commitment::new(&pda_id, &pda_account);
let mut commitment_set = CommitmentSet::with_capacity(1);
commitment_set.extend(std::slice::from_ref(&pda_commitment));
let pda_pre = AccountWithMetadata::new(pda_account, true, pda_id);
let recipient_pre =
AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps =
ProgramWithDependencies::new(program, [(auth_transfer_id, auth_transfer)].into());
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, auth_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk,
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier: 99,
},
InputAccountIdentity::Public,
],
&program_with_deps,
);
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
}

View File

@ -140,7 +140,8 @@ impl Message {
#[cfg(test)]
pub mod tests {
use nssa_core::{
Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, SharedSecretKey,
Commitment, EncryptionScheme, Nullifier, NullifierPublicKey, PrivateAccountKind,
SharedSecretKey,
account::{Account, AccountId, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{BlockValidityWindow, TimestampValidityWindow},
@ -168,10 +169,10 @@ pub mod tests {
let encrypted_private_post_states = Vec::new();
let account_id2 = nssa_core::account::AccountId::from((&npk2, 0));
let account_id2 = nssa_core::account::AccountId::for_regular_private_account(&npk2, 0);
let new_commitments = vec![Commitment::new(&account_id2, &account2)];
let account_id1 = nssa_core::account::AccountId::from((&npk1, 0));
let account_id1 = nssa_core::account::AccountId::for_regular_private_account(&npk1, 0);
let old_commitment = Commitment::new(&account_id1, &account1);
let new_nullifiers = vec![(
Nullifier::for_account_update(&old_commitment, &nsk1),
@ -247,12 +248,18 @@ pub mod tests {
let npk = NullifierPublicKey::from(&[1; 32]);
let vpk = ViewingPublicKey::from_scalar([2; 32]);
let account = Account::default();
let account_id = nssa_core::account::AccountId::from((&npk, 0));
let account_id = nssa_core::account::AccountId::for_regular_private_account(&npk, 0);
let commitment = Commitment::new(&account_id, &account);
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(&esk, &vpk);
let epk = EphemeralPublicKey::from_scalar(esk);
let ciphertext = EncryptionScheme::encrypt(&account, 0, &shared_secret, &commitment, 2);
let ciphertext = EncryptionScheme::encrypt(
&account,
&PrivateAccountKind::Regular(0),
&shared_secret,
&commitment,
2,
);
let encrypted_account_data =
EncryptedAccountData::new(ciphertext.clone(), &npk, &vpk, epk.clone());

View File

@ -332,6 +332,16 @@ mod tests {
}
}
#[must_use]
pub fn pda_fund_spend_proxy() -> Self {
use test_program_methods::{PDA_FUND_SPEND_PROXY_ELF, PDA_FUND_SPEND_PROXY_ID};
Self {
id: PDA_FUND_SPEND_PROXY_ID,
elf: PDA_FUND_SPEND_PROXY_ELF.to_vec(),
}
}
#[must_use]
pub fn changer_claimer() -> Self {
use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID};

View File

@ -459,7 +459,7 @@ pub mod tests {
#[must_use]
pub fn with_private_account(mut self, keys: &TestPrivateKeys, account: &Account) -> Self {
let account_id = AccountId::from((&keys.npk(), 0));
let account_id = AccountId::for_regular_private_account(&keys.npk(), 0);
let commitment = Commitment::new(&account_id, account);
self.private_state.0.extend(&[commitment]);
self
@ -618,8 +618,8 @@ pub mod tests {
..Account::default()
};
let account_id1 = AccountId::from((&keys1.npk(), 0));
let account_id2 = AccountId::from((&keys2.npk(), 0));
let account_id1 = AccountId::for_regular_private_account(&keys1.npk(), 0);
let account_id2 = AccountId::for_regular_private_account(&keys2.npk(), 0);
let init_commitment1 = Commitment::new(&account_id1, &account);
let init_commitment2 = Commitment::new(&account_id2, &account);
@ -1256,6 +1256,12 @@ pub mod tests {
}
}
fn test_public_account_keys_2() -> TestPublicKeys {
TestPublicKeys {
signing_key: PrivateKey::try_new([38; 32]).unwrap(),
}
}
pub fn test_private_account_keys_1() -> TestPrivateKeys {
TestPrivateKeys {
nsk: [13; 32],
@ -1326,7 +1332,7 @@ pub mod tests {
state: &V03State,
) -> PrivacyPreservingTransaction {
let program = Program::authenticated_transfer_program();
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let sender_commitment = Commitment::new(&sender_account_id, sender_private_account);
let sender_pre = AccountWithMetadata::new(
sender_private_account.clone(),
@ -1390,7 +1396,7 @@ pub mod tests {
state: &V03State,
) -> PrivacyPreservingTransaction {
let program = Program::authenticated_transfer_program();
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let sender_commitment = Commitment::new(&sender_account_id, sender_private_account);
let sender_pre = AccountWithMetadata::new(
sender_private_account.clone(),
@ -1505,8 +1511,8 @@ pub mod tests {
&state,
);
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0));
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0);
let expected_new_commitment_1 = Commitment::new(
&sender_account_id,
&Account {
@ -1584,7 +1590,7 @@ pub mod tests {
&state,
);
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let expected_new_commitment = Commitment::new(
&sender_account_id,
&Account {
@ -2185,6 +2191,7 @@ pub mod tests {
InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
},
],
&program.into(),
@ -2206,7 +2213,7 @@ pub mod tests {
let seed = PdaSeed::new([42; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
@ -2215,6 +2222,7 @@ pub mod tests {
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program.into(),
);
@ -2244,7 +2252,7 @@ pub mod tests {
// `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state.
// `AccountId::for_private_pda(program, seed, npk_b) != account_id`, so the claim check in
// the circuit must reject.
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk_a);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk_a, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let result = execute_and_prove(
@ -2253,6 +2261,7 @@ pub mod tests {
vec![InputAccountIdentity::PrivatePdaInit {
npk: npk_b,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program.into(),
);
@ -2274,7 +2283,7 @@ pub mod tests {
let seed = PdaSeed::new([77; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk);
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let callee_id = callee.id();
@ -2287,6 +2296,7 @@ pub mod tests {
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program_with_deps,
);
@ -2311,7 +2321,7 @@ pub mod tests {
let wrong_delegated_seed = PdaSeed::new([88; 32]);
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk);
let account_id = AccountId::for_private_pda(&delegator.id(), &claim_seed, &npk, u128::MAX);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let callee_id = callee.id();
@ -2324,6 +2334,7 @@ pub mod tests {
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program_with_deps,
);
@ -2348,8 +2359,8 @@ pub mod tests {
let shared_a = SharedSecretKey::new(&[66; 32], &keys_a.vpk());
let shared_b = SharedSecretKey::new(&[77; 32], &keys_b.vpk());
let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk());
let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk());
let account_a = AccountId::for_private_pda(&program.id(), &seed, &keys_a.npk(), u128::MAX);
let account_b = AccountId::for_private_pda(&program.id(), &seed, &keys_b.npk(), u128::MAX);
let pre_a = AccountWithMetadata::new(Account::default(), false, account_a);
let pre_b = AccountWithMetadata::new(Account::default(), false, account_b);
@ -2361,10 +2372,12 @@ pub mod tests {
InputAccountIdentity::PrivatePdaInit {
npk: keys_a.npk(),
ssk: shared_a,
identifier: u128::MAX,
},
InputAccountIdentity::PrivatePdaInit {
npk: keys_b.npk(),
ssk: shared_b,
identifier: u128::MAX,
},
],
&program.into(),
@ -2394,7 +2407,7 @@ pub mod tests {
// Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized =
// true, account_id derived via the private formula.
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, u128::MAX);
let owned_pre_state = AccountWithMetadata::new(
Account {
program_owner: program.id(),
@ -2410,6 +2423,7 @@ pub mod tests {
vec![InputAccountIdentity::PrivatePdaInit {
npk,
ssk: shared_secret,
identifier: u128::MAX,
}],
&program.into(),
);
@ -2812,7 +2826,7 @@ pub mod tests {
balance: 100,
..Account::default()
};
let sender_account_id = AccountId::from((&sender_keys.npk(), 0));
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 0);
let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account);
let sender_init_nullifier = Nullifier::for_account_initialization(&sender_account_id);
let mut state = V03State::new_with_genesis_accounts(
@ -2905,8 +2919,8 @@ pub mod tests {
(&to_keys.npk(), 0),
);
let from_account_id = AccountId::from((&from_keys.npk(), 0));
let to_account_id = AccountId::from((&to_keys.npk(), 0));
let from_account_id = AccountId::for_regular_private_account(&from_keys.npk(), 0);
let to_account_id = AccountId::for_regular_private_account(&to_keys.npk(), 0);
let from_commitment = Commitment::new(&from_account_id, &from_account.account);
let to_commitment = Commitment::new(&to_account_id, &to_account.account);
let from_init_nullifier = Nullifier::for_account_initialization(&from_account_id);
@ -3266,7 +3280,7 @@ pub mod tests {
let result = state.transition_from_privacy_preserving_transaction(&tx, 1, 0);
assert!(result.is_ok());
let account_id = AccountId::from((&private_keys.npk(), 0));
let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0);
let nullifier = Nullifier::for_account_initialization(&account_id);
assert!(state.private_state.1.contains(&nullifier));
}
@ -3315,7 +3329,7 @@ pub mod tests {
.transition_from_privacy_preserving_transaction(&tx, 1, 0)
.unwrap();
let account_id = AccountId::from((&private_keys.npk(), 0));
let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0);
let nullifier = Nullifier::for_account_initialization(&account_id);
assert!(state.private_state.1.contains(&nullifier));
}
@ -3372,7 +3386,7 @@ pub mod tests {
);
// Verify the account is now initialized (nullifier exists)
let account_id = AccountId::from((&private_keys.npk(), 0));
let account_id = AccountId::for_regular_private_account(&private_keys.npk(), 0);
let nullifier = Nullifier::for_account_initialization(&account_id);
assert!(state.private_state.1.contains(&nullifier));
@ -3527,7 +3541,7 @@ pub mod tests {
let recipient_account =
AccountWithMetadata::new(Account::default(), true, (&recipient_keys.npk(), 0));
let recipient_account_id = AccountId::from((&recipient_keys.npk(), 0));
let recipient_account_id = AccountId::for_regular_private_account(&recipient_keys.npk(), 0);
let recipient_commitment =
Commitment::new(&recipient_account_id, &recipient_account.account);
let recipient_init_nullifier = Nullifier::for_account_initialization(&recipient_account_id);
@ -4286,4 +4300,225 @@ pub mod tests {
"program with spoofed caller_program_id in output should be rejected"
);
}
#[test]
fn two_private_pda_family_members_receive_and_spend() {
let funder_keys = test_public_account_keys_1();
let alice_keys = test_private_account_keys_1();
let alice_npk = alice_keys.npk();
let proxy = Program::pda_fund_spend_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let proxy_id = proxy.id();
let auth_transfer_id = auth_transfer.id();
let seed = PdaSeed::new([42; 32]);
let amount: u128 = 100;
let program_with_deps =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
let funder_id = funder_keys.account_id();
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0);
let alice_pda_1_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 1);
let recipient_id = test_public_account_keys_2().account_id();
let recipient_signing_key = test_public_account_keys_2().signing_key;
let mut state = V03State::new_with_genesis_accounts(&[(funder_id, 500)], vec![], 0);
let alice_pda_0_account = Account {
program_owner: auth_transfer_id,
balance: amount,
nonce: Nonce::private_account_nonce_init(&alice_pda_0_id),
..Account::default()
};
let alice_pda_1_account = Account {
program_owner: auth_transfer_id,
balance: amount,
nonce: Nonce::private_account_nonce_init(&alice_pda_1_id),
..Account::default()
};
let alice_shared_0 = SharedSecretKey::new(&[10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new(&[11; 32], &alice_keys.vpk());
// Fund alice_pda_0
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(funder_account, true, funder_id),
AccountWithMetadata::new(Account::default(), false, alice_pda_0_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, true)).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk: alice_npk,
ssk: alice_shared_0,
identifier: 0,
},
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
1,
0,
)
.unwrap();
}
// Fund alice_pda_1
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(funder_account, true, funder_id),
AccountWithMetadata::new(Account::default(), false, alice_pda_1_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, true)).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk: alice_npk,
ssk: alice_shared_1,
identifier: 1,
},
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![funder_id],
vec![funder_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&funder_keys.signing_key]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
2,
0,
)
.unwrap();
}
let commitment_pda_0 = Commitment::new(&alice_pda_0_id, &alice_pda_0_account);
let commitment_pda_1 = Commitment::new(&alice_pda_1_id, &alice_pda_1_account);
assert!(state.get_proof_for_commitment(&commitment_pda_0).is_some());
assert!(state.get_proof_for_commitment(&commitment_pda_1).is_some());
// Alice spends alice_pda_0 into the public recipient.
{
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_0_account, true, alice_pda_0_id),
AccountWithMetadata::new(recipient_account, true, recipient_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk: alice_shared_0,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_0)
.expect("pda_0 must be in state"),
identifier: 0,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![Nonce(0)],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([10; 32]),
)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[&recipient_signing_key]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
3,
0,
)
.unwrap();
}
// Alice spends alice_pda_1 into the same public recipient.
{
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_1_account, true, alice_pda_1_id),
AccountWithMetadata::new(recipient_account, false, recipient_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk: alice_shared_1,
nsk: alice_keys.nsk,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1)
.expect("pda_1 must be in state"),
identifier: 1,
},
InputAccountIdentity::Public,
],
&program_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([11; 32]),
)],
output,
)
.unwrap();
let witness_set = WitnessSet::for_message(&message, proof, &[]);
state
.transition_from_privacy_preserving_transaction(
&PrivacyPreservingTransaction::new(message, witness_set),
4,
0,
)
.unwrap();
}
assert_eq!(state.get_account_by_id(recipient_id).balance, 2 * amount);
}
}

View File

@ -1,12 +1,13 @@
use std::{
collections::{HashMap, HashSet, VecDeque, hash_map::Entry},
collections::{HashMap, VecDeque, hash_map::Entry},
convert::Infallible,
};
use nssa_core::{
Commitment, CommitmentSetDigest, DUMMY_COMMITMENT_HASH, EncryptionScheme, Identifier,
InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey,
PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, SharedSecretKey,
PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, PrivateAccountKind,
SharedSecretKey,
account::{Account, AccountId, AccountWithMetadata, Nonce},
compute_digest_for_path,
program::{
@ -17,25 +18,26 @@ use nssa_core::{
};
use risc0_zkvm::{guest::env, serde::to_vec};
const PRIVATE_PDA_FIXED_IDENTIFIER: Identifier = u128::MAX;
/// State of the involved accounts before and after program execution.
struct ExecutionState {
pre_states: Vec<AccountWithMetadata>,
post_states: HashMap<AccountId, Account>,
block_validity_window: BlockValidityWindow,
timestamp_validity_window: TimestampValidityWindow,
/// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound
/// to their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk)`
/// check.
/// Positions (in `pre_states`) of private-PDA accounts whose supplied npk has been bound to
/// their `AccountId` via a proven `AccountId::for_private_pda(program_id, seed, npk,
/// identifier)` check.
/// Two proof paths populate this set: a `Claim::Pda(seed)` in a program's `post_state` on
/// that `pre_state`, or a caller's `ChainedCall.pda_seeds` entry matching that `pre_state`
/// under the private derivation. Binding is an idempotent property, not an event: the same
/// position can legitimately be bound through both paths in the same tx (e.g. a program
/// claims a private PDA and then delegates it to a callee), and the set uses `contains`,
/// not `assert!(insert)`. After the main loop, every private-PDA position must appear in
/// this set; otherwise the npk is unbound and the circuit rejects.
private_pda_bound_positions: HashSet<usize>,
/// claims a private PDA and then delegates it to a callee), and the map uses `contains_key`,
/// not `assert!(insert)`. After the main loop, every private-PDA position must appear in this
/// map; otherwise the npk is unbound and the circuit rejects.
/// The stored `(ProgramId, PdaSeed)` is the owner program and seed, used in
/// `compute_circuit_output` to construct `PrivateAccountKind::Pda { program_id, seed,
/// identifier }`.
private_pda_bound_positions: HashMap<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
/// one private PDA per distinct npk. Without this check, a single `pda_seeds: [S]` entry in
@ -45,12 +47,12 @@ struct ExecutionState {
/// `AccountId` entry or as an equality check against the existing one, making the rule: one
/// `(program, seed)` → one account per tx.
pda_family_binding: HashMap<(ProgramId, PdaSeed), AccountId>,
/// Map from a private-PDA `pre_state`'s position in `account_identities` to the npk that
/// variant supplies for that position. Populated once in `derive_from_outputs` by walking
/// Map from a private-PDA `pre_state`'s position in `account_identities` to the (npk,
/// identifier) supplied for that position. Built once in `derive_from_outputs` by walking
/// `account_identities` and consulting `npk_if_private_pda`. Used later by the claim and
/// caller-seeds authorization paths to verify
/// `AccountId::for_private_pda(program_id, seed, npk) == pre_state.account_id`.
private_pda_npk_by_position: HashMap<usize, NullifierPublicKey>,
/// `AccountId::for_private_pda(program_id, seed, npk, identifier) == pre_state.account_id`.
private_pda_npk_by_position: HashMap<usize, (NullifierPublicKey, Identifier)>,
}
impl ExecutionState {
@ -60,14 +62,15 @@ impl ExecutionState {
program_id: ProgramId,
program_outputs: Vec<ProgramOutput>,
) -> Self {
// Build position → npk map for private-PDA pre_states, indexed by position in
// `account_identities`. The vec is documented as 1:1 with the program's pre_state order,
// so position here matches `pre_state_position` used downstream in
// Build position → (npk, identifier) map for private-PDA pre_states, indexed by position
// in `account_identities`. The vec is documented as 1:1 with the program's pre_state
// order, so position here matches `pre_state_position` used downstream in
// `validate_and_sync_states`.
let mut private_pda_npk_by_position: HashMap<usize, NullifierPublicKey> = HashMap::new();
let mut private_pda_npk_by_position: HashMap<usize, (NullifierPublicKey, Identifier)> =
HashMap::new();
for (pos, account_identity) in account_identities.iter().enumerate() {
if let Some(npk) = account_identity.npk_if_private_pda() {
private_pda_npk_by_position.insert(pos, npk);
if let Some((npk, identifier)) = account_identity.npk_if_private_pda() {
private_pda_npk_by_position.insert(pos, (npk, identifier));
}
}
@ -105,7 +108,7 @@ impl ExecutionState {
post_states: HashMap::new(),
block_validity_window,
timestamp_validity_window,
private_pda_bound_positions: HashSet::new(),
private_pda_bound_positions: HashMap::new(),
pda_family_binding: HashMap::new(),
private_pda_npk_by_position,
};
@ -208,7 +211,9 @@ impl ExecutionState {
for (pos, account_identity) in account_identities.iter().enumerate() {
if account_identity.is_private_pda() {
assert!(
execution_state.private_pda_bound_positions.contains(&pos),
execution_state
.private_pda_bound_positions
.contains_key(&pos),
"private PDA pre_state at position {pos} has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds"
);
}
@ -353,18 +358,24 @@ impl ExecutionState {
);
}
Claim::Pda(seed) => {
let npk = self
let (npk, identifier) = self
.private_pda_npk_by_position
.get(&pre_state_position)
.expect(
"private PDA pre_state must have an npk in the position map",
);
let pda = AccountId::for_private_pda(&program_id, &seed, npk);
let pda =
AccountId::for_private_pda(&program_id, &seed, npk, *identifier);
assert_eq!(
pre_account_id, pda,
"Invalid private PDA claim for account {pre_account_id}"
);
self.private_pda_bound_positions.insert(pre_state_position);
bind_private_pda_position(
&mut self.private_pda_bound_positions,
pre_state_position,
program_id,
seed,
);
assert_family_binding(
&mut self.pda_family_binding,
program_id,
@ -428,6 +439,24 @@ fn assert_family_binding(
}
}
fn bind_private_pda_position(
map: &mut HashMap<usize, (ProgramId, PdaSeed)>,
position: usize,
program_id: ProgramId,
seed: PdaSeed,
) {
match map.entry(position) {
Entry::Occupied(e) => assert_eq!(
*e.get(),
(program_id, seed),
"Duplicate binding at position {position}: conflicting (program_id, seed)"
),
Entry::Vacant(e) => {
e.insert((program_id, seed));
}
}
}
/// Resolve the authorization state of a `pre_state` seen again in a chained call and record
/// any resulting bindings. Returns `true` if the `pre_state` is authorized through either a
/// previously-seen authorization or a matching caller seed (under the public or private
@ -443,8 +472,8 @@ fn assert_family_binding(
)]
fn resolve_authorization_and_record_bindings(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
private_pda_bound_positions: &mut HashSet<usize>,
private_pda_npk_by_position: &HashMap<usize, NullifierPublicKey>,
private_pda_bound_positions: &mut HashMap<usize, (ProgramId, PdaSeed)>,
private_pda_npk_by_position: &HashMap<usize, (NullifierPublicKey, Identifier)>,
pre_account_id: AccountId,
pre_state_position: usize,
caller_program_id: Option<ProgramId>,
@ -457,8 +486,9 @@ fn resolve_authorization_and_record_bindings(
if AccountId::for_public_pda(&caller, seed) == pre_account_id {
return Some((*seed, false, caller));
}
if let Some(npk) = private_pda_npk_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk) == pre_account_id
if let Some((npk, identifier)) =
private_pda_npk_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, *identifier) == pre_account_id
{
return Some((*seed, true, caller));
}
@ -469,7 +499,12 @@ fn resolve_authorization_and_record_bindings(
if let Some((seed, is_private_form, caller)) = matched_caller_seed {
assert_family_binding(pda_family_binding, caller, seed, pre_account_id);
if is_private_form {
private_pda_bound_positions.insert(pre_state_position);
bind_private_pda_position(
private_pda_bound_positions,
pre_state_position,
caller,
seed,
);
}
}
@ -477,7 +512,7 @@ fn resolve_authorization_and_record_bindings(
}
fn compute_circuit_output(
execution_state: ExecutionState,
mut execution_state: ExecutionState,
account_identities: &[InputAccountIdentity],
) -> PrivacyPreservingCircuitOutput {
let mut output = PrivacyPreservingCircuitOutput {
@ -490,6 +525,7 @@ fn compute_circuit_output(
timestamp_validity_window: execution_state.timestamp_validity_window,
};
let pda_seed_by_position = std::mem::take(&mut execution_state.private_pda_bound_positions);
let states_iter = execution_state.into_states_iter();
assert_eq!(
account_identities.len(),
@ -498,7 +534,9 @@ fn compute_circuit_output(
);
let mut output_index = 0;
for (account_identity, (pre_state, post_state)) in account_identities.iter().zip(states_iter) {
for (pos, (account_identity, (pre_state, post_state))) in
account_identities.iter().zip(states_iter).enumerate()
{
match account_identity {
InputAccountIdentity::Public => {
output.public_pre_states.push(pre_state);
@ -509,12 +547,8 @@ fn compute_circuit_output(
nsk,
identifier,
} => {
assert_ne!(
*identifier, PRIVATE_PDA_FIXED_IDENTIFIER,
"Identifier must be different from {PRIVATE_PDA_FIXED_IDENTIFIER}. This is reserved for private PDA."
);
let npk = NullifierPublicKey::from(nsk);
let account_id = AccountId::from((&npk, *identifier));
let account_id = AccountId::for_regular_private_account(&npk, *identifier);
assert_eq!(account_id, pre_state.account_id, "AccountId mismatch");
assert!(
@ -538,7 +572,7 @@ fn compute_circuit_output(
&mut output_index,
post_state,
&account_id,
*identifier,
&PrivateAccountKind::Regular(*identifier),
ssk,
new_nullifier,
new_nonce,
@ -550,12 +584,8 @@ fn compute_circuit_output(
membership_proof,
identifier,
} => {
assert_ne!(
*identifier, PRIVATE_PDA_FIXED_IDENTIFIER,
"Identifier must be different from {PRIVATE_PDA_FIXED_IDENTIFIER}. This is reserved for private PDA."
);
let npk = NullifierPublicKey::from(nsk);
let account_id = AccountId::from((&npk, *identifier));
let account_id = AccountId::for_regular_private_account(&npk, *identifier);
assert_eq!(account_id, pre_state.account_id, "AccountId mismatch");
assert!(
@ -576,7 +606,7 @@ fn compute_circuit_output(
&mut output_index,
post_state,
&account_id,
*identifier,
&PrivateAccountKind::Regular(*identifier),
ssk,
new_nullifier,
new_nonce,
@ -587,11 +617,7 @@ fn compute_circuit_output(
ssk,
identifier,
} => {
assert_ne!(
*identifier, PRIVATE_PDA_FIXED_IDENTIFIER,
"Identifier must be different from {PRIVATE_PDA_FIXED_IDENTIFIER}. This is reserved for private PDA."
);
let account_id = AccountId::from((npk, *identifier));
let account_id = AccountId::for_regular_private_account(npk, *identifier);
assert_eq!(account_id, pre_state.account_id, "AccountId mismatch");
assert_eq!(
@ -615,13 +641,17 @@ fn compute_circuit_output(
&mut output_index,
post_state,
&account_id,
*identifier,
&PrivateAccountKind::Regular(*identifier),
ssk,
new_nullifier,
new_nonce,
);
}
InputAccountIdentity::PrivatePdaInit { npk: _, ssk } => {
InputAccountIdentity::PrivatePdaInit {
npk: _,
ssk,
identifier,
} => {
// The npk-to-account_id binding is established upstream in
// `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds`
// match. Here we only enforce the init pre-conditions. The supplied npk on
@ -645,12 +675,19 @@ fn compute_circuit_output(
let new_nonce = Nonce::private_account_nonce_init(&pre_state.account_id);
let account_id = pre_state.account_id;
let (pda_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaInit position must be in pda_seed_by_position");
emit_private_output(
&mut output,
&mut output_index,
post_state,
&account_id,
PRIVATE_PDA_FIXED_IDENTIFIER,
&PrivateAccountKind::Pda {
program_id: *pda_program_id,
seed: *seed,
identifier: *identifier,
},
ssk,
new_nullifier,
new_nonce,
@ -660,6 +697,7 @@ fn compute_circuit_output(
ssk,
nsk,
membership_proof,
identifier,
} => {
// The npk binding is established upstream. Authorization must already be set;
// an unauthorized PrivatePdaUpdate would mean the prover supplied an nsk for an
@ -679,12 +717,19 @@ fn compute_circuit_output(
let new_nonce = pre_state.account.nonce.private_account_nonce_increment(nsk);
let account_id = pre_state.account_id;
let (pda_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
emit_private_output(
&mut output,
&mut output_index,
post_state,
&account_id,
PRIVATE_PDA_FIXED_IDENTIFIER,
&PrivateAccountKind::Pda {
program_id: *pda_program_id,
seed: *seed,
identifier: *identifier,
},
ssk,
new_nullifier,
new_nonce,
@ -705,7 +750,7 @@ fn emit_private_output(
output_index: &mut u32,
post_state: Account,
account_id: &AccountId,
identifier: Identifier,
kind: &PrivateAccountKind,
shared_secret: &SharedSecretKey,
new_nullifier: (Nullifier, CommitmentSetDigest),
new_nonce: Nonce,
@ -718,7 +763,7 @@ fn emit_private_output(
let commitment_post = Commitment::new(account_id, &post_with_updated_nonce);
let encrypted_account = EncryptionScheme::encrypt(
&post_with_updated_nonce,
identifier,
kind,
shared_secret,
&commitment_post,
*output_index,

View File

@ -49,7 +49,7 @@ pub enum Instruction {
pub fn compute_ata_seed(owner_id: AccountId, definition_id: AccountId) -> PdaSeed {
use risc0_zkvm::sha::{Impl, Sha256};
let mut bytes = [0u8; 64];
let mut bytes = [0_u8; 64];
bytes[0..32].copy_from_slice(&owner_id.to_bytes());
bytes[32..64].copy_from_slice(&definition_id.to_bytes());
PdaSeed::new(

View File

@ -141,7 +141,7 @@ impl<BP: BlockPublisherTrait> SequencerCore<BP> {
.iter()
.map(|init_comm_data| {
let npk = &init_comm_data.npk;
let account_id = nssa::AccountId::from((npk, 0));
let account_id = nssa::AccountId::for_regular_private_account(npk, 0);
let mut acc = init_comm_data.account.clone();

View File

@ -0,0 +1,70 @@
use nssa_core::{
account::AccountWithMetadata,
program::{
AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
},
};
use risc0_zkvm::serde::to_vec;
/// Proxy for interacting with private PDAs via `auth_transfer`.
///
/// The `is_fund` flag selects the operating mode:
///
/// - `false` (Spend): `pre_states = [pda (authorized), recipient]`. Debits the PDA. The PDA-to-npk
/// binding is established via `pda_seeds` in the chained call to `auth_transfer`.
///
/// - `true` (Fund): `pre_states = [sender (authorized), pda (foreign/uninitialized)]`. Credits the
/// PDA. A direct call to `auth_transfer` cannot bind the PDA because `auth_transfer` uses
/// `Claim::Authorized`, not `Claim::Pda`. Routing through this proxy establishes the binding via
/// `pda_seeds` in the chained call.
type Instruction = (PdaSeed, u128, ProgramId, bool);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (seed, amount, auth_transfer_id, is_fund),
},
instruction_words,
) = read_nssa_inputs::<Instruction>();
let Ok([first, second]) = <[_; 2]>::try_from(pre_states) else {
return;
};
assert!(first.is_authorized, "first pre_state must be authorized");
let chained_pre_states = if is_fund {
let pda_authorized = AccountWithMetadata {
account: second.account.clone(),
account_id: second.account_id,
is_authorized: true,
};
vec![first.clone(), pda_authorized]
} else {
vec![first.clone(), second.clone()]
};
let first_post = AccountPostState::new(first.account.clone());
let second_post = AccountPostState::new(second.account.clone());
let chained_call = ChainedCall {
program_id: auth_transfer_id,
instruction_data: to_vec(&amount).unwrap(),
pre_states: chained_pre_states,
pda_seeds: vec![seed],
};
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![first, second],
vec![first_post, second_post],
)
.with_chained_calls(vec![chained_call])
.write();
}

View File

@ -103,7 +103,10 @@ pub struct PrivateAccountPrivateInitialData {
impl PrivateAccountPrivateInitialData {
#[must_use]
pub fn account_id(&self) -> nssa::AccountId {
nssa::AccountId::from((&self.key_chain.nullifier_public_key, self.identifier))
nssa::AccountId::for_regular_private_account(
&self.key_chain.nullifier_public_key,
self.identifier,
)
}
}
@ -208,7 +211,7 @@ pub fn initial_state() -> V03State {
.iter()
.map(|init_comm_data| {
let npk = &init_comm_data.npk;
let account_id = nssa::AccountId::from((npk, 0));
let account_id = nssa::AccountId::for_regular_private_account(npk, 0);
let mut acc = init_comm_data.account.clone();

View File

@ -11,6 +11,7 @@ use key_protocol::{
};
use log::debug;
use nssa::program::Program;
use nssa_core::PrivateAccountKind;
use crate::config::{InitialAccountData, Label, PersistentAccountData, WalletConfig};
@ -72,8 +73,8 @@ impl WalletChainStore {
PersistentAccountData::Private(data) => {
let npk = data.data.value.0.nullifier_public_key;
let chain_index = data.chain_index;
for identifier in &data.identifiers {
let account_id = nssa::AccountId::from((&npk, *identifier));
for kind in &data.kinds {
let account_id = nssa::AccountId::for_private_account(&npk, kind);
private_tree
.account_id_map
.insert(account_id, chain_index.clone());
@ -89,7 +90,10 @@ impl WalletChainStore {
data.account_id(),
UserPrivateAccountData {
key_chain: data.key_chain,
accounts: vec![(data.identifier, data.account)],
accounts: vec![(
PrivateAccountKind::Regular(data.identifier),
data.account,
)],
},
);
}
@ -135,7 +139,7 @@ impl WalletChainStore {
account_id,
UserPrivateAccountData {
key_chain: data.key_chain,
accounts: vec![(data.identifier, account)],
accounts: vec![(PrivateAccountKind::Regular(data.identifier), account)],
},
);
}
@ -190,7 +194,7 @@ impl WalletChainStore {
pub fn insert_private_account_data(
&mut self,
account_id: nssa::AccountId,
identifier: nssa_core::Identifier,
kind: &PrivateAccountKind,
account: nssa_core::account::Account,
) {
debug!("inserting at address {account_id}, this account {account:?}");
@ -202,10 +206,10 @@ impl WalletChainStore {
.entry(account_id)
{
let entry = entry.get_mut();
if let Some((_, acc)) = entry.accounts.iter_mut().find(|(id, _)| *id == identifier) {
if let Some((_, acc)) = entry.accounts.iter_mut().find(|(k, _)| k == kind) {
*acc = account;
} else {
entry.accounts.push((identifier, account));
entry.accounts.push((kind.clone(), account));
}
return;
}
@ -228,24 +232,21 @@ impl WalletChainStore {
.key_map
.get_mut(&chain_index)
{
if let Some((_, acc)) = node.value.1.iter_mut().find(|(id, _)| *id == identifier) {
if let Some((_, acc)) = node.value.1.iter_mut().find(|(k, _)| k == kind) {
*acc = account;
} else {
node.value.1.push((identifier, account));
node.value.1.push((kind.clone(), account));
}
}
} else {
// Node not yet in account_id_map — find it by checking all nodes
for (ci, node) in &mut self.user_data.private_key_tree.key_map {
let expected_id =
nssa::AccountId::from((&node.value.0.nullifier_public_key, identifier));
if expected_id == account_id {
if let Some((_, acc)) =
node.value.1.iter_mut().find(|(id, _)| *id == identifier)
{
let npk = &node.value.0.nullifier_public_key;
if nssa::AccountId::for_private_account(npk, kind) == account_id {
if let Some((_, acc)) = node.value.1.iter_mut().find(|(k, _)| k == kind) {
*acc = account;
} else {
node.value.1.push((identifier, account));
node.value.1.push((kind.clone(), account));
}
// Register in account_id_map
self.user_data
@ -291,7 +292,7 @@ mod tests {
data: public_data,
}),
PersistentAccountData::Private(Box::new(PersistentAccountDataPrivate {
identifiers: vec![],
kinds: vec![],
chain_index: ChainIndex::root(),
data: private_data,
})),

View File

@ -108,6 +108,10 @@ pub enum NewSubcommand {
#[arg(long, requires = "pda")]
/// Program ID as hex string.
program_id: Option<String>,
#[arg(long, requires = "pda")]
/// Identifier that diversifies this PDA within the (`program_id`, seed, npk) family.
/// Defaults to a random value if not specified.
identifier: Option<u128>,
},
/// Recommended for receiving from multiple senders: creates a key node (npk + vpk) without
/// registering any account.
@ -208,6 +212,7 @@ impl WalletSubcommand for NewSubcommand {
pda,
seed,
program_id,
identifier,
} => {
if let Some(label) = &label
&& wallet_core
@ -239,7 +244,12 @@ impl WalletSubcommand for NewSubcommand {
pid[i] = u32::from_le_bytes(chunk.try_into().unwrap());
}
wallet_core.create_shared_pda_account(&group, pda_seed, pid)?
wallet_core.create_shared_pda_account(
&group,
pda_seed,
pid,
identifier.unwrap_or_else(rand::random),
)?
} else {
wallet_core.create_shared_regular_account(&group)?
};

View File

@ -28,7 +28,7 @@ pub struct PersistentAccountDataPublic {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PersistentAccountDataPrivate {
pub identifiers: Vec<nssa_core::Identifier>,
pub kinds: Vec<nssa_core::PrivateAccountKind>,
pub chain_index: ChainIndex,
pub data: ChildKeysPrivate,
}

View File

@ -166,10 +166,10 @@ pub fn produce_data_for_storage(
}
for (chain_index, node) in &user_data.private_key_tree.key_map {
let identifiers = node.value.1.iter().map(|(id, _)| *id).collect();
let kinds = node.value.1.iter().map(|(kind, _)| kind.clone()).collect();
vec_for_storage.push(
PersistentAccountDataPrivate {
identifiers,
kinds,
chain_index: chain_index.clone(),
data: node.clone(),
}
@ -188,12 +188,12 @@ pub fn produce_data_for_storage(
}
for entry in user_data.default_user_private_accounts.values() {
for (identifier, account) in &entry.accounts {
for (kind, account) in &entry.accounts {
vec_for_storage.push(
InitialAccountData::Private(Box::new(PrivateAccountPrivateInitialData {
account: account.clone(),
key_chain: entry.key_chain.clone(),
identifier: *identifier,
identifier: kind.identifier(),
}))
.into(),
);

View File

@ -24,7 +24,8 @@ use nssa::{
},
};
use nssa_core::{
Commitment, MembershipProof, SharedSecretKey, account::Nonce, program::InstructionData,
Commitment, MembershipProof, PrivateAccountKind, SharedSecretKey, account::Nonce,
program::InstructionData,
};
pub use privacy_preserving_tx::PrivacyPreservingAccount;
use sequencer_service_rpc::{RpcClient as _, SequencerClient, SequencerClientBuilder};
@ -297,9 +298,12 @@ impl WalletCore {
.value
.0
.nullifier_public_key;
let account_id = AccountId::from((&npk, identifier));
self.storage
.insert_private_account_data(account_id, identifier, Account::default());
let account_id = AccountId::for_regular_private_account(&npk, identifier);
self.storage.insert_private_account_data(
account_id,
&PrivateAccountKind::Regular(identifier),
Account::default(),
);
(account_id, cci)
}
@ -341,15 +345,14 @@ impl WalletCore {
.user_data
.group_key_holder(&entry.group_label)?;
if let Some(pda_seed) = &entry.pda_seed {
let program_id = entry.pda_program_id?;
let keys = holder.derive_keys_for_pda(&program_id, pda_seed);
Some(PrivacyPreservingAccount::PrivatePda {
if let (Some(pda_seed), Some(program_id)) = (entry.pda_seed, entry.pda_program_id) {
let keys = holder.derive_keys_for_pda(&program_id, &pda_seed);
Some(PrivacyPreservingAccount::PrivatePdaShared {
account_id,
nsk: keys.nullifier_secret_key,
npk: keys.generate_nullifier_public_key(),
vpk: keys.generate_viewing_public_key(),
program_id,
seed: *pda_seed,
identifier: entry.identifier,
})
} else {
let derivation_seed = {
@ -406,6 +409,7 @@ impl WalletCore {
group_name: &str,
pda_seed: nssa_core::program::PdaSeed,
program_id: nssa_core::program::ProgramId,
identifier: nssa_core::Identifier,
) -> Result<SharedAccountInfo> {
let holder = self
.storage
@ -416,12 +420,12 @@ impl WalletCore {
let keys = holder.derive_keys_for_pda(&program_id, &pda_seed);
let npk = keys.generate_nullifier_public_key();
let vpk = keys.generate_viewing_public_key();
let account_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk);
let account_id = AccountId::for_private_pda(&program_id, &pda_seed, &npk, identifier);
self.register_shared_account(
account_id,
String::from(group_name),
u128::MAX,
identifier,
Some(pda_seed),
Some(program_id),
);
@ -536,7 +540,7 @@ impl WalletCore {
let acc_ead = tx.message.encrypted_private_post_states[output_index].clone();
let acc_comm = tx.message.new_commitments[output_index].clone();
let (identifier, res_acc) = nssa_core::EncryptionScheme::decrypt(
let (kind, res_acc) = nssa_core::EncryptionScheme::decrypt(
&acc_ead.ciphertext,
secret,
&acc_comm,
@ -549,7 +553,7 @@ impl WalletCore {
println!("Received new acc {res_acc:#?}");
self.storage
.insert_private_account_data(*acc_account_id, identifier, res_acc);
.insert_private_account_data(*acc_account_id, &kind, res_acc);
}
AccDecodeData::Skip => {}
}
@ -717,24 +721,22 @@ impl WalletCore {
.try_into()
.expect("Ciphertext ID is expected to fit in u32"),
)
.map(|(identifier, res_acc)| {
let account_id = nssa::AccountId::from((
&key_chain.nullifier_public_key,
identifier,
));
(account_id, identifier, res_acc)
.map(|(kind, res_acc)| {
let npk = &key_chain.nullifier_public_key;
let account_id = nssa::AccountId::for_private_account(npk, &kind);
(account_id, kind, res_acc)
})
})
.collect::<Vec<_>>()
})
.collect::<Vec<_>>();
for (affected_account_id, identifier, new_acc) in affected_accounts {
for (affected_account_id, kind, new_acc) in affected_accounts {
info!(
"Received new account for account_id {affected_account_id:#?} with account object {new_acc:#?}"
);
self.storage
.insert_private_account_data(affected_account_id, identifier, new_acc);
.insert_private_account_data(affected_account_id, &kind, new_acc);
}
// Scan for updates to shared accounts (GMS-derived).
@ -792,7 +794,7 @@ impl WalletCore {
let shared_secret = SharedSecretKey::new(&vsk, &encrypted_data.epk);
let commitment = &tx.message.new_commitments[ciph_id];
if let Some((_decrypted_identifier, new_acc)) = nssa_core::EncryptionScheme::decrypt(
if let Some((_kind, new_acc)) = nssa_core::EncryptionScheme::decrypt(
&encrypted_data.ciphertext,
&shared_secret,
commitment,

View File

@ -6,7 +6,6 @@ use nssa_core::{
SharedSecretKey,
account::{AccountWithMetadata, Nonce},
encryption::{EphemeralPublicKey, ViewingPublicKey},
program::{PdaSeed, ProgramId},
};
use crate::{ExecutionFailureKind, WalletCore};
@ -20,15 +19,16 @@ pub enum PrivacyPreservingAccount {
vpk: ViewingPublicKey,
identifier: Identifier,
},
/// A private PDA with externally-provided keys. The caller resolves the keys
/// (e.g. via `GroupKeyHolder::derive_keys_for_pda`) before constructing this variant.
/// The wallet computes the `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`.
PrivatePda {
nsk: NullifierSecretKey,
/// An owned private PDA: wallet holds the nsk/npk; `account_id` was derived via
/// [`AccountId::for_private_pda`].
PrivatePdaOwned(AccountId),
/// A foreign private PDA: wallet knows the recipient's npk/vpk but not their nsk.
/// Uses a default (uninitialised) account.
PrivatePdaForeign {
account_id: AccountId,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
program_id: ProgramId,
seed: PdaSeed,
identifier: Identifier,
},
/// A shared regular private account with externally-provided keys (e.g. from GMS).
/// Uses standard `AccountId = from((&npk, identifier))` with authorized/unauthorized private
@ -39,6 +39,15 @@ pub enum PrivacyPreservingAccount {
vpk: ViewingPublicKey,
identifier: Identifier,
},
/// A shared private PDA with externally-provided keys (e.g. from GMS).
/// `account_id` was derived via [`AccountId::for_private_pda`].
PrivatePdaShared {
account_id: AccountId,
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: Identifier,
},
}
impl PrivacyPreservingAccount {
@ -52,13 +61,11 @@ impl PrivacyPreservingAccount {
matches!(
&self,
Self::PrivateOwned(_)
| Self::PrivateForeign {
npk: _,
vpk: _,
identifier: _,
}
| Self::PrivatePda { .. }
| Self::PrivateForeign { .. }
| Self::PrivatePdaOwned(_)
| Self::PrivatePdaForeign { .. }
| Self::PrivateShared { .. }
| Self::PrivatePdaShared { .. }
)
}
}
@ -103,7 +110,7 @@ impl AccountManager {
State::Public { account, sk }
}
PrivacyPreservingAccount::PrivateOwned(account_id) => {
let pre = private_acc_preparation(wallet, account_id).await?;
let pre = private_key_tree_acc_preparation(wallet, account_id, false).await?;
State::Private(pre)
}
@ -121,26 +128,42 @@ impl AccountManager {
nsk: None,
npk,
identifier,
is_pda: false,
vpk,
pre_state: auth_acc,
proof: None,
ssk,
epk,
is_pda: false,
};
State::Private(pre)
}
PrivacyPreservingAccount::PrivatePda {
nsk,
PrivacyPreservingAccount::PrivatePdaOwned(account_id) => {
let pre = private_key_tree_acc_preparation(wallet, account_id, true).await?;
State::Private(pre)
}
PrivacyPreservingAccount::PrivatePdaForeign {
account_id,
npk,
vpk,
program_id,
seed,
identifier,
} => {
let pre =
private_pda_preparation(wallet, nsk, npk, vpk, &program_id, &seed).await?;
let acc = nssa_core::account::Account::default();
let auth_acc = AccountWithMetadata::new(acc, false, account_id);
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
let pre = AccountPreparedData {
nsk: None,
npk,
identifier,
vpk,
pre_state: auth_acc,
proof: None,
ssk,
epk,
is_pda: true,
};
State::Private(pre)
}
PrivacyPreservingAccount::PrivateShared {
@ -149,7 +172,25 @@ impl AccountManager {
vpk,
identifier,
} => {
let pre = private_shared_preparation(wallet, nsk, npk, vpk, identifier).await?;
let account_id = nssa::AccountId::from((&npk, identifier));
let pre = private_shared_acc_preparation(
wallet, account_id, nsk, npk, vpk, identifier, false,
)
.await?;
State::Private(pre)
}
PrivacyPreservingAccount::PrivatePdaShared {
account_id,
nsk,
npk,
vpk,
identifier,
} => {
let pre = private_shared_acc_preparation(
wallet, account_id, nsk, npk, vpk, identifier, true,
)
.await?;
State::Private(pre)
}
@ -210,10 +251,12 @@ impl AccountManager {
ssk: pre.ssk,
nsk,
membership_proof,
identifier: pre.identifier,
},
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
identifier: pre.identifier,
},
},
State::Private(pre) => match (pre.nsk, pre.proof.clone()) {
@ -265,7 +308,6 @@ struct AccountPreparedData {
nsk: Option<NullifierSecretKey>,
npk: NullifierPublicKey,
identifier: Identifier,
is_pda: bool,
vpk: ViewingPublicKey,
pre_state: AccountWithMetadata,
proof: Option<MembershipProof>,
@ -276,20 +318,23 @@ struct AccountPreparedData {
ssk: SharedSecretKey,
/// Cached ephemeral public key, paired with `ssk`.
epk: EphemeralPublicKey,
/// True when this account is a private PDA (owned or foreign). Used by `account_identities()`
/// to select `PrivatePdaInit`/`PrivatePdaUpdate` rather than the standalone private variants.
is_pda: bool,
}
async fn private_acc_preparation(
async fn private_key_tree_acc_preparation(
wallet: &WalletCore,
account_id: AccountId,
is_pda: bool,
) -> Result<AccountPreparedData, ExecutionFailureKind> {
let Some((from_keys, from_acc, from_identifier)) =
wallet.storage.user_data.get_private_account(account_id)
else {
return Err(ExecutionFailureKind::KeyNotFoundError);
};
let (from_keys, from_acc, from_identifier) = wallet
.storage
.user_data
.get_private_account(account_id)
.ok_or(ExecutionFailureKind::KeyNotFoundError)?;
let nsk = from_keys.private_key_holder.nullifier_secret_key;
let from_npk = from_keys.nullifier_public_key;
let from_vpk = from_keys.viewing_public_key;
@ -301,7 +346,7 @@ async fn private_acc_preparation(
// TODO: Technically we could allow unauthorized owned accounts, but currently we don't have
// support from that in the wallet.
let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, (&from_npk, from_identifier));
let sender_pre = AccountWithMetadata::new(from_acc.clone(), true, account_id);
let eph_holder = EphemeralKeyHolder::new(&from_npk);
let ssk = eph_holder.calculate_shared_secret_sender(&from_vpk);
@ -311,27 +356,24 @@ async fn private_acc_preparation(
nsk: Some(nsk),
npk: from_npk,
identifier: from_identifier,
is_pda: false,
vpk: from_vpk,
pre_state: sender_pre,
proof,
ssk,
epk,
is_pda,
})
}
async fn private_pda_preparation(
async fn private_shared_acc_preparation(
wallet: &WalletCore,
account_id: AccountId,
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
program_id: &ProgramId,
seed: &PdaSeed,
identifier: Identifier,
is_pda: bool,
) -> Result<AccountPreparedData, ExecutionFailureKind> {
let account_id = nssa::AccountId::for_private_pda(program_id, seed, &npk);
// Check local cache first (private PDA state is encrypted on-chain, the sequencer
// only stores commitments). Fall back to default for new PDAs.
let acc = wallet
.storage
.user_data
@ -340,11 +382,6 @@ async fn private_pda_preparation(
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();
// is_authorized tracks whether the account existed on-chain before this tx.
// NSK is only provided for existing accounts: the circuit consumes NSKs sequentially
// from an iterator and asserts none are left over, so supplying an NSK for a new
// (unauthorized) account would trigger the over-supply assertion.
let pre_state = AccountWithMetadata::new(acc, exists, account_id);
let proof = if exists {
@ -360,61 +397,16 @@ async fn private_pda_preparation(
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
Ok(AccountPreparedData {
nsk: exists.then_some(nsk),
npk,
identifier: u128::MAX,
is_pda: true,
vpk,
pre_state,
proof,
ssk,
epk,
})
}
async fn private_shared_preparation(
wallet: &WalletCore,
nsk: NullifierSecretKey,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: Identifier,
) -> Result<AccountPreparedData, ExecutionFailureKind> {
let account_id = nssa::AccountId::from((&npk, identifier));
let acc = wallet
.storage
.user_data
.shared_private_account(&account_id)
.map(|e| e.account.clone())
.unwrap_or_default();
let exists = acc != nssa_core::account::Account::default();
let pre_state = AccountWithMetadata::new(acc, exists, (&npk, identifier));
let proof = if exists {
wallet
.check_private_account_initialized(account_id)
.await
.unwrap_or(None)
} else {
None
};
let eph_holder = EphemeralKeyHolder::new(&npk);
let ssk = eph_holder.calculate_shared_secret_sender(&vpk);
let epk = eph_holder.generate_ephemeral_public_key();
Ok(AccountPreparedData {
nsk: exists.then_some(nsk),
npk,
identifier,
is_pda: false,
vpk,
pre_state,
proof,
ssk,
epk,
is_pda,
})
}