fix(lee): restore per-branch PDA authorization scoping in privacy circuit

derive_from_outputs's rewrite for ProgramImageClaim support collapsed
three distinct authorization sources into one flat, mutable
authorized_accounts set shared across the whole call tree: a
caller_pda_seeds match (call-specific, never cached), a truly-global
first-sight non-PDA authorization, and a per-branch caller-authorized
set inherited only down one lineage. Caching a PDA match into that
shared set meant a PDA delegated to one program became falsely
authorized for any other program elsewhere in the same transaction
that happened to include the same account in its own pre_states - an
authorization bypass, not just a test-semantics quirk.

Restores the three-way split (globally_authorized field plus a local
CallerData carrying caller_account_id/caller_image_id/
authorized_accounts, threaded per-branch through the traversal and
returned from validate_and_sync_states), matching the pattern already
used correctly in validated_state_diff::from_public_transaction's
public-dispatch path. Keeps derive_from_outputs's ProgramImageClaim
image-id resolution unchanged.
This commit is contained in:
Marvin Jones
2026-08-22 21:14:29 -04:00
parent 73f1295e76
commit 2cf467d5d6
@@ -52,6 +52,21 @@ 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)>,
/// 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>,
}
/// A chained call's caller context, threaded down its own lineage only (never shared across
/// sibling branches): who called it, that caller's real image id (needed for PDA derivation,
/// since a `Deploy`-created caller's address doesn't encode it), and the accounts authorized
/// along the path from the root down to this call. `authorized_accounts` is monotonically
/// growing per lineage — each child inherits its parent's set plus whatever the parent itself
/// authorized in its own `pre_states` — so authorization earned in one branch never leaks into an
/// unrelated sibling branch.
struct CallerData {
caller_account_id: Option<AccountId>,
caller_image_id: Option<ProgramId>,
authorized_accounts: HashSet<AccountId>,
}
@@ -114,7 +129,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 {
@@ -137,14 +152,17 @@ impl ExecutionState {
pre_states: first_output.pre_states.clone(),
pda_seeds: Vec::new(),
};
let mut chained_calls = VecDeque::from_iter([(initial_call, None, None)]);
let initial_caller_data = CallerData {
caller_account_id: None,
caller_image_id: None,
authorized_accounts: HashSet::new(),
};
let mut chained_calls = VecDeque::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_account_id, caller_image_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"
@@ -193,7 +211,7 @@ impl ExecutionState {
// by spoofing caller_account_id (e.g. passing caller_account_id = self_account_id
// to bypass access control checks).
assert_eq!(
program_output.caller_account_id, caller_account_id,
program_output.caller_account_id, caller_data.caller_account_id,
"Program output caller_account_id does not match actual caller"
);
@@ -208,23 +226,26 @@ impl ExecutionState {
panic!("Invalid program behavior in program {current_program_id:?}: {err}");
}
for next_call in program_output.chained_calls.iter().rev() {
chained_calls.push_front((
next_call.clone(),
Some(chained_call.program_account_id),
Some(current_program_id),
));
}
execution_state.validate_and_sync_states(
let authorized_accounts = execution_state.validate_and_sync_states(
account_identities,
chained_call.program_account_id,
current_program_id,
caller_image_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() {
chained_calls.push_front((
next_call,
CallerData {
caller_account_id: Some(chained_call.program_account_id),
caller_image_id: Some(current_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",
);
@@ -284,11 +305,12 @@ impl ExecutionState {
account_identities: &[InputAccountIdentity],
account_id: AccountId,
program_id: ProgramId,
caller_image_id: Option<ProgramId>,
caller: CallerData,
caller_pda_seeds: &[PdaSeed],
output_pre_states: Vec<AccountWithMetadata>,
output_post_states: Vec<AccountPostState>,
) {
) -> 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;
@@ -312,30 +334,25 @@ 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!(
"Pre state must exist in execution state for account {pre_account_id}",
)
},
|(pos, acc)| (acc.is_authorized, pos),
);
.position(|acc| acc.account_id == pre_account_id)
.unwrap_or_else(|| {
panic!(
"Pre state must exist in execution state for account {pre_account_id}",
)
});
let is_authorized = resolve_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,
pre_account_id,
pre_state_position,
caller_image_id,
caller_pda_seeds,
previous_is_authorized,
);
assert_eq!(
@@ -397,12 +414,11 @@ impl ExecutionState {
&mut self.pda_family_binding,
&mut self.private_pda_bound_positions,
&self.private_pda_by_position,
&mut self.authorized_accounts,
&self.globally_authorized,
&caller,
pre_account_id,
pre_state_position,
caller_image_id,
caller_pda_seeds,
false,
);
assert_eq!(
pre_is_authorized, is_authorized,
@@ -412,8 +428,8 @@ impl ExecutionState {
if !has_private_pda_witness
&& authorize_first_sight_without_pda_witness(
&mut self.pda_family_binding,
&mut self.authorized_accounts,
caller_image_id,
&mut self.globally_authorized,
&caller,
caller_pda_seeds,
pre_account_id,
pre_is_authorized,
@@ -432,6 +448,10 @@ impl ExecutionState {
}
}
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!(
@@ -515,6 +535,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
@@ -603,48 +627,54 @@ fn bind_private_pda_position(
/// Either the account is a public PDA the caller delegates via `caller_pda_seeds`, in which case
/// the public mask must be cleared before export (see the `pre.is_authorized = false` comment at
/// the call site), or it's a regular account, whose authorization (if any) becomes globally
/// visible for the rest of the call tree. Only reachable when `caller_image_id.is_some()`;
/// visible for the rest of the call tree. Only reachable when `caller.caller_image_id.is_some()`;
/// top-level flows have no caller-emitted seeds, so a first-sight PDA there must come through the
/// claim path instead.
fn authorize_first_sight_without_pda_witness(
pda_family_binding: &mut HashMap<(ProgramId, PdaSeed), AccountId>,
authorized_accounts: &mut HashSet<AccountId>,
caller_image_id: Option<ProgramId>,
globally_authorized: &mut HashSet<AccountId>,
caller: &CallerData,
caller_pda_seeds: &[PdaSeed],
pre_account_id: AccountId,
pre_is_authorized: bool,
) -> bool {
let matched_caller_seed = caller_image_id.and_then(|caller| {
let matched_caller_seed = caller.caller_image_id.and_then(|caller_id| {
caller_pda_seeds
.iter()
.find(|seed| AccountId::for_public_pda(&caller, seed) == pre_account_id)
.map(|seed| (*seed, caller))
.find(|seed| AccountId::for_public_pda(&caller_id, seed) == pre_account_id)
.map(|seed| (*seed, caller_id))
});
if let Some((seed, caller)) = matched_caller_seed {
if let Some((seed, caller_id)) = matched_caller_seed {
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, seed, pre_account_id);
assert_family_binding(pda_family_binding, caller_id, seed, pre_account_id);
true
} else {
if pre_is_authorized {
authorized_accounts.insert(pre_account_id);
globally_authorized.insert(pre_account_id);
}
false
}
}
/// 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_image_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.
/// any resulting bindings.
///
/// Authorized through exactly one of three sources, each scoped differently: a `caller_pda_seeds`
/// match is recomputed fresh for this call and never cached, since delegation is call-specific,
/// not transaction-wide; `globally_authorized` covers a non-PDA account authorized anywhere in the
/// tree, since that authorization is backed by a signature-like proof valid for the whole
/// transaction; `caller.authorized_accounts` covers an account the caller itself authorized in its
/// own `pre_states`, inherited only down that caller's own lineage. Conflating any of these would
/// let authorization earned in one branch leak into an unrelated sibling branch. 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.caller_image_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.
#[expect(
clippy::too_many_arguments,
reason = "breaking out a context struct does not buy us anything here"
@@ -653,53 +683,46 @@ fn resolve_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,
pre_account_id: AccountId,
pre_state_position: usize,
caller_image_id: Option<ProgramId>,
caller_pda_seeds: &[PdaSeed],
previous_is_authorized: bool,
) -> bool {
// `for_public_pda`/`for_private_pda`'s derivation formula is pinned to the caller's real
// image id, not its dispatch-facing `AccountId` — a `Deploy`-created caller's address doesn't
// encode it, so `caller_image_id` must be the recovered real image id (see
// encode it, so `caller.caller_image_id` must be the recovered real image id (see
// `derive_from_outputs`'s `current_program_id`), not a bijection round-trip.
let matched_caller_seed: Option<(PdaSeed, bool, ProgramId)> =
caller_image_id.and_then(|caller| {
caller.caller_image_id.and_then(|caller_id| {
caller_pda_seeds.iter().find_map(|seed| {
if AccountId::for_public_pda(&caller, seed) == pre_account_id {
return Some((*seed, false, caller));
if AccountId::for_public_pda(&caller_id, seed) == pre_account_id {
return Some((*seed, false, caller_id));
}
if let Some((npk, vpk, identifier)) =
private_pda_by_position.get(&pre_state_position)
&& AccountId::for_private_pda(&caller, seed, npk, vpk, *identifier)
&& AccountId::for_private_pda(&caller_id, seed, npk, vpk, *identifier)
== pre_account_id
{
return Some((*seed, true, caller));
return Some((*seed, true, caller_id));
}
None
})
});
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_id)) = matched_caller_seed {
assert_family_binding(pda_family_binding, caller_id, seed, pre_account_id);
if is_private_form {
bind_private_pda_position(
private_pda_bound_positions,
pre_state_position,
caller,
caller_id,
seed,
);
}
}
if authorized_accounts.contains(&pre_account_id) {
return true;
}
let authorized = previous_is_authorized || matched_caller_seed.is_some();
if authorized {
authorized_accounts.insert(pre_account_id);
}
authorized
matched_caller_seed.is_some()
|| globally_authorized.contains(&pre_account_id)
|| caller.authorized_accounts.contains(&pre_account_id)
}