Merge 3c6d623c495a815687d2489b9d451c2f280877e4 into 694e48422847771b9d3d1948dc796830986003f2

This commit is contained in:
Sergio Chouhy 2026-05-21 11:46:11 -03:00 committed by GitHub
commit 9a550847d9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 359 additions and 183 deletions

View File

@ -16,7 +16,6 @@ ignore = [
{ id = "RUSTSEC-2026-0097", reason = "`rand` v0.8.5 is present transitively from logos crates, modification may break integration" },
{ id = "RUSTSEC-2026-0118", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" },
{ id = "RUSTSEC-2026-0119", reason = "`hickory-proto` v0.25.0-alpha.5 is present transitively from logos crates, modification may break integration" },
{ id = "RUSTSEC-2026-0145", reason = "`astral-tokio-tar` v0.6.1 is pulled transitively via testcontainers (integration_tests dev/test path); waiting on upstream fix" },
]
yanked = "deny"
unused-ignored-advisory = "deny"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -704,6 +704,7 @@ async fn ppt_that_chain_calls_faucet_is_dropped() -> Result<()> {
npk,
ssk,
identifier: 1337,
seed: None,
},
],
&program_with_deps,

View File

@ -6,27 +6,37 @@
use std::{path::PathBuf, time::Duration};
use anyhow::{Context as _, Result};
use authenticated_transfer_core::Instruction as AuthTransferInstruction;
use common::transaction::NSSATransaction;
use integration_tests::{
NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext,
NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY, TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext,
verify_commitment_is_in_state,
};
use key_protocol::key_management::ephemeral_key_holder::EphemeralKeyHolder;
use log::info;
use nssa::{
AccountId, ProgramId, privacy_preserving_transaction::circuit::ProgramWithDependencies,
AccountId, PrivacyPreservingTransaction, ProgramId,
privacy_preserving_transaction::{
circuit::{ProgramWithDependencies, execute_and_prove},
message::Message,
witness_set::WitnessSet,
},
program::Program,
};
use nssa_core::{NullifierPublicKey, encryption::ViewingPublicKey, program::PdaSeed};
use nssa_core::{
InputAccountIdentity, NullifierPublicKey,
account::{Account, AccountWithMetadata},
encryption::ViewingPublicKey,
program::PdaSeed,
};
use sequencer_service_rpc::RpcClient as _;
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`.
/// Funds a private PDA by calling `auth_transfer` directly.
#[expect(
clippy::too_many_arguments,
reason = "test helper — grouping args would obscure intent"
@ -34,32 +44,68 @@ use wallet::{
async fn fund_private_pda(
wallet: &WalletCore,
sender: AccountId,
pda_account_id: AccountId,
npk: NullifierPublicKey,
vpk: ViewingPublicKey,
identifier: u128,
seed: PdaSeed,
authority_program_id: ProgramId,
amount: u128,
proxy_program: &ProgramWithDependencies,
auth_transfer_id: ProgramId,
auth_transfer: &ProgramWithDependencies,
) -> 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,
)
let pda_account_id = AccountId::for_private_pda(&authority_program_id, &seed, &npk, identifier);
let sender_account = wallet
.get_account_public(sender)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
.map_err(|e| anyhow::anyhow!("failed to get sender account: {e}"))?;
let sender_sk = wallet
.get_account_public_signing_key(sender)
.context("sender signing key not found")?;
let sender_pre = AccountWithMetadata::new(sender_account.clone(), true, sender);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_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 instruction = Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.context("failed to serialize auth_transfer instruction")?;
let account_identities = vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk,
ssk,
identifier,
seed: Some((seed, authority_program_id)),
},
];
let (output, proof) = execute_and_prove(
vec![sender_pre, pda_pre],
instruction,
account_identities,
auth_transfer,
)
.map_err(|e| anyhow::anyhow!("circuit proving failed: {e}"))?;
let message = Message::try_from_circuit_output(
vec![sender],
vec![sender_account.nonce],
vec![(npk, vpk, epk)],
output,
)
.map_err(|e| anyhow::anyhow!("message build failed: {e}"))?;
let witness_set = WitnessSet::for_message(&message, proof, &[sender_sk]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
wallet
.sequencer_client
.send_transaction(NSSATransaction::PrivacyPreserving(tx))
.await
.map_err(|e| anyhow::anyhow!("send transaction failed: {e}"))?;
Ok(())
}
@ -78,7 +124,7 @@ async fn spend_private_pda(
seed: PdaSeed,
amount: u128,
spend_program: &ProgramWithDependencies,
auth_transfer_id: nssa::ProgramId,
auth_transfer_id: ProgramId,
) -> Result<()> {
wallet
.send_privacy_preserving_tx(
@ -90,8 +136,8 @@ async fn spend_private_pda(
identifier: 0,
},
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false))
.context("failed to serialize pda_fund_spend_proxy instruction")?,
Program::serialize_instruction((seed, amount, auth_transfer_id))
.context("failed to serialize pda_spend_proxy instruction")?,
spend_program,
)
.await
@ -124,9 +170,9 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
let proxy = {
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../artifacts/test_program_methods")
.join(NSSA_PROGRAM_FOR_TEST_PDA_FUND_SPEND_PROXY);
.join(NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY);
Program::new(std::fs::read(&path).with_context(|| format!("reading {path:?}"))?)
.context("invalid pda_fund_spend_proxy binary")?
.context("invalid pda_spend_proxy binary")?
};
let auth_transfer = Program::authenticated_transfer_program();
let proxy_id = proxy.id();
@ -134,6 +180,7 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
let seed = PdaSeed::new([42; 32]);
let amount: u128 = 100;
let auth_transfer_program = ProgramWithDependencies::new(auth_transfer.clone(), [].into());
let spend_program =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer)].into());
@ -151,14 +198,13 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
fund_private_pda(
ctx.wallet(),
sender_0,
alice_pda_0_id,
alice_npk,
alice_vpk.clone(),
0,
seed,
proxy_id,
amount,
&spend_program,
auth_transfer_id,
&auth_transfer_program,
)
.await?;
@ -166,14 +212,13 @@ async fn private_pda_family_members_receive_and_spend() -> Result<()> {
fund_private_pda(
ctx.wallet(),
sender_1,
alice_pda_1_id,
alice_npk,
alice_vpk.clone(),
1,
seed,
proxy_id,
amount,
&spend_program,
auth_transfer_id,
&auth_transfer_program,
)
.await?;

View File

@ -5,7 +5,7 @@ use crate::{
NullifierSecretKey, SharedSecretKey,
account::{Account, AccountWithMetadata},
encryption::Ciphertext,
program::{BlockValidityWindow, ProgramId, ProgramOutput, TimestampValidityWindow},
program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow},
};
#[derive(Serialize, Deserialize)]
@ -60,15 +60,28 @@ pub enum InputAccountIdentity {
npk: NullifierPublicKey,
ssk: SharedSecretKey,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
/// pre_state.account_id` rather than requiring a `Claim::Pda` or caller
/// `pda_seeds` to establish the binding. The `pre_state` must have `is_authorized
/// == false`.
seed: Option<(PdaSeed, ProgramId)>,
},
/// 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
/// Update of an existing private PDA, with membership proof. `npk` is derived
/// from `nsk`. Authorization may be established upstream by a caller `pda_seeds` match or a
/// previously-seen authorization in a chained call.
PrivatePdaUpdate {
ssk: SharedSecretKey,
nsk: NullifierSecretKey,
membership_proof: MembershipProof,
identifier: Identifier,
/// When `Some((seed, authority_program_id))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, identifier) ==
/// pre_state.account_id` rather than requiring a caller `pda_seeds` to establish
/// the binding. The `pre_state` must have `is_authorized == false`.
seed: Option<(PdaSeed, ProgramId)>,
},
}

View File

@ -461,6 +461,7 @@ mod tests {
npk,
ssk: shared_secret,
identifier,
seed: None,
}],
&program.clone().into(),
)
@ -488,7 +489,7 @@ mod tests {
let seed = PdaSeed::new([42; 32]);
let shared_secret_pda = SharedSecretKey::new([55; 32], &keys.vpk());
// PDA (new, mask 3)
// PDA (new, private PDA)
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, 0);
let pda_pre = AccountWithMetadata::new(Account::default(), false, pda_id);
@ -506,6 +507,7 @@ mod tests {
npk,
ssk: shared_secret_pda,
identifier: 0,
seed: None,
}],
&program_with_deps,
);
@ -557,6 +559,7 @@ mod tests {
npk,
ssk: shared_secret_pda,
identifier: 0,
seed: None,
},
InputAccountIdentity::Public,
],
@ -747,7 +750,7 @@ mod tests {
/// 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 program = Program::pda_spend_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let npk = keys.npk();
@ -784,6 +787,7 @@ mod tests {
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier,
seed: None,
},
InputAccountIdentity::Public,
],
@ -819,6 +823,7 @@ mod tests {
npk,
ssk: shared_secret,
identifier: 99,
seed: None,
}],
&program.into(),
);
@ -828,7 +833,7 @@ mod tests {
#[test]
fn private_pda_update_identifier_mismatch_fails() {
let program = Program::pda_fund_spend_proxy();
let program = Program::pda_spend_proxy();
let auth_transfer = Program::authenticated_transfer_program();
let keys = test_private_account_keys_1();
let npk = keys.npk();
@ -862,6 +867,7 @@ mod tests {
nsk: keys.nsk,
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
identifier: 99,
seed: None,
},
InputAccountIdentity::Public,
],

View File

@ -350,12 +350,12 @@ 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};
pub fn pda_spend_proxy() -> Self {
use test_program_methods::{PDA_SPEND_PROXY_ELF, PDA_SPEND_PROXY_ID};
Self {
id: PDA_FUND_SPEND_PROXY_ID,
elf: PDA_FUND_SPEND_PROXY_ELF.to_vec(),
id: PDA_SPEND_PROXY_ID,
elf: PDA_SPEND_PROXY_ELF.to_vec(),
}
}

View File

@ -2218,7 +2218,7 @@ pub mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// A mask-3 account that no program claims via `Claim::Pda` and no caller authorizes via
/// A private PDA account that no program claims via `Claim::Pda` and no caller authorizes via
/// `ChainedCall.pda_seeds` has no binding between its supplied npk and its `account_id`,
/// so the circuit must reject. Here `simple_balance_transfer` emits no claim for the
/// second account, leaving position 1 unbound.
@ -2249,6 +2249,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
seed: None,
},
],
&program.into(),
@ -2257,7 +2258,7 @@ pub mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Happy path: a program claims a new mask-3 account via `Claim::Pda(seed)`. The circuit
/// Happy path: a program claims a new private PDA via `Claim::Pda(seed)`. The circuit
/// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s
/// position, derives `AccountId` via `AccountId::for_private_pda(program_id, seed, npk)`, and
/// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim
@ -2280,11 +2281,12 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
seed: None,
}],
&program.into(),
);
let (output, _proof) = result.expect("mask-3 private PDA claim should succeed");
let (output, _proof) = result.expect("private PDA claim should succeed");
assert_eq!(output.new_nullifiers.len(), 1);
assert_eq!(output.new_commitments.len(), 1);
assert_eq!(output.ciphertexts.len(), 1);
@ -2319,6 +2321,7 @@ pub mod tests {
npk: npk_b,
ssk: shared_secret,
identifier: u128::MAX,
seed: None,
}],
&program.into(),
);
@ -2326,7 +2329,7 @@ pub mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Happy path for the caller-seeds authorization of a mask-3 PDA. The delegator claims a
/// Happy path for the caller-seeds authorization of a private PDA. The delegator claims a
/// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same
/// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state`'s authorization
/// is established via the private derivation
@ -2354,12 +2357,13 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
seed: None,
}],
&program_with_deps,
);
let (output, _proof) =
result.expect("caller-seeds authorization of mask-3 private PDA should succeed");
result.expect("caller-seeds authorization of private PDA should succeed");
assert_eq!(output.new_commitments.len(), 1);
assert_eq!(output.new_nullifiers.len(), 1);
}
@ -2392,6 +2396,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
seed: None,
}],
&program_with_deps,
);
@ -2401,8 +2406,8 @@ pub mod tests {
/// Exploit-scenario pin. A single `(program_id, seed)` pair can derive a family of
/// `AccountId`s, one public PDA and one private PDA per distinct npk. Without the tx-wide
/// family-binding check, a program could claim `PDA_alice` (mask-3, `alice_npk`) and
/// `PDA_bob` (mask-3, `bob_npk`) under the same seed in one transaction, and once reuse
/// family-binding check, a program could claim `PDA_alice` (`alice_npk`) and
/// `PDA_bob` (`bob_npk`) under the same seed in one transaction, and once reuse
/// is supported a later chained call could delegate both to a callee via
/// `pda_seeds: [S]` and mix balances across them. The binding check rejects the setup
/// here: after the first claim records `(program, seed) → PDA_alice`, the second claim
@ -2430,11 +2435,13 @@ pub mod tests {
npk: keys_a.npk(),
ssk: shared_a,
identifier: u128::MAX,
seed: None,
},
InputAccountIdentity::PrivatePdaInit {
npk: keys_b.npk(),
ssk: shared_b,
identifier: u128::MAX,
seed: None,
},
],
&program.into(),
@ -2443,17 +2450,11 @@ pub mod tests {
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
}
/// Pins the current limitation: a mask-3 PDA that was claimed in a previous transaction
/// cannot be re-used in a new transaction as-is. This PR only binds supplied npks via a
/// fresh `Claim::Pda` or a caller's `ChainedCall.pda_seeds`, neither is present when a
/// program operates on an already-owned private PDA at top level. The reject site is the
/// post-loop `private_pda_bound_positions` assertion in
/// `privacy_preserving_circuit.rs`: `noop` emits no `Claim::Pda` and there is no caller
/// A private PDA that is reused at top level without an external seed in the identity still
/// fails binding. The noop program emits no `Claim::Pda` and there is no caller
/// `ChainedCall.pda_seeds`, so position 0 is never bound and the assertion fires.
// TODO: a follow-up PR in the Private PDAs series needs to let the wallet supply a
// `(seed, original_owner_program_id)` side input per mask-3 `pre_state` so the circuit
// can re-verify `AccountId::for_private_pda(owner, seed, npk) == pre.account_id` without a
// claim.
/// Supplying `seed: Some((seed, owner_program_id))` in the `PrivatePdaUpdate` identity is
/// the correct path for top-level reuse; this test pins the failure when no seed is provided.
#[test]
fn private_pda_top_level_reuse_rejected_by_binding_check() {
let program = Program::noop();
@ -2481,6 +2482,7 @@ pub mod tests {
npk,
ssk: shared_secret,
identifier: u128::MAX,
seed: None,
}],
&program.into(),
);
@ -4372,15 +4374,15 @@ pub mod tests {
let alice_keys = test_private_account_keys_1();
let alice_npk = alice_keys.npk();
let proxy = Program::pda_fund_spend_proxy();
let proxy = Program::pda_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 spend_with_deps =
ProgramWithDependencies::new(proxy, [(auth_transfer_id, auth_transfer.clone())].into());
let funder_id = funder_keys.account_id();
let alice_pda_0_id = AccountId::for_private_pda(&proxy_id, &seed, &alice_npk, 0);
@ -4406,7 +4408,7 @@ pub mod tests {
let alice_shared_0 = SharedSecretKey::new([10; 32], &alice_keys.vpk());
let alice_shared_1 = SharedSecretKey::new([11; 32], &alice_keys.vpk());
// Fund alice_pda_0
// Fund alice_pda_0 via authenticated_transfer directly.
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
@ -4415,16 +4417,18 @@ pub mod tests {
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(),
Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk: alice_npk,
ssk: alice_shared_0,
identifier: 0,
seed: Some((seed, proxy_id)),
},
],
&program_with_deps,
&auth_transfer.clone().into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
@ -4448,7 +4452,7 @@ pub mod tests {
.unwrap();
}
// Fund alice_pda_1
// Fund alice_pda_1 the same way with identifier 1.
{
let funder_account = state.get_account_by_id(funder_id);
let funder_nonce = funder_account.nonce;
@ -4457,16 +4461,18 @@ pub mod tests {
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(),
Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaInit {
npk: alice_npk,
ssk: alice_shared_1,
identifier: 1,
seed: Some((seed, proxy_id)),
},
],
&program_with_deps,
&auth_transfer.into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
@ -4504,7 +4510,7 @@ pub mod tests {
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(),
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk: alice_shared_0,
@ -4513,10 +4519,11 @@ pub mod tests {
.get_proof_for_commitment(&commitment_pda_0)
.expect("pda_0 must be in state"),
identifier: 0,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
&spend_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
@ -4545,10 +4552,10 @@ pub mod tests {
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(alice_pda_1_account.clone(), true, alice_pda_1_id),
AccountWithMetadata::new(recipient_account, false, recipient_id),
],
Program::serialize_instruction((seed, amount, auth_transfer_id, false)).unwrap(),
Program::serialize_instruction((seed, amount, auth_transfer_id)).unwrap(),
vec![
InputAccountIdentity::PrivatePdaUpdate {
ssk: alice_shared_1,
@ -4557,10 +4564,11 @@ pub mod tests {
.get_proof_for_commitment(&commitment_pda_1)
.expect("pda_1 must be in state"),
identifier: 1,
seed: None,
},
InputAccountIdentity::Public,
],
&program_with_deps,
&spend_with_deps,
)
.unwrap();
let message = Message::try_from_circuit_output(
@ -4585,5 +4593,70 @@ pub mod tests {
}
assert_eq!(state.get_account_by_id(recipient_id).balance, 2 * amount);
// Re-fund alice_pda_1 top-level via auth_transfer using PrivatePdaUpdate with an
// external seed.
let alice_pda_1_account_after_spend = Account {
program_owner: auth_transfer_id,
balance: 0,
nonce: alice_pda_1_account
.nonce
.private_account_nonce_increment(&alice_keys.nsk),
..Account::default()
};
let commitment_pda_1_after_spend =
Commitment::new(&alice_pda_1_id, &alice_pda_1_account_after_spend);
let alice_shared_1_refund = SharedSecretKey::new([12; 32], &alice_keys.vpk());
{
let recipient_account = state.get_account_by_id(recipient_id);
let recipient_nonce = recipient_account.nonce;
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(recipient_account, true, recipient_id),
AccountWithMetadata::new(
alice_pda_1_account_after_spend,
false,
alice_pda_1_id,
),
],
Program::serialize_instruction(AuthTransferInstruction::Transfer { amount })
.unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::PrivatePdaUpdate {
nsk: alice_keys.nsk,
ssk: alice_shared_1_refund,
membership_proof: state
.get_proof_for_commitment(&commitment_pda_1_after_spend)
.expect("pda_1 after spend must be in state"),
identifier: 1,
seed: Some((seed, proxy_id)),
},
],
&Program::authenticated_transfer_program().into(),
)
.unwrap();
let message = Message::try_from_circuit_output(
vec![recipient_id],
vec![recipient_nonce],
vec![(
alice_npk,
alice_keys.vpk(),
EphemeralPublicKey::from_scalar([12; 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),
5,
0,
)
.unwrap();
}
assert_eq!(state.get_account_by_id(recipient_id).balance, amount);
}
}

View File

@ -305,6 +305,68 @@ impl ExecutionState {
}
Entry::Vacant(_) => {
// Pre state for the initial call
let pre_state_position = self.pre_states.len();
let external_seed = match account_identities.get(pre_state_position) {
Some(InputAccountIdentity::PrivatePdaInit {
npk,
identifier,
seed: Some((seed, authority_program_id)),
..
}) => {
let expected = AccountId::for_private_pda(
authority_program_id,
seed,
npk,
*identifier,
);
assert_eq!(
pre_account_id, expected,
"External seed mismatch for PrivatePdaInit at position {pre_state_position}"
);
Some((*seed, *authority_program_id))
}
Some(InputAccountIdentity::PrivatePdaUpdate {
nsk,
identifier,
seed: Some((seed, authority_program_id)),
..
}) => {
let npk = NullifierPublicKey::from(nsk);
let expected = AccountId::for_private_pda(
authority_program_id,
seed,
&npk,
*identifier,
);
assert_eq!(
pre_account_id, expected,
"External seed mismatch for PrivatePdaUpdate at position {pre_state_position}"
);
Some((*seed, *authority_program_id))
}
_ => None,
};
// External seed is only consulted the first time the account is seen.
// Subsequent calls need no re-check because the entry is already recorded on
// private_pda_bound_positions.
if let Some((seed, authority_program_id)) = external_seed {
assert!(
!pre.is_authorized,
"Private PDA with externally-provided seed must not be authorized at position {pre_state_position}"
);
bind_private_pda_position(
&mut self.private_pda_bound_positions,
pre_state_position,
authority_program_id,
seed,
);
assert_family_binding(
&mut self.pda_family_binding,
authority_program_id,
seed,
pre_account_id,
);
}
self.pre_states.push(pre);
}
}
@ -348,14 +410,11 @@ impl ExecutionState {
);
}
}
} else if account_identity.is_private_pda() {
} else {
// Private accounts: don't enforce the claim semantics. Unauthorized private
// claiming is intentionally allowed
match claim {
Claim::Authorized => {
assert!(
pre_is_authorized,
"Cannot claim unauthorized private PDA {pre_account_id}"
);
}
Claim::Authorized => {}
Claim::Pda(seed) => {
let (npk, identifier) = self
.private_pda_npk_by_position
@ -383,10 +442,6 @@ impl ExecutionState {
);
}
}
} else {
// Standalone private accounts: don't enforce the claim semantics.
// Unauthorized private claiming is intentionally allowed since operating
// these accounts requires the npk/nsk keypair anyway.
}
post.account_mut().program_owner = program_id;

View File

@ -148,6 +148,7 @@ pub fn compute_circuit_output(
npk: _,
ssk,
identifier,
seed: _,
} => {
// The npk-to-account_id binding is established upstream in
// `validate_and_sync_states` via `Claim::Pda(seed)` or a caller `pda_seeds`
@ -172,7 +173,7 @@ pub 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
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaInit position must be in pda_seed_by_position");
emit_private_output(
@ -181,7 +182,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Pda {
program_id: *pda_program_id,
program_id: *authority_program_id,
seed: *seed,
identifier: *identifier,
},
@ -195,14 +196,16 @@ pub fn compute_circuit_output(
nsk,
membership_proof,
identifier,
seed: external_seed,
} => {
// The npk binding is established upstream. Authorization must already be set;
// an unauthorized PrivatePdaUpdate would mean the prover supplied an nsk for an
// unbound PDA, which the upstream binding check would have rejected anyway,
// but we assert here to fail fast and document the precondition.
// With an external seed the binding comes from the circuit input and the
// pre_state is intentionally unauthorized; without one the binding comes from
// a Claim or caller pda_seeds, so the pre_state must already be authorized.
// When `external_seed` is `Some`, execution_state already asserted
// `!pre_state.is_authorized`.
assert!(
pre_state.is_authorized,
"PrivatePdaUpdate requires authorized pre_state"
pre_state.is_authorized ^ external_seed.is_some(),
"PrivatePdaUpdate requires authorized pre_state or external seed"
);
let new_nullifier = compute_update_nullifier_and_set_digest(
@ -214,7 +217,7 @@ pub 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
let (authority_program_id, seed) = pda_seed_by_position
.get(&pos)
.expect("PrivatePdaUpdate position must be in pda_seed_by_position");
emit_private_output(
@ -223,7 +226,7 @@ pub fn compute_circuit_output(
post_state,
&account_id,
&PrivateAccountKind::Pda {
program_id: *pda_program_id,
program_id: *authority_program_id,
seed: *seed,
identifier: *identifier,
},

View File

@ -34,7 +34,7 @@ pub mod setup;
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";
pub const NSSA_PROGRAM_FOR_TEST_PDA_SPEND_PROXY: &str = "pda_spend_proxy.bin";
pub(crate) const BEDROCK_SERVICE_WITH_OPEN_PORT: &str = "logos-blockchain-node-0";
pub(crate) const BEDROCK_SERVICE_PORT: u16 = 18080;

View File

@ -1,71 +0,0 @@
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(&authenticated_transfer_core::Instruction::Transfer { 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

@ -0,0 +1,50 @@
use nssa_core::program::{
AccountPostState, ChainedCall, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
read_nssa_inputs,
};
use risc0_zkvm::serde::to_vec;
/// Proxy for spending from a private PDA via `auth_transfer`.
///
/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient.
/// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `auth_transfer`.
type Instruction = (PdaSeed, u128, ProgramId);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (seed, amount, auth_transfer_id),
},
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 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(&authenticated_transfer_core::Instruction::Transfer { amount })
.unwrap(),
pre_states: vec![first.clone(), second.clone()],
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

@ -15,7 +15,7 @@ use test_fixtures::{DiskSizes, TestContext};
use wallet::cli::SubcommandReturnValue;
const TX_INCLUSION_POLL_INTERVAL: Duration = Duration::from_millis(250);
const TX_INCLUSION_TIMEOUT: Duration = Duration::from_secs(120);
const TX_INCLUSION_TIMEOUT: Duration = Duration::from_mins(2);
/// Borsh-serialized sizes for one zone block fetched after a step. `block_bytes`
/// is the full Block (header + body + bedrock metadata) and is the closest

View File

@ -181,7 +181,7 @@ async fn measure_bedrock_finality(ctx: &TestContext) -> Result<Duration> {
.context("connect indexer WS")?;
let sequencer_tip = ctx.sequencer_client().get_last_block_id().await?;
let timeout = Duration::from_secs(60);
let timeout = Duration::from_mins(1);
let started = std::time::Instant::now();
let poll = async {
loop {

View File

@ -297,7 +297,7 @@ impl WalletCore {
.key_chain()
.group_key_holder(&entry.group_label)?;
if let (Some(pda_seed), Some(program_id)) = (entry.pda_seed, entry.pda_program_id) {
if let (Some(pda_seed), Some(program_id)) = (entry.pda_seed, entry.authority_program_id) {
let keys = holder.derive_keys_for_pda(&program_id, &pda_seed);
Some(PrivacyPreservingAccount::PrivatePdaShared {
account_id,
@ -340,7 +340,7 @@ impl WalletCore {
group_label: Label,
identifier: nssa_core::Identifier,
pda_seed: Option<nssa_core::program::PdaSeed>,
pda_program_id: Option<nssa_core::program::ProgramId>,
authority_program_id: Option<nssa_core::program::ProgramId>,
) {
self.storage.key_chain_mut().insert_shared_private_account(
account_id,
@ -348,7 +348,7 @@ impl WalletCore {
group_label,
identifier,
pda_seed,
pda_program_id,
authority_program_id,
account: Account::default(),
},
);
@ -729,7 +729,7 @@ impl WalletCore {
.key_chain()
.group_key_holder(&entry.group_label)?;
let keys = match (&entry.pda_seed, &entry.pda_program_id) {
let keys = match (&entry.pda_seed, &entry.authority_program_id) {
(Some(pda_seed), Some(program_id)) => {
holder.derive_keys_for_pda(program_id, pda_seed)
}

View File

@ -252,11 +252,13 @@ impl AccountManager {
nsk,
membership_proof,
identifier: pre.identifier,
seed: None,
},
_ => InputAccountIdentity::PrivatePdaInit {
npk: pre.npk,
ssk: pre.ssk,
identifier: pre.identifier,
seed: None,
},
},
State::Private(pre) => match (pre.nsk, pre.proof.clone()) {

View File

@ -55,7 +55,7 @@ pub struct SharedAccountEntry {
/// For PDA accounts, the seed and program ID used to derive keys via `derive_keys_for_pda`.
/// `None` for regular shared accounts (keys derived from identifier via derivation seed).
pub pda_seed: Option<nssa_core::program::PdaSeed>,
pub pda_program_id: Option<nssa_core::program::ProgramId>,
pub authority_program_id: Option<nssa_core::program::ProgramId>,
pub account: Account,
}
@ -858,7 +858,7 @@ mod tests {
group_label: Label::new("test-group"),
identifier: 42,
pda_seed: None,
pda_program_id: None,
authority_program_id: None,
account: nssa_core::account::Account::default(),
};
let encoded = bincode::serialize(&entry).expect("serialize");
@ -871,7 +871,7 @@ mod tests {
group_label: Label::new("pda-group"),
identifier: u128::MAX,
pda_seed: Some(PdaSeed::new([7_u8; 32])),
pda_program_id: Some([9; 8]),
authority_program_id: Some([9; 8]),
account: nssa_core::account::Account::default(),
};
let pda_encoded = bincode::serialize(&pda_entry).expect("serialize pda");
@ -890,7 +890,7 @@ mod tests {
group_label: Label::new("old"),
identifier: 1,
pda_seed: None,
pda_program_id: None,
authority_program_id: None,
account: nssa_core::account::Account::default(),
};
let encoded = bincode::serialize(&entry).expect("serialize");