diff --git a/Cargo.lock b/Cargo.lock index 830002fe8..4efb6c76a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 2730e27ac..97567415d 100644 Binary files a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin and b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin differ diff --git a/artifacts/lez/programs/amm.bin b/artifacts/lez/programs/amm.bin index 21f8a61d5..fd192e693 100644 Binary files a/artifacts/lez/programs/amm.bin and b/artifacts/lez/programs/amm.bin differ diff --git a/artifacts/lez/programs/associated_token_account.bin b/artifacts/lez/programs/associated_token_account.bin index 0421cb8ad..96ca998d2 100644 Binary files a/artifacts/lez/programs/associated_token_account.bin and b/artifacts/lez/programs/associated_token_account.bin differ diff --git a/artifacts/lez/programs/authenticated_transfer.bin b/artifacts/lez/programs/authenticated_transfer.bin index 12ca53193..9ac927132 100644 Binary files a/artifacts/lez/programs/authenticated_transfer.bin and b/artifacts/lez/programs/authenticated_transfer.bin differ diff --git a/artifacts/lez/programs/bridge.bin b/artifacts/lez/programs/bridge.bin index 1e1d2cb81..98a113fe7 100644 Binary files a/artifacts/lez/programs/bridge.bin and b/artifacts/lez/programs/bridge.bin differ diff --git a/artifacts/lez/programs/bridge_lock.bin b/artifacts/lez/programs/bridge_lock.bin index cc19eb2d3..72ee2e941 100644 Binary files a/artifacts/lez/programs/bridge_lock.bin and b/artifacts/lez/programs/bridge_lock.bin differ diff --git a/artifacts/lez/programs/clock.bin b/artifacts/lez/programs/clock.bin index d985851b9..10c5bdcae 100644 Binary files a/artifacts/lez/programs/clock.bin and b/artifacts/lez/programs/clock.bin differ diff --git a/artifacts/lez/programs/cross_zone_inbox.bin b/artifacts/lez/programs/cross_zone_inbox.bin index 5926c45c1..eab9bf344 100644 Binary files a/artifacts/lez/programs/cross_zone_inbox.bin and b/artifacts/lez/programs/cross_zone_inbox.bin differ diff --git a/artifacts/lez/programs/cross_zone_outbox.bin b/artifacts/lez/programs/cross_zone_outbox.bin index 575788bfd..3da07afbe 100644 Binary files a/artifacts/lez/programs/cross_zone_outbox.bin and b/artifacts/lez/programs/cross_zone_outbox.bin differ diff --git a/artifacts/lez/programs/faucet.bin b/artifacts/lez/programs/faucet.bin index 10e279688..dd9d2a282 100644 Binary files a/artifacts/lez/programs/faucet.bin and b/artifacts/lez/programs/faucet.bin differ diff --git a/artifacts/lez/programs/pinata.bin b/artifacts/lez/programs/pinata.bin index ba715ec2f..d123d24dc 100644 Binary files a/artifacts/lez/programs/pinata.bin and b/artifacts/lez/programs/pinata.bin differ diff --git a/artifacts/lez/programs/pinata_token.bin b/artifacts/lez/programs/pinata_token.bin index 870b038d2..8aeca35d3 100644 Binary files a/artifacts/lez/programs/pinata_token.bin and b/artifacts/lez/programs/pinata_token.bin differ diff --git a/artifacts/lez/programs/ping_receiver.bin b/artifacts/lez/programs/ping_receiver.bin index 5e5df91c6..564bce29b 100644 Binary files a/artifacts/lez/programs/ping_receiver.bin and b/artifacts/lez/programs/ping_receiver.bin differ diff --git a/artifacts/lez/programs/ping_sender.bin b/artifacts/lez/programs/ping_sender.bin index 9e4be8598..29b067403 100644 Binary files a/artifacts/lez/programs/ping_sender.bin and b/artifacts/lez/programs/ping_sender.bin differ diff --git a/artifacts/lez/programs/sequencer_stake.bin b/artifacts/lez/programs/sequencer_stake.bin index e1ad5976a..a3d271f7b 100644 Binary files a/artifacts/lez/programs/sequencer_stake.bin and b/artifacts/lez/programs/sequencer_stake.bin differ diff --git a/artifacts/lez/programs/token.bin b/artifacts/lez/programs/token.bin index fb1835d12..1e6ac3005 100644 Binary files a/artifacts/lez/programs/token.bin and b/artifacts/lez/programs/token.bin differ diff --git a/artifacts/lez/programs/vault.bin b/artifacts/lez/programs/vault.bin index 80bd45f5e..3c419e5a3 100644 Binary files a/artifacts/lez/programs/vault.bin and b/artifacts/lez/programs/vault.bin differ diff --git a/artifacts/lez/programs/wrapped_token.bin b/artifacts/lez/programs/wrapped_token.bin index 2745546bd..3294a44ba 100644 Binary files a/artifacts/lez/programs/wrapped_token.bin and b/artifacts/lez/programs/wrapped_token.bin differ diff --git a/lee/privacy_preserving_circuit/src/execution_state.rs b/lee/privacy_preserving_circuit/src/execution_state.rs index 334cad7c2..67089dd11 100644 --- a/lee/privacy_preserving_circuit/src/execution_state.rs +++ b/lee/privacy_preserving_circuit/src/execution_state.rs @@ -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, - authorized_accounts: HashSet, + /// The set containing non-PDA accounts authorized at their first sight, anywhere in the + /// call tree, remaining authorized throughout all calls. + globally_authorized: HashSet, } 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, + caller: CallerData, caller_pda_seeds: &[PdaSeed], output_pre_states: Vec, output_post_states: Vec, - ) { - for (pre, mut post) in output_pre_states.into_iter().zip(output_post_states) { + ) -> HashSet { + 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, + 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, + 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, private_pda_by_position: &HashMap, - authorized_accounts: &mut HashSet, + globally_authorized: &HashSet, + caller: &CallerData, + caller_pda_seeds: &[PdaSeed], pre_account_id: AccountId, pre_state_position: usize, - caller_program_id: Option, - 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}", + ); } diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index a01deef2b..aa6e4dae5 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -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 { diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index e30162902..3af02b7b6 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -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)>, }, } diff --git a/lee/state_machine/core/src/program/mod.rs b/lee/state_machine/core/src/program/mod.rs index 770bcf2db..d8d0acd40 100644 --- a/lee/state_machine/core/src/program/mod.rs +++ b/lee/state_machine/core/src/program/mod.rs @@ -198,6 +198,12 @@ impl AccountId { } } +#[derive(Debug)] +pub struct CallerData { + pub program_id: Option, + pub authorized_accounts: HashSet, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] pub struct ChainedCall { /// The program ID of the program to execute. diff --git a/lee/state_machine/src/lib.rs b/lee/state_machine/src/lib.rs index 9886127b1..eceb8f28e 100644 --- a/lee/state_machine/src/lib.rs +++ b/lee/state_machine/src/lib.rs @@ -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( diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs index 299ebcd87..e8a676f1c 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -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 { 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(_)))); } diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index 758a7c85f..d4e2c24aa 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -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)> = 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::)), + )) + .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)> = 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::, + )) + .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, + declare_authorized: bool, + callee: Program, + with_sibling: bool, +) -> Result { + 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(), diff --git a/lee/state_machine/src/state/tests/mod.rs b/lee/state_machine/src/state/tests/mod.rs index b1c67b9b5..c3a9e2285 100644 --- a/lee/state_machine/src/state/tests/mod.rs +++ b/lee/state_machine/src/state/tests/mod.rs @@ -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, diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index 738cc5153..7afb99b1f 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -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, - authorized_accounts: HashSet, -} - fn authenticate_public_transaction_signers( tx: &PublicTransaction, state: &V03State, diff --git a/lee/state_machine/test_methods/guest/src/bin/non_delegating_forwarder.rs b/lee/state_machine/test_methods/guest/src/bin/non_delegating_forwarder.rs new file mode 100644 index 000000000..81b30f666 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/non_delegating_forwarder.rs @@ -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::(); + + 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(); +} diff --git a/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs b/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs index d8b9bb5ce..86ef73b4a 100644 --- a/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs +++ b/lee/state_machine/test_methods/guest/src/bin/pda_spend_proxy.rs @@ -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], }; diff --git a/lee/state_machine/test_methods/guest/src/bin/selective_pda_delegator.rs b/lee/state_machine/test_methods/guest/src/bin/selective_pda_delegator.rs new file mode 100644 index 000000000..54e5020f0 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/selective_pda_delegator.rs @@ -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)>, +); + +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::(); + + 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(); +} diff --git a/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs b/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs index ce7f1d8ee..c6789161e 100644 --- a/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs +++ b/lee/state_machine/test_methods/guest/src/bin/simple_transfer_proxy.rs @@ -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; diff --git a/lee/state_machine/test_methods/guest/src/bin/undeclaring_pda_delegator.rs b/lee/state_machine/test_methods/guest/src/bin/undeclaring_pda_delegator.rs new file mode 100644 index 000000000..2589ccdc9 --- /dev/null +++ b/lee/state_machine/test_methods/guest/src/bin/undeclaring_pda_delegator.rs @@ -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, + bool, + ProgramId, + InstructionData, + Option, +); + +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::(); + + 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(); +} diff --git a/lez/wallet/src/account_manager.rs b/lez/wallet/src/account_manager.rs index 3cf1cce4e..d38c0897e 100644 --- a/lez/wallet/src/account_manager.rs +++ b/lez/wallet/src/account_manager.rs @@ -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(); diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 03a6d8254..1a14baddf 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ diff --git a/test_programs/guest/src/bin/pda_spend_proxy.rs b/test_programs/guest/src/bin/pda_spend_proxy.rs index 5b00004c3..0b4c89145 100644 --- a/test_programs/guest/src/bin/pda_spend_proxy.rs +++ b/test_programs/guest/src/bin/pda_spend_proxy.rs @@ -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], };