Merge pull request #683 from logos-blockchain/artem/private-authorization-longevity

fix!(lee): scope private PDA authorization to the callee subtree
This commit is contained in:
Artem Gureev
2026-08-18 17:31:14 +04:00
committed by GitHub
36 changed files with 971 additions and 363 deletions
Generated
+12 -12
View File
@@ -2308,7 +2308,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
dependencies = [
"data-encoding",
"syn 2.0.117",
"syn 1.0.109",
]
[[package]]
@@ -2494,7 +2494,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -2862,7 +2862,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3487,9 +3487,9 @@ checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
[[package]]
name = "h2"
version = "0.4.14"
version = "0.4.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
dependencies = [
"atomic-waker",
"bytes",
@@ -3945,7 +3945,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.4",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -7145,7 +7145,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -8199,7 +8199,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2 0.6.4",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -8237,7 +8237,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.4",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
]
@@ -9195,7 +9195,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -10467,7 +10467,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -11926,7 +11926,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
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.
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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -8,7 +8,7 @@ use lee_core::{
account::{Account, AccountId, AccountWithMetadata},
encryption::ViewingPublicKey,
program::{
AccountPostState, BlockValidityWindow, ChainedCall, Claim, DEFAULT_PROGRAM_ID,
AccountPostState, BlockValidityWindow, CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_ID,
MAX_NUMBER_CHAINED_CALLS, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow,
validate_execution,
},
@@ -51,7 +51,9 @@ pub struct ExecutionState {
/// `AccountId::for_private_pda(program_id, seed, npk, vpk, identifier) ==
/// pre_state.account_id`.
private_pda_by_position: HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
authorized_accounts: HashSet<AccountId>,
/// The set containing non-PDA accounts authorized at their first sight, anywhere in the
/// call tree, remaining authorized throughout all calls.
globally_authorized: HashSet<AccountId>,
}
impl ExecutionState {
@@ -112,7 +114,7 @@ impl ExecutionState {
private_pda_bound_positions: HashMap::new(),
pda_family_binding: HashMap::new(),
private_pda_by_position,
authorized_accounts: HashSet::new(),
globally_authorized: HashSet::new(),
};
let Some(first_output) = program_outputs.first() else {
@@ -125,12 +127,17 @@ impl ExecutionState {
pre_states: first_output.pre_states.clone(),
pda_seeds: Vec::new(),
};
let mut chained_calls = VecDeque::from_iter([(initial_call, None)]);
let initial_caller_data = CallerData {
program_id: None,
authorized_accounts: HashSet::new(),
};
let mut chained_calls =
VecDeque::<(ChainedCall, CallerData)>::from_iter([(initial_call, initial_caller_data)]);
let mut program_outputs_iter = program_outputs.into_iter();
let mut chain_calls_counter = 0;
while let Some((chained_call, caller_program_id)) = chained_calls.pop_front() {
while let Some((chained_call, caller_data)) = chained_calls.pop_front() {
assert!(
chain_calls_counter <= MAX_NUMBER_CHAINED_CALLS,
"Max chained calls depth is exceeded"
@@ -166,7 +173,7 @@ impl ExecutionState {
// by spoofing caller_program_id (e.g. passing caller_program_id = self_program_id
// to bypass access control checks).
assert_eq!(
program_output.caller_program_id, caller_program_id,
program_output.caller_program_id, caller_data.program_id,
"Program output caller_program_id does not match actual caller"
);
@@ -184,18 +191,25 @@ impl ExecutionState {
);
}
for next_call in program_output.chained_calls.iter().rev() {
chained_calls.push_front((next_call.clone(), Some(chained_call.program_id)));
}
execution_state.validate_and_sync_states(
let authorized_accounts = execution_state.validate_and_sync_states(
account_identities,
chained_call.program_id,
caller_program_id,
caller_data,
&chained_call.pda_seeds,
program_output.pre_states,
program_output.post_states,
);
for next_call in program_output.chained_calls.into_iter().rev() {
// Push the call with newly-authorized account set.
chained_calls.push_front((
next_call,
CallerData {
program_id: Some(chained_call.program_id),
authorized_accounts: authorized_accounts.clone(),
},
));
}
chain_calls_counter = chain_calls_counter.checked_add(1).expect(
"Chain calls counter should not overflow as it checked before incrementing",
);
@@ -246,16 +260,20 @@ impl ExecutionState {
}
/// Validate program pre and post states and populate the execution state.
///
/// Return the set of authorized accounts as the result of the processed
/// call.
fn validate_and_sync_states(
&mut self,
account_identities: &[InputAccountIdentity],
program_id: ProgramId,
caller_program_id: Option<ProgramId>,
caller: CallerData,
caller_pda_seeds: &[PdaSeed],
output_pre_states: Vec<AccountWithMetadata>,
output_post_states: Vec<AccountPostState>,
) {
for (pre, mut post) in output_pre_states.into_iter().zip(output_post_states) {
) -> HashSet<AccountId> {
let mut authorized_output_accounts = Vec::new();
for (mut pre, mut post) in output_pre_states.into_iter().zip(output_post_states) {
let pre_account_id = pre.account_id;
let pre_is_authorized = pre.is_authorized;
let post_states_entry = self.post_states.entry(pre.account_id);
@@ -278,33 +296,26 @@ impl ExecutionState {
"Inconsistent pre state for account {pre_account_id}",
);
let (previous_is_authorized, pre_state_position) = self
let pre_state_position = self
.pre_states
.iter()
.enumerate()
.find(|(_, acc)| acc.account_id == pre_account_id)
.map_or_else(
|| panic!(
.position(|acc| acc.account_id == pre_account_id)
.unwrap_or_else(|| {
panic!(
"Pre state must exist in execution state for account {pre_account_id}",
),
|(pos, acc)| (acc.is_authorized, pos)
);
)
});
let is_authorized = resolve_authorization_and_record_bindings(
assert_authorization_and_record_bindings(
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_by_position,
&mut self.authorized_accounts,
&self.globally_authorized,
&caller,
caller_pda_seeds,
pre_account_id,
pre_state_position,
caller_program_id,
caller_pda_seeds,
previous_is_authorized,
);
assert_eq!(
pre_is_authorized, is_authorized,
"Inconsistent authorization for account {pre_account_id}",
pre_is_authorized,
);
}
Entry::Vacant(_) => {
@@ -340,10 +351,6 @@ impl ExecutionState {
// Subsequent calls need no re-check because the entry is already recorded on
// private_pda_bound_positions.
if let Some((authority_program_id, seed)) = 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,
@@ -357,10 +364,50 @@ impl ExecutionState {
pre_account_id,
);
}
let has_private_pda_witness = self
.private_pda_by_position
.contains_key(&pre_state_position);
if has_private_pda_witness {
assert_authorization_and_record_bindings(
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_by_position,
&self.globally_authorized,
&caller,
caller_pda_seeds,
pre_account_id,
pre_state_position,
pre_is_authorized,
);
}
if !has_private_pda_witness
&& authorize_first_sight_without_pda_witness(
&mut self.pda_family_binding,
&mut self.globally_authorized,
&caller,
caller_pda_seeds,
pre_account_id,
pre_is_authorized,
)
{
// authorize_first_sight_without_pda_witness is only true for PDAs
// which will be recorded in output journal.
//
// Since we are in a privacy circuit, the verifier cannot
// replay the transaction to see which public PDAs were
// actually authorized. We mark them false as the
// verifier checks regular account signatures as well.
pre.is_authorized = false;
}
self.pre_states.push(pre);
}
}
// If an account it authorized, push it to the autorized set.
if pre_is_authorized {
authorized_output_accounts.push(pre_account_id);
}
if let Some(claim) = post.required_claim() {
// The invoked program can only claim accounts with default program id.
assert_eq!(
@@ -444,6 +491,10 @@ impl ExecutionState {
post_states_entry.insert_entry(post.into_account());
}
let mut authorized_accounts = caller.authorized_accounts;
authorized_accounts.extend(authorized_output_accounts);
authorized_accounts
}
/// Consume self and yield the validity windows, the per-position PDA seed/program map
@@ -527,66 +578,127 @@ fn bind_private_pda_position(
}
}
/// Resolve the authorization state of a `pre_state` seen again in a chained call and record
/// any resulting bindings. Returns `true` if the `pre_state` is authorized through either a
/// previously-seen authorization or a matching caller seed (under the public or private
/// derivation). When a caller seed matches, also records the `(caller, seed) → account_id`
/// family binding and, for the private form, marks the position in
/// `private_pda_bound_positions`. Only reachable when `caller_program_id.is_some()`,
/// top-level flows have no caller-emitted seeds, so binding at top level must come from the
/// claim path. Free function so callers can pass individual `&mut self.*` field borrows
/// without holding a borrow on the surrounding struct's other fields.
/// Match `account_id` against the caller's seeds under the public-PDA derivation. `None`
/// if no appropriate authorization given.
fn match_caller_seed_as_public_pda(
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
account_id: AccountId,
) -> Option<(PdaSeed, ProgramId)> {
let caller_program_id = caller.program_id?;
// Costy for calls with multiple seeds in one call.
caller_pda_seeds.iter().find_map(|seed| {
if AccountId::for_public_pda(&caller_program_id, seed) == account_id {
return Some((*seed, caller_program_id));
}
None
})
}
/// Match `account_id` against the caller's seeds interpreted as private-PDA derivations, using the
/// (npk, vpk, identifier) supplied for this position. `None` when the position carries no
/// private-PDA witness.
fn match_caller_seed_as_private_pda(
private_pda_by_position: &HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
account_id: AccountId,
pre_state_position: usize,
) -> Option<(PdaSeed, ProgramId)> {
let (npk, vpk, identifier) = private_pda_by_position.get(&pre_state_position)?;
let caller_program_id = caller.program_id?;
// Costy for calls with multiple seeds in one call.
caller_pda_seeds.iter().find_map(|seed| {
if AccountId::for_private_pda(&caller_program_id, seed, npk, vpk, *identifier) == account_id
{
return Some((*seed, caller_program_id));
}
None
})
}
/// Judge a non-private-PDA `pre_state` at its first sighting and resolve its journal mask.
///
/// Either the account is a public PDA in which case the public mask should be changed, or
/// it is a regular account. For PDAs, we assert the family bindings. For regular accounts,
/// add to global authorization set.
fn authorize_first_sight_without_pda_witness(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
globally_authorized: &mut HashSet<AccountId>,
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
pre_account_id: AccountId,
pre_is_authorized: bool,
) -> bool {
if let Some((seed, caller_program_id)) =
match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id)
{
assert!(
pre_is_authorized,
"Caller-seeded public PDA must be declared authorized at first sight: {pre_account_id}"
);
assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id);
true
} else {
// If an authorized account is a non-PDA one, it is globally authorized.
if pre_is_authorized {
globally_authorized.insert(pre_account_id);
}
false
}
}
/// When a caller seed matches, also records the `(caller, seed) → account_id` family binding
/// and, for the private form, marks the position in `private_pda_bound_positions`. Free
/// function so callers can pass individual `&mut self.*` field borrows without holding a borrow
/// on the surrounding struct's other fields.
#[expect(
clippy::too_many_arguments,
reason = "breaking out a context struct does not buy us anything here"
)]
fn resolve_authorization_and_record_bindings(
fn assert_authorization_and_record_bindings(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
private_pda_bound_positions: &mut HashMap<usize, (ProgramId, PdaSeed)>,
private_pda_by_position: &HashMap<usize, (NullifierPublicKey, ViewingPublicKey, Identifier)>,
authorized_accounts: &mut HashSet<AccountId>,
globally_authorized: &HashSet<AccountId>,
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
pre_account_id: AccountId,
pre_state_position: usize,
caller_program_id: Option<ProgramId>,
caller_pda_seeds: &[PdaSeed],
previous_is_authorized: bool,
) -> bool {
pre_is_authorized: bool,
) {
let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> =
caller_program_id.and_then(|caller| {
caller_pda_seeds.iter().find_map(|seed| {
if AccountId::for_public_pda(&caller, seed) == pre_account_id {
return Some((*seed, false, caller));
}
if let Some((npk, vpk, identifier)) =
private_pda_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier)
== pre_account_id
{
return Some((*seed, true, caller));
}
None
})
});
match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id)
.map(|(seed, caller_program_id)| (seed, false, caller_program_id))
.or_else(|| {
match_caller_seed_as_private_pda(
private_pda_by_position,
caller,
caller_pda_seeds,
pre_account_id,
pre_state_position,
)
.map(|(seed, caller_program_id)| (seed, true, caller_program_id))
});
if let Some((seed, is_private_form, caller)) = matched_caller_seed {
assert_family_binding(pda_family_binding, caller, seed, pre_account_id);
if let Some((seed, is_private_form, caller_program_id)) = matched_caller_seed {
assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id);
if is_private_form {
bind_private_pda_position(
private_pda_bound_positions,
pre_state_position,
caller,
caller_program_id,
seed,
);
}
}
if authorized_accounts.contains(&pre_account_id) {
return true;
}
let is_authorized = matched_caller_seed.is_some()
|| globally_authorized.contains(&pre_account_id)
|| caller.authorized_accounts.contains(&pre_account_id);
let authorized = previous_is_authorized || matched_caller_seed.is_some();
if authorized {
authorized_accounts.insert(pre_account_id);
}
authorized
assert_eq!(
pre_is_authorized, is_authorized,
"Inconsistent authorization for account {pre_account_id}",
);
}
+20 -38
View File
@@ -66,46 +66,28 @@ pub fn compute_circuit_output(
WitnessKind::Pda { .. } => pre_state.account_id,
};
match (kind, nullifier) {
(
WitnessKind::Regular { ask },
NullifierWitness::Init { .. } | NullifierWitness::Update { .. },
) => {
if let Some(ask) = ask {
let derived = NullifierSecretKey::from(ask);
match nullifier {
// Check that the authorization key is actually bound to the
// account Id.
NullifierWitness::Update { nsk, .. } => assert_eq!(
derived, *nsk,
"Authorization secret key does not derive this account's nullifier secret key"
),
NullifierWitness::Init { npk, .. } => assert_eq!(
NullifierPublicKey::from(&derived),
*npk,
"Authorization secret key does not derive this account's nullifier public key"
),
}
if let WitnessKind::Regular { ask } = kind {
if let Some(ask) = ask {
let derived = NullifierSecretKey::from(ask);
match nullifier {
// Check that the authorization key is actually bound to the
// account Id.
NullifierWitness::Update { nsk, .. } => assert_eq!(
derived, *nsk,
"Authorization secret key does not derive this account's nullifier secret key"
),
NullifierWitness::Init { npk, .. } => assert_eq!(
NullifierPublicKey::from(&derived),
*npk,
"Authorization secret key does not derive this account's nullifier public key"
),
}
assert_eq!(
pre_state.is_authorized,
ask.is_some(),
"Regular private account authorization must match the supplied credential"
);
}
(WitnessKind::Pda { .. }, NullifierWitness::Init { .. }) => assert!(
!pre_state.is_authorized,
"Private PDA init requires unauthorized pre_state"
),
// 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 `binding` is `Some`, execution_state already asserted
// `!pre_state.is_authorized`.
(WitnessKind::Pda { binding }, NullifierWitness::Update { .. }) => assert!(
pre_state.is_authorized ^ binding.is_some(),
"Private PDA update requires authorized pre_state or external seed"
),
assert_eq!(
pre_state.is_authorized,
ask.is_some(),
"Regular private account authorization must match the supplied credential"
);
}
let (new_nullifier, new_nonce, view_tag) = match nullifier {
+1 -4
View File
@@ -54,15 +54,12 @@ pub enum WitnessKind {
/// Private PDA. The npk-to-account_id binding is proven upstream 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.
/// An init is unauthorized; on an update, authorization may be established upstream by a
/// caller `pda_seeds` match or a previously-seen authorization in a chained call.
Pda {
/// When `Some((authority_program_id, seed))`, the circuit binds this position via the
/// external derivation check
/// `AccountId::for_private_pda(authority_program_id, seed, npk, vpk, 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`.
/// `pda_seeds` to establish the binding.
binding: Option<(ProgramId, PdaSeed)>,
},
}
@@ -198,6 +198,12 @@ impl AccountId {
}
}
#[derive(Debug)]
pub struct CallerData {
pub program_id: Option<ProgramId>,
pub authorized_accounts: HashSet<AccountId>,
}
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
pub struct ChainedCall {
/// The program ID of the program to execute.
+24
View File
@@ -135,6 +135,30 @@ mod test_methods {
)
}
#[must_use]
pub const fn selective_pda_delegator() -> Program {
Program::new_unchecked(
test_methods::SELECTIVE_PDA_DELEGATOR_ID,
Cow::Borrowed(test_methods::SELECTIVE_PDA_DELEGATOR_ELF),
)
}
#[must_use]
pub const fn undeclaring_pda_delegator() -> Program {
Program::new_unchecked(
test_methods::UNDECLARING_PDA_DELEGATOR_ID,
Cow::Borrowed(test_methods::UNDECLARING_PDA_DELEGATOR_ELF),
)
}
#[must_use]
pub const fn non_delegating_forwarder() -> Program {
Program::new_unchecked(
test_methods::NON_DELEGATING_FORWARDER_ID,
Cow::Borrowed(test_methods::NON_DELEGATING_FORWARDER_ELF),
)
}
#[must_use]
pub const fn pda_claimer() -> Program {
Program::new_unchecked(
@@ -15,7 +15,7 @@ use crate::{
program::Program,
state::{
CommitmentSet,
tests::{test_private_account_keys_1, test_private_account_keys_2},
tests::{init_pda_witness, test_private_account_keys_1, test_private_account_keys_2},
},
};
@@ -473,16 +473,7 @@ fn private_pda_init() {
let result = execute_and_prove(
vec![pda_pre],
instruction,
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
vec![init_pda_witness(&keys, 0, None)],
&program_with_deps,
);
@@ -527,16 +518,7 @@ fn private_pda_withdraw() {
vec![pda_pre, recipient_pre],
instruction,
vec![
InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
init_pda_witness(&keys, 0, None),
InputAccountIdentity::Public,
],
&program_with_deps,
@@ -947,24 +929,25 @@ fn auth_asserting_program_rejects_unauthorized_regular_private_account() {
assert!(matches!(result, Err(LeeError::ProgramProveFailed(_))));
}
/// A private-PDA update 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() {
/// Root-call private-PDA update attempt: `pda_spend_proxy` spends a PDA it owns via
/// `simple_balance_transfer`.
fn pda_update_attempt(
declare_authorized: bool,
derivation_identifier: u128,
witness_identifier: u128,
) -> Result<lee_core::PrivacyPreservingCircuitOutput, LeeError> {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier);
let esk = EphemeralSecretKey::new(
&pda_id,
&[0; 32],
&Nonce::default().private_account_nonce_increment(&keys.nsk()),
let pda_id = AccountId::for_private_pda(
&program.id(),
&seed,
&keys.npk(),
&keys.vpk(),
derivation_identifier,
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
let pda_account = Account {
program_owner: simple_transfer_id,
balance: 1,
@@ -974,22 +957,20 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
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 pda_pre = AccountWithMetadata::new(pda_account, declare_authorized, pda_id);
let recipient_pre = AccountWithMetadata::new(Account::default(), true, AccountId::new([0; 32]));
let program_with_deps = ProgramWithDependencies::new(
program.clone(),
[(simple_transfer_id, simple_transfer)].into(),
);
let program_with_deps =
ProgramWithDependencies::new(program, [(simple_transfer_id, simple_transfer)].into());
let (output, _) = execute_and_prove(
execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(),
Program::serialize_instruction((seed, 1_u128, simple_transfer_id)).unwrap(),
vec![
InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
identifier: witness_identifier,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Update {
view_tag: 0,
@@ -1001,18 +982,46 @@ fn private_pda_update_encrypts_pda_kind_with_identifier() {
],
&program_with_deps,
)
.unwrap();
.map(|(output, _proof)| output)
}
/// A private-PDA update 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_id = crate::test_methods::pda_spend_proxy().id();
let keys = test_private_account_keys_1();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 99;
let output = pda_update_attempt(false, identifier, identifier)
.expect("a well-formed private PDA update must prove");
let pda_id =
AccountId::for_private_pda(&program_id, &seed, &keys.npk(), &keys.vpk(), identifier);
let esk = EphemeralSecretKey::new(
&pda_id,
&[0; 32],
&Nonce::default().private_account_nonce_increment(&keys.nsk()),
);
let ssk = SharedSecretKey::encapsulate_deterministic(&keys.vpk(), &esk).0;
assert_eq!(
decrypt_kind(&output, &ssk, 0),
PrivateAccountKind::Pda {
program_id: program.id(),
program_id,
seed,
identifier
},
);
}
#[test]
fn private_pda_update_at_root_call_may_not_declare_authorization() {
let result = pda_update_attempt(true, 99, 99);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_init_identifier_mismatch_fails() {
let program = crate::test_methods::pda_claimer();
@@ -1020,6 +1029,27 @@ fn private_pda_init_identifier_mismatch_fails() {
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 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![init_pda_witness(&keys, 99, None)],
&program.into(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn private_pda_init_at_root_call_may_not_declare_authorization() {
let program = crate::test_methods::pda_claimer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let identifier: u128 = 5;
let account_id =
AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), identifier);
let pre_state = AccountWithMetadata::new(Account::default(), true, account_id);
let result = execute_and_prove(
@@ -1028,7 +1058,7 @@ fn private_pda_init_identifier_mismatch_fails() {
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 99,
identifier,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
@@ -1043,47 +1073,7 @@ fn private_pda_init_identifier_mismatch_fails() {
#[test]
fn private_pda_update_identifier_mismatch_fails() {
let program = crate::test_methods::pda_spend_proxy();
let simple_transfer = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([42; 32]);
let simple_transfer_id = simple_transfer.id();
let pda_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), 5);
let pda_account = Account {
program_owner: simple_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, [(simple_transfer_id, simple_transfer)].into());
let result = execute_and_prove(
vec![pda_pre, recipient_pre],
Program::serialize_instruction((seed, 1_u128, simple_transfer_id, false)).unwrap(),
vec![
InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: 99,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Update {
view_tag: 0,
nsk: keys.nsk(),
membership_proof: commitment_set.get_proof_for(&pda_commitment).unwrap(),
},
}),
InputAccountIdentity::Public,
],
&program_with_deps,
);
let result = pda_update_attempt(false, 5, 99);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
+411 -111
View File
@@ -399,7 +399,6 @@ fn circuit_should_fail_if_new_private_account_is_provided_with_default_values_bu
fn private_pda_without_binding_fails() {
let program = crate::test_methods::simple_balance_transfer();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let public_account_1 = AccountWithMetadata::new(
Account {
program_owner: program.id(),
@@ -417,16 +416,7 @@ fn private_pda_without_binding_fails() {
Program::serialize_instruction(10_u128).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
init_pda_witness(&keys, u128::MAX, None),
],
&program.into(),
);
@@ -452,16 +442,7 @@ fn private_pda_claim_succeeds() {
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
vec![init_pda_witness(&keys, u128::MAX, None)],
&program.into(),
);
@@ -480,7 +461,6 @@ fn private_pda_npk_mismatch_fails() {
let keys_a = test_private_account_keys_1();
let keys_b = test_private_account_keys_2();
let npk_a = keys_a.npk();
let npk_b = keys_b.npk();
let seed = PdaSeed::new([42; 32]);
// `account_id` is derived from `npk_a`, but `npk_b` is supplied for this pre_state.
@@ -493,16 +473,7 @@ fn private_pda_npk_mismatch_fails() {
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction(seed).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys_b.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk: npk_b,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
vec![init_pda_witness(&keys_b, u128::MAX, None)],
&program.into(),
);
@@ -532,16 +503,7 @@ fn caller_pda_seeds_authorize_private_pda_for_callee() {
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction((seed, seed, callee_id)).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
vec![init_pda_witness(&keys, u128::MAX, None)],
&program_with_deps,
);
@@ -573,22 +535,411 @@ fn caller_pda_seeds_with_wrong_seed_rejects_private_pda_for_callee() {
let result = execute_and_prove(
vec![pre_state],
Program::serialize_instruction((claim_seed, wrong_delegated_seed, callee_id)).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
vec![init_pda_witness(&keys, u128::MAX, None)],
&program_with_deps,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
fn sibling_declaring_delegated_pda(pda_is_authorized: bool) -> Result<(), LeeError> {
let delegator = crate::test_methods::selective_pda_delegator();
let callee = crate::test_methods::auth_asserting_noop();
let sibling = crate::test_methods::noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), 0);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let callee_id = callee.id();
let sibling_id = sibling.id();
let program_with_deps = ProgramWithDependencies::new(
delegator,
[(callee_id, callee), (sibling_id, sibling)].into(),
);
execute_and_prove(
vec![pre_state],
Program::serialize_instruction((
seed,
seed,
callee_id,
Program::serialize_instruction(()).unwrap(),
Some((sibling_id, Some(pda_is_authorized))),
))
.unwrap(),
vec![init_pda_witness(&keys, 0, None)],
&program_with_deps,
)
.map(|_| ())
}
#[test]
fn delegated_pda_is_not_authorized_in_sibling_call() {
let result = sibling_declaring_delegated_pda(true);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn sibling_call_may_declare_delegated_pda_unauthorized() {
sibling_declaring_delegated_pda(false)
.expect("a sibling declaring the delegated PDA unauthorized must be accepted");
}
#[test]
fn delegated_pda_stays_authorized_in_delegated_subtree() {
let delegator = crate::test_methods::selective_pda_delegator();
let forwarder = crate::test_methods::non_delegating_forwarder();
let callee = crate::test_methods::auth_asserting_noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), 0);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let forwarder_id = forwarder.id();
let callee_id = callee.id();
let program_with_deps = ProgramWithDependencies::new(
delegator,
[(forwarder_id, forwarder), (callee_id, callee)].into(),
);
let no_sibling: Option<(ProgramId, Option<bool>)> = None;
execute_and_prove(
vec![pre_state],
Program::serialize_instruction((
seed,
seed,
forwarder_id,
Program::serialize_instruction((
callee_id,
Program::serialize_instruction(()).unwrap(),
true,
))
.unwrap(),
no_sibling,
))
.unwrap(),
vec![init_pda_witness(&keys, 0, None)],
&program_with_deps,
)
.expect("a callee that forwards without re-delegating must keep the PDA authorized");
}
#[test]
fn holder_authorization_survives_across_sibling_calls() {
let delegator = crate::test_methods::selective_pda_delegator();
let callee = crate::test_methods::auth_asserting_noop();
let sibling = crate::test_methods::noop();
let pda_keys = test_private_account_keys_1();
let holder_keys = test_private_account_keys_2();
let npk = pda_keys.npk();
let holder_npk = holder_keys.npk();
let seed = PdaSeed::new([77; 32]);
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &pda_keys.vpk(), 0);
let holder_id = AccountId::for_regular_private_account(&holder_npk, &holder_keys.vpk(), 0);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let holder_pre_state = AccountWithMetadata::new(Account::default(), true, holder_id);
let callee_id = callee.id();
let sibling_id = sibling.id();
let program_with_deps = ProgramWithDependencies::new(
delegator,
[(callee_id, callee), (sibling_id, sibling)].into(),
);
execute_and_prove(
vec![pre_state, holder_pre_state],
Program::serialize_instruction((
seed,
seed,
callee_id,
Program::serialize_instruction(()).unwrap(),
Some((sibling_id, None::<bool>)),
))
.unwrap(),
vec![
init_pda_witness(&pda_keys, 0, None),
InputAccountIdentity::Private(PrivateWitness {
vpk: holder_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Regular {
ask: Some(holder_keys.ask),
},
nullifier: NullifierWitness::Init {
npk: holder_npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
],
&program_with_deps,
)
.expect("an account authorized by its own credential stays authorized in a sibling call");
}
#[test]
fn inherited_scope_passes_through_intermediate_calls() {
let delegator = crate::test_methods::selective_pda_delegator();
let forwarder = crate::test_methods::non_delegating_forwarder();
let callee = crate::test_methods::auth_asserting_noop();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let account_id = AccountId::for_private_pda(&delegator.id(), &seed, &npk, &keys.vpk(), 0);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let forwarder_id = forwarder.id();
let callee_id = callee.id();
let program_with_deps = ProgramWithDependencies::new(
delegator,
[(forwarder_id, forwarder), (callee_id, callee)].into(),
);
let no_sibling: Option<(ProgramId, Option<bool>)> = None;
let forward_through_undeclaring_call = Program::serialize_instruction((
forwarder_id,
Program::serialize_instruction((
callee_id,
Program::serialize_instruction(()).unwrap(),
false,
))
.unwrap(),
true,
))
.unwrap();
execute_and_prove(
vec![pre_state],
Program::serialize_instruction((
seed,
seed,
forwarder_id,
forward_through_undeclaring_call,
no_sibling,
))
.unwrap(),
vec![init_pda_witness(&keys, 0, None)],
&program_with_deps,
)
.expect(
"an account authorized in an ancestor's output stays authorized below a call that never mentions it",
);
}
fn undeclaring_private_delegation(
delegated: bool,
external_binding: bool,
declare_authorized: bool,
callee: Program,
) -> Result<(), LeeError> {
let delegator = crate::test_methods::undeclaring_pda_delegator();
let keys = test_private_account_keys_1();
let npk = keys.npk();
let seed = PdaSeed::new([77; 32]);
let delegator_id = delegator.id();
let account_id = AccountId::for_private_pda(&delegator_id, &seed, &npk, &keys.vpk(), 0);
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
let callee_id = callee.id();
let program_with_deps = ProgramWithDependencies::new(delegator, [(callee_id, callee)].into());
execute_and_prove(
vec![pre_state],
Program::serialize_instruction((
delegated.then_some(seed),
declare_authorized,
callee_id,
Program::serialize_instruction(()).unwrap(),
None::<ProgramId>,
))
.unwrap(),
vec![init_pda_witness(
&keys,
0,
external_binding.then_some((delegator_id, seed)),
)],
&program_with_deps,
)
.map(|_| ())
}
#[test]
fn delegated_private_pda_first_seen_in_callee_is_authorized() {
undeclaring_private_delegation(true, true, true, crate::test_methods::auth_asserting_noop())
.expect("a caller's pda_seeds must authorize a private PDA it delegates at first sight");
}
#[test]
fn caller_seeds_bind_a_private_pda_first_seen_in_the_callee() {
undeclaring_private_delegation(
true,
false,
true,
crate::test_methods::auth_asserting_noop(),
)
.expect("a caller's pda_seeds must bind a private PDA it delegates at first sight");
}
#[test]
fn undelegated_private_pda_in_a_callee_may_not_declare_authorization() {
let result = undeclaring_private_delegation(
false,
true,
true,
crate::test_methods::auth_asserting_noop(),
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn granted_private_pda_may_not_be_declared_unauthorized_at_first_sight() {
// `noop` tolerates unauthorized pre_states during host-side execution, so the only
// rejector left is the first-sight consistency assert on the granted edge.
let result = undeclaring_private_delegation(true, true, false, crate::test_methods::noop());
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
fn undeclaring_public_delegation(
account_id: AccountId,
delegated_seed: Option<PdaSeed>,
declare_authorized: bool,
callee: Program,
with_sibling: bool,
) -> Result<lee_core::PrivacyPreservingCircuitOutput, LeeError> {
let delegator = crate::test_methods::undeclaring_pda_delegator();
let sibling = crate::test_methods::noop();
let pre_state = AccountWithMetadata::new(
Account {
program_owner: delegator.id(),
..Account::default()
},
false,
account_id,
);
let callee_id = callee.id();
let sibling_id = sibling.id();
let program_with_deps = ProgramWithDependencies::new(
delegator,
[(callee_id, callee), (sibling_id, sibling)].into(),
);
execute_and_prove(
vec![pre_state],
Program::serialize_instruction((
delegated_seed,
declare_authorized,
callee_id,
Program::serialize_instruction(()).unwrap(),
with_sibling.then_some(sibling_id),
))
.unwrap(),
vec![InputAccountIdentity::Public],
&program_with_deps,
)
.map(|(output, _proof)| output)
}
#[test]
fn delegated_public_pda_first_seen_in_callee_is_authorized() {
let seed = PdaSeed::new([77; 32]);
let delegator_id = crate::test_methods::undeclaring_pda_delegator().id();
let pda = AccountId::for_public_pda(&delegator_id, &seed);
let output = undeclaring_public_delegation(
pda,
Some(seed),
true,
crate::test_methods::auth_asserting_noop(),
false,
)
.expect("a caller's pda_seeds must authorize a public PDA it delegates at first sight");
// The callee ran with the PDA authorized (auth_asserting_noop did not panic), while
// the journal exports the credential view: a seed grant is not a signer-backed claim.
assert_eq!(output.public_actions.len(), 1);
assert_eq!(output.public_actions[0].pre.account_id, pda);
assert!(!output.public_actions[0].pre.is_authorized);
}
#[test]
fn granted_public_pda_may_not_be_declared_unauthorized_at_first_sight() {
let seed = PdaSeed::new([77; 32]);
let delegator_id = crate::test_methods::undeclaring_pda_delegator().id();
let pda = AccountId::for_public_pda(&delegator_id, &seed);
// `noop` tolerates unauthorized pre_states, so the only rejector left is the
// first-sight consistency assert on the granted edge.
let result =
undeclaring_public_delegation(pda, Some(seed), false, crate::test_methods::noop(), false);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn public_pda_first_sight_grant_does_not_extend_to_sibling_calls() {
let seed = PdaSeed::new([77; 32]);
let delegator_id = crate::test_methods::undeclaring_pda_delegator().id();
let pda = AccountId::for_public_pda(&delegator_id, &seed);
let result = undeclaring_public_delegation(
pda,
Some(seed),
true,
crate::test_methods::auth_asserting_noop(),
true,
);
assert!(matches!(result, Err(LeeError::CircuitProvingError(_))));
}
#[test]
fn public_account_first_sight_authorization_is_exported_to_the_journal() {
let seed = PdaSeed::new([77; 32]);
let output = undeclaring_public_delegation(
AccountId::new([9; 32]),
Some(seed),
true,
crate::test_methods::auth_asserting_noop(),
false,
)
.expect("a first-sight authorization claim on a plain public account must prove");
assert!(output.public_actions[0].pre.is_authorized);
}
#[test]
fn wrong_seed_public_pda_first_sight_is_exported_as_credential_claim() {
let seed = PdaSeed::new([77; 32]);
let wrong_seed = PdaSeed::new([88; 32]);
let delegator_id = crate::test_methods::undeclaring_pda_delegator().id();
let pda = AccountId::for_public_pda(&delegator_id, &seed);
let output = undeclaring_public_delegation(
pda,
Some(wrong_seed),
true,
crate::test_methods::auth_asserting_noop(),
false,
)
.expect("an unmatched seed must fall back to the credential-claim path");
// In-circuit this is indistinguishable from a signer's claim; the exported `true`
// is what the verifier audits (and rejects — the id is not a signer).
assert!(output.public_actions[0].pre.is_authorized);
}
/// 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` (`alice_npk`) and
@@ -626,26 +977,8 @@ fn two_private_pda_claims_under_same_seed_are_rejected() {
vec![pre_a, pre_b],
Program::serialize_instruction(seed).unwrap(),
vec![
InputAccountIdentity::Private(PrivateWitness {
vpk: keys_a.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk: keys_a.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
InputAccountIdentity::Private(PrivateWitness {
vpk: keys_b.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk: keys_b.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
init_pda_witness(&keys_a, u128::MAX, None),
init_pda_witness(&keys_b, u128::MAX, None),
],
&program.into(),
);
@@ -665,31 +998,20 @@ fn private_pda_top_level_reuse_rejected_by_binding_check() {
let npk = keys.npk();
let seed = PdaSeed::new([99; 32]);
// Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized =
// true, account_id derived via the private formula.
let account_id = AccountId::for_private_pda(&program.id(), &seed, &npk, &keys.vpk(), u128::MAX);
let owned_pre_state = AccountWithMetadata::new(
Account {
program_owner: program.id(),
..Account::default()
},
true,
false,
account_id,
);
let result = execute_and_prove(
vec![owned_pre_state],
Program::serialize_instruction(()).unwrap(),
vec![InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier: u128::MAX,
kind: WitnessKind::Pda { binding: None },
nullifier: NullifierWitness::Init {
npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
})],
vec![init_pda_witness(&keys, u128::MAX, None)],
&program.into(),
);
@@ -1051,18 +1373,7 @@ fn two_private_pda_family_members_receive_and_spend() {
Program::serialize_instruction(amount).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Private(PrivateWitness {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
identifier: 0,
kind: WitnessKind::Pda {
binding: Some((proxy_id, seed)),
},
nullifier: NullifierWitness::Init {
npk: alice_npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
init_pda_witness(&alice_keys, 0, Some((proxy_id, seed))),
],
&simple_transfer.clone().into(),
)
@@ -1090,18 +1401,7 @@ fn two_private_pda_family_members_receive_and_spend() {
Program::serialize_instruction(amount).unwrap(),
vec![
InputAccountIdentity::Public,
InputAccountIdentity::Private(PrivateWitness {
vpk: alice_keys.vpk(),
random_seed: [0; 32],
identifier: 1,
kind: WitnessKind::Pda {
binding: Some((proxy_id, seed)),
},
nullifier: NullifierWitness::Init {
npk: alice_npk,
commitment_root: DUMMY_COMMITMENT_HASH,
},
}),
init_pda_witness(&alice_keys, 1, Some((proxy_id, seed))),
],
&simple_transfer.into(),
)
@@ -1128,7 +1428,7 @@ fn two_private_pda_family_members_receive_and_spend() {
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(alice_pda_0_account, false, alice_pda_0_id),
AccountWithMetadata::new(recipient_account, true, recipient_id),
],
Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(),
@@ -1167,7 +1467,7 @@ fn two_private_pda_family_members_receive_and_spend() {
let recipient_account = state.get_account_by_id(recipient_id);
let (output, proof) = execute_and_prove(
vec![
AccountWithMetadata::new(alice_pda_1_account.clone(), true, alice_pda_1_id),
AccountWithMetadata::new(alice_pda_1_account.clone(), false, alice_pda_1_id),
AccountWithMetadata::new(recipient_account, false, recipient_id),
],
Program::serialize_instruction((seed, amount, simple_transfer_id)).unwrap(),
+21 -3
View File
@@ -7,9 +7,9 @@
use std::collections::HashMap;
use lee_core::{
AuthorizationSecretKey, BlockId, Commitment, DUMMY_COMMITMENT_HASH, InputAccountIdentity,
Nullifier, NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivateWitness, Timestamp,
WitnessKind,
AuthorizationSecretKey, BlockId, Commitment, DUMMY_COMMITMENT_HASH, Identifier,
InputAccountIdentity, Nullifier, NullifierPublicKey, NullifierSecretKey, NullifierWitness,
PrivateWitness, Timestamp, WitnessKind,
account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data},
encryption::ViewingPublicKey,
program::{
@@ -259,6 +259,24 @@ pub fn test_private_account_keys_2() -> TestPrivateKeys {
}
}
/// Init-lifecycle private-PDA witness for `keys`, the shape every PDA circuit test starts from.
pub fn init_pda_witness(
keys: &TestPrivateKeys,
identifier: Identifier,
binding: Option<(ProgramId, PdaSeed)>,
) -> InputAccountIdentity {
InputAccountIdentity::Private(PrivateWitness {
vpk: keys.vpk(),
random_seed: [0; 32],
identifier,
kind: WitnessKind::Pda { binding },
nullifier: NullifierWitness::Init {
npk: keys.npk(),
commitment_root: DUMMY_COMMITMENT_HASH,
},
})
}
fn shielded_balance_transfer_for_tests(
sender_keys: &TestPublicKeys,
recipient_keys: &TestPrivateKeys,
@@ -7,7 +7,7 @@ use lee_core::{
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp,
account::{Account, AccountId, AccountWithMetadata},
program::{
ChainedCall, Claim, DEFAULT_PROGRAM_ID, ProgramId, compute_public_authorized_pdas,
CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_ID, compute_public_authorized_pdas,
validate_execution,
},
};
@@ -264,17 +264,14 @@ impl ValidatedStateDiff {
// Union with the caller's authorized set so that authorization is monotonically
// growing: once an account is authorized at any point in the chain it remains
// authorized for all subsequent calls.
let authorized_accounts: HashSet<_> = caller_data
.authorized_accounts
.into_iter()
.chain(
program_output
.pre_states
.iter()
.filter(|pre| pre.is_authorized)
.map(|pre| pre.account_id),
)
.collect();
let mut authorized_accounts = caller_data.authorized_accounts;
authorized_accounts.extend(
program_output
.pre_states
.iter()
.filter(|pre| pre.is_authorized)
.map(|pre| pre.account_id),
);
for new_call in program_output.chained_calls.into_iter().rev() {
chained_calls.push_front((
new_call,
@@ -470,12 +467,6 @@ impl ValidatedStateDiff {
}
}
#[derive(Debug)]
struct CallerData {
program_id: Option<ProgramId>,
authorized_accounts: HashSet<AccountId>,
}
fn authenticate_public_transaction_signers(
tx: &PublicTransaction,
state: &V03State,
@@ -0,0 +1,45 @@
use lee_core::program::{
AccountPostState, ChainedCall, InstructionData, ProgramId, ProgramInput, ProgramOutput,
read_lee_inputs,
};
type Instruction = (ProgramId, InstructionData, bool);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction: (callee_program_id, callee_instruction, declare_pre_states),
},
instruction_words,
) = read_lee_inputs::<Instruction>();
let (output_pre_states, output_post_states) = if declare_pre_states {
let post_states = pre_states
.iter()
.map(|account| AccountPostState::new(account.account.clone()))
.collect();
(pre_states.clone(), post_states)
} else {
(Vec::new(), Vec::new())
};
// Make exactly one chained call based on the input instruction with no
// pda seeds, ensuring the target PDAs are never authorized.
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
output_pre_states,
output_post_states,
)
.with_chained_calls(vec![ChainedCall {
program_id: callee_program_id,
instruction_data: callee_instruction,
pre_states,
pda_seeds: vec![],
}])
.write();
}
@@ -5,7 +5,7 @@ use risc0_zkvm::serde::to_vec;
/// Proxy for spending from a private PDA via `simple_transfer`.
///
/// `pre_states = [pda (authorized), recipient]`. Debits the PDA and credits the recipient.
/// `pre_states = [pda, recipient]`. Debits the PDA and credits the recipient.
/// The PDA-to-npk binding is established via `pda_seeds` in the chained call to `simple_transfer`.
type Instruction = (PdaSeed, u128, ProgramId);
@@ -24,15 +24,16 @@ fn main() {
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 mut first_for_callee = first.clone();
first_for_callee.is_authorized = true;
let chained_call = ChainedCall {
program_id: simple_transfer_id,
instruction_data: to_vec(&amount).unwrap(),
pre_states: vec![first.clone(), second.clone()],
pre_states: vec![first_for_callee, second.clone()],
pda_seeds: vec![seed],
};
@@ -0,0 +1,82 @@
use lee_core::program::{
AccountPostState, ChainedCall, Claim, InstructionData, PdaSeed, ProgramId, ProgramInput,
ProgramOutput, read_lee_inputs,
};
use risc0_zkvm::serde::to_vec;
type Instruction = (
PdaSeed,
PdaSeed,
ProgramId,
InstructionData,
Option<(ProgramId, Option<bool>)>,
);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
pre_states,
instruction:
(claim_seed, delegated_seed, callee_program_id, callee_instruction, sibling),
},
instruction_words,
) = read_lee_inputs::<Instruction>();
let Some((pda, rest)) = pre_states.split_first() else {
return;
};
let pda_for_callee = |is_authorized| {
let mut for_callee = pda.clone();
for_callee.is_authorized = is_authorized;
for_callee.account.program_owner = self_program_id;
for_callee
};
// Send a call to the specified program with the same pre-states
// but authorized first PDA supplied.
// Push all the delegated seeds.
let mut chained_calls = vec![ChainedCall {
program_id: callee_program_id,
instruction_data: callee_instruction,
pre_states: std::iter::once(pda_for_callee(true))
.chain(rest.iter().cloned())
.collect(),
pda_seeds: vec![delegated_seed],
}];
// If sibling is present in instruction, send out a call
// with no seeds so that PDAs stay unauthorized in parallel
// branches.
if let Some((sibling_program_id, sibling_pda)) = sibling {
chained_calls.push(ChainedCall {
program_id: sibling_program_id,
instruction_data: to_vec(&()).unwrap(),
pre_states: sibling_pda.map_or_else(
|| rest.to_vec(),
|is_authorized| {
std::iter::once(pda_for_callee(is_authorized))
.chain(rest.iter().cloned())
.collect()
},
),
pda_seeds: vec![],
});
}
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
vec![pda.clone()],
// Claim first PDA supplied
vec![AccountPostState::new_claimed(
pda.account.clone(),
Claim::Pda(claim_seed),
)],
)
.with_chained_calls(chained_calls)
.write();
}
@@ -51,7 +51,7 @@ fn main() {
let recipient_post = AccountPostState::new(recipient_pre.account.clone());
// Chain to simple_transfer with pda_seeds to authorize the PDA.
// The circuit's resolve_authorization_and_record_bindings establishes the
// The circuit's assert_authorization_and_record_bindings establishes the
// private PDA (seed, npk) binding when pda_seeds match the private PDA derivation.
let mut auth_pda_pre = pda_pre;
auth_pda_pre.is_authorized = true;
@@ -0,0 +1,59 @@
use lee_core::program::{
ChainedCall, InstructionData, PdaSeed, ProgramId, ProgramInput, ProgramOutput, read_lee_inputs,
};
use risc0_zkvm::serde::to_vec;
type Instruction = (
Option<PdaSeed>,
bool,
ProgramId,
InstructionData,
Option<ProgramId>,
);
fn main() {
let (
ProgramInput {
self_program_id,
caller_program_id,
mut pre_states,
instruction: (seed, declare_authorized, callee_program_id, callee_instruction, sibling),
},
instruction_words,
) = read_lee_inputs::<Instruction>();
let Some(first) = pre_states.first_mut() else {
return;
};
first.is_authorized = declare_authorized;
let sibling_call = sibling.map(|sibling_program_id| {
let mut sibling_pre = pre_states[0].clone();
sibling_pre.is_authorized = true;
ChainedCall {
program_id: sibling_program_id,
instruction_data: to_vec(&()).unwrap(),
pre_states: vec![sibling_pre],
pda_seeds: vec![],
}
});
let mut chained_calls = vec![ChainedCall {
program_id: callee_program_id,
instruction_data: callee_instruction,
pre_states,
pda_seeds: seed.into_iter().collect(),
}];
chained_calls.extend(sibling_call);
// Emit an output with only chained calls and no pre or post-states.
ProgramOutput::new(
self_program_id,
caller_program_id,
instruction_words,
Vec::new(),
Vec::new(),
)
.with_chained_calls(chained_calls)
.write();
}
+5 -5
View File
@@ -527,20 +527,20 @@ fn private_key_tree_acc_preparation(
let from_identifier = from_acc.kind.identifier();
let from_keys = &from_acc.key_chain;
let ask = from_keys.private_key_holder.authorization_secret_key;
// A PDA is program-authorized and carries no credential of its own.
let ask = (!is_pda).then_some(from_keys.private_key_holder.authorization_secret_key);
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.clone();
// 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.account.clone(), true, account_id);
let sender_pre = AccountWithMetadata::new(from_acc.account.clone(), ask.is_some(), account_id);
let random_seed = random_bytes();
Ok(AccountPreparedData {
// A PDA is program-authorized and carries no credential of its own.
ask: (!is_pda).then_some(ask),
ask,
nsk: Some(nsk),
npk: from_npk,
identifier: from_identifier,
@@ -593,7 +593,7 @@ fn private_shared_acc_preparation(
.map(|e| e.account.clone())
.unwrap_or_default();
let pre_state = AccountWithMetadata::new(acc, true, account_id);
let pre_state = AccountWithMetadata::new(acc, ask.is_some(), account_id);
let random_seed = random_bytes();
Binary file not shown.
@@ -5,7 +5,7 @@ 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.
/// `pre_states = [pda, 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);
@@ -24,16 +24,17 @@ fn main() {
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 mut first_for_callee = first.clone();
first_for_callee.is_authorized = true;
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()],
pre_states: vec![first_for_callee, second.clone()],
pda_seeds: vec![seed],
};