mirror of
https://github.com/logos-blockchain/lssa.git
synced 2026-06-02 23:29:34 +00:00
refactor: make programs privacy-agnostic in the privacy circuit
This commit is contained in:
parent
48478ca21e
commit
f9a5a7635e
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.
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.
BIN
artifacts/test_program_methods/private_pda_delegator.bin
Normal file
BIN
artifacts/test_program_methods/private_pda_delegator.bin
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -52,7 +52,6 @@ fn main() {
|
|||||||
instruction_data: chained_call_instruction_data,
|
instruction_data: chained_call_instruction_data,
|
||||||
pre_states,
|
pre_states,
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write the outputs.
|
// Write the outputs.
|
||||||
|
|||||||
@ -65,7 +65,6 @@ fn main() {
|
|||||||
instruction_data: chained_call_instruction_data,
|
instruction_data: chained_call_instruction_data,
|
||||||
pre_states: vec![pre_state_for_chained_call],
|
pre_states: vec![pre_state_for_chained_call],
|
||||||
pda_seeds: vec![PDA_SEED],
|
pda_seeds: vec![PDA_SEED],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Write the outputs.
|
// Write the outputs.
|
||||||
|
|||||||
@ -65,14 +65,13 @@ pub struct ChainedCall {
|
|||||||
pub pre_states: Vec<AccountWithMetadata>,
|
pub pre_states: Vec<AccountWithMetadata>,
|
||||||
/// The instruction data to pass.
|
/// The instruction data to pass.
|
||||||
pub instruction_data: InstructionData,
|
pub instruction_data: InstructionData,
|
||||||
/// Public PDA seeds authorized for the callee. Each derives an `AccountId` via
|
/// PDA seeds authorized for the callee. For each callee `pre_state`, the outer circuit
|
||||||
/// `AccountId::from((&caller_program_id, seed))`.
|
/// checks whether its `AccountId` matches a public PDA derivation
|
||||||
|
/// `AccountId::from((&caller, seed))` (mask 0) or a private PDA derivation
|
||||||
|
/// `private_pda_account_id(&caller, seed, npk)` (mask 3, where `npk` is the wallet-supplied
|
||||||
|
/// npk for that `pre_state`). Programs stay privacy-agnostic: they emit seeds, the circuit
|
||||||
|
/// resolves public vs private based on the `pre_state`'s mask.
|
||||||
pub pda_seeds: Vec<PdaSeed>,
|
pub pda_seeds: Vec<PdaSeed>,
|
||||||
/// Private PDA `(seed, npk)` pairs authorized for the callee. Each derives an `AccountId`
|
|
||||||
/// via `private_pda_account_id(&caller_program_id, seed, npk)`. The npk binds the
|
|
||||||
/// authorization to a specific group of controllers and is part of the caller program's
|
|
||||||
/// Risc0-proven output, so the outer circuit can trust it.
|
|
||||||
pub private_pda_seeds: Vec<(PdaSeed, NullifierPublicKey)>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ChainedCall {
|
impl ChainedCall {
|
||||||
@ -88,7 +87,6 @@ impl ChainedCall {
|
|||||||
instruction_data: risc0_zkvm::serde::to_vec(instruction)
|
instruction_data: risc0_zkvm::serde::to_vec(instruction)
|
||||||
.expect("Serialization to Vec<u32> should not fail"),
|
.expect("Serialization to Vec<u32> should not fail"),
|
||||||
pda_seeds: Vec::new(),
|
pda_seeds: Vec::new(),
|
||||||
private_pda_seeds: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -97,15 +95,6 @@ impl ChainedCall {
|
|||||||
self.pda_seeds = pda_seeds;
|
self.pda_seeds = pda_seeds;
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn with_private_pda_seeds(
|
|
||||||
mut self,
|
|
||||||
private_pda_seeds: Vec<(PdaSeed, NullifierPublicKey)>,
|
|
||||||
) -> Self {
|
|
||||||
self.private_pda_seeds = private_pda_seeds;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Represents the final state of an `Account` after a program execution.
|
/// Represents the final state of an `Account` after a program execution.
|
||||||
@ -131,16 +120,13 @@ pub enum Claim {
|
|||||||
/// This will give no error if program had authorization in pre state and may be useful
|
/// This will give no error if program had authorization in pre state and may be useful
|
||||||
/// if program decides to give up authorization for a chained call.
|
/// if program decides to give up authorization for a chained call.
|
||||||
Authorized,
|
Authorized,
|
||||||
/// The program requests ownership of the account through a public PDA. The `AccountId` is
|
/// The program requests ownership of the account through a PDA. The `pre_state`'s
|
||||||
/// `AccountId::from((&program_id, &seed))`.
|
/// visibility mask selects the derivation formula: mask 0 uses
|
||||||
|
/// `AccountId::from((&program_id, &seed))`, mask 3 uses
|
||||||
|
/// `private_pda_account_id(&program_id, &seed, &npk)` with the wallet-supplied npk for that
|
||||||
|
/// `pre_state`. Programs stay privacy-agnostic: they emit a seed, the circuit resolves the
|
||||||
|
/// rest from the mask.
|
||||||
Pda(PdaSeed),
|
Pda(PdaSeed),
|
||||||
/// The program requests ownership of the account through a private PDA. The `AccountId` is
|
|
||||||
/// `private_pda_account_id(&program_id, &seed, &npk)`. The npk is part of the program's
|
|
||||||
/// Risc0-proven output, so the outer circuit can trust it.
|
|
||||||
PrivatePda {
|
|
||||||
seed: PdaSeed,
|
|
||||||
npk: NullifierPublicKey,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AccountPostState {
|
impl AccountPostState {
|
||||||
@ -530,27 +516,24 @@ pub fn private_pda_account_id(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Computes the set of PDA `AccountId`s the callee is authorized to mutate.
|
/// Computes the set of public-PDA `AccountId`s the callee is authorized to mutate.
|
||||||
///
|
///
|
||||||
/// `pda_seeds` produces public PDAs. `private_pda_seeds` produces private PDAs whose derivation
|
/// Returns only public-form derivations, suitable for contexts where all accounts are public
|
||||||
/// includes the caller-supplied npk. All seeds and npks must come from the caller's Risc0-proven
|
/// (e.g. the public-execution path). The privacy circuit must additionally check each mask-3
|
||||||
/// [`ChainedCall`], so the outer circuit can trust them.
|
/// `pre_state` against `private_pda_account_id(caller, seed, npk)` with the wallet-supplied
|
||||||
|
/// npk for that `pre_state`.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn compute_authorized_pdas(
|
pub fn compute_authorized_pdas(
|
||||||
caller_program_id: Option<ProgramId>,
|
caller_program_id: Option<ProgramId>,
|
||||||
pda_seeds: &[PdaSeed],
|
pda_seeds: &[PdaSeed],
|
||||||
private_pda_seeds: &[(PdaSeed, NullifierPublicKey)],
|
|
||||||
) -> HashSet<AccountId> {
|
) -> HashSet<AccountId> {
|
||||||
let Some(caller) = caller_program_id else {
|
let Some(caller) = caller_program_id else {
|
||||||
return HashSet::new();
|
return HashSet::new();
|
||||||
};
|
};
|
||||||
let public = pda_seeds
|
pda_seeds
|
||||||
.iter()
|
.iter()
|
||||||
.map(|seed| AccountId::from((&caller, seed)));
|
.map(|seed| AccountId::from((&caller, seed)))
|
||||||
let private = private_pda_seeds
|
.collect()
|
||||||
.iter()
|
|
||||||
.map(|(seed, npk)| private_pda_account_id(&caller, seed, npk));
|
|
||||||
public.chain(private).collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reads the NSSA inputs from the guest environment.
|
/// Reads the NSSA inputs from the guest environment.
|
||||||
@ -944,50 +927,22 @@ mod tests {
|
|||||||
|
|
||||||
// ---- compute_authorized_pdas tests ----
|
// ---- compute_authorized_pdas tests ----
|
||||||
|
|
||||||
/// With no private PDA seeds, `compute_authorized_pdas` returns public PDA addresses only.
|
/// `compute_authorized_pdas` returns the public PDA addresses for the caller's seeds.
|
||||||
#[test]
|
#[test]
|
||||||
fn compute_authorized_pdas_public_only() {
|
fn compute_authorized_pdas_with_seeds() {
|
||||||
let caller: ProgramId = [1; 8];
|
let caller: ProgramId = [1; 8];
|
||||||
let seed = PdaSeed::new([2; 32]);
|
let seed = PdaSeed::new([2; 32]);
|
||||||
let result = compute_authorized_pdas(Some(caller), &[seed], &[]);
|
let result = compute_authorized_pdas(Some(caller), &[seed]);
|
||||||
let expected = AccountId::from((&caller, &seed));
|
let expected = AccountId::from((&caller, &seed));
|
||||||
assert!(result.contains(&expected));
|
assert!(result.contains(&expected));
|
||||||
assert_eq!(result.len(), 1);
|
assert_eq!(result.len(), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Private PDA seeds produce private PDA `AccountId`s via the `npk`-inclusive derivation.
|
|
||||||
#[test]
|
|
||||||
fn compute_authorized_pdas_private_only() {
|
|
||||||
let caller: ProgramId = [1; 8];
|
|
||||||
let seed = PdaSeed::new([2; 32]);
|
|
||||||
let npk = NullifierPublicKey([3; 32]);
|
|
||||||
let result = compute_authorized_pdas(Some(caller), &[], &[(seed, npk)]);
|
|
||||||
let expected = private_pda_account_id(&caller, &seed, &npk);
|
|
||||||
assert!(result.contains(&expected));
|
|
||||||
let public_id = AccountId::from((&caller, &seed));
|
|
||||||
assert!(!result.contains(&public_id));
|
|
||||||
assert_eq!(result.len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Public and private seeds can coexist in a single chained call; both are authorized.
|
|
||||||
#[test]
|
|
||||||
fn compute_authorized_pdas_public_and_private() {
|
|
||||||
let caller: ProgramId = [1; 8];
|
|
||||||
let pub_seed = PdaSeed::new([2; 32]);
|
|
||||||
let priv_seed = PdaSeed::new([4; 32]);
|
|
||||||
let npk = NullifierPublicKey([3; 32]);
|
|
||||||
let result = compute_authorized_pdas(Some(caller), &[pub_seed], &[(priv_seed, npk)]);
|
|
||||||
assert!(result.contains(&AccountId::from((&caller, &pub_seed))));
|
|
||||||
assert!(result.contains(&private_pda_account_id(&caller, &priv_seed, &npk)));
|
|
||||||
assert_eq!(result.len(), 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// With no caller (top-level call), the result is always empty.
|
/// With no caller (top-level call), the result is always empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn compute_authorized_pdas_no_caller_returns_empty() {
|
fn compute_authorized_pdas_no_caller_returns_empty() {
|
||||||
let seed = PdaSeed::new([2; 32]);
|
let seed = PdaSeed::new([2; 32]);
|
||||||
let npk = NullifierPublicKey([3; 32]);
|
let result = compute_authorized_pdas(None, &[seed]);
|
||||||
let result = compute_authorized_pdas(None, &[seed], &[(seed, npk)]);
|
|
||||||
assert!(result.is_empty());
|
assert!(result.is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -129,12 +129,6 @@ pub enum InvalidProgramBehaviorError {
|
|||||||
actual: AccountId,
|
actual: AccountId,
|
||||||
},
|
},
|
||||||
|
|
||||||
#[error("Private PDA claim mismatch: expected {expected:?}, actual {actual:?}")]
|
|
||||||
MismatchedPrivatePdaClaim {
|
|
||||||
expected: AccountId,
|
|
||||||
actual: AccountId,
|
|
||||||
},
|
|
||||||
|
|
||||||
#[error("Default account {account_id} was modified without being claimed")]
|
#[error("Default account {account_id} was modified without being claimed")]
|
||||||
DefaultAccountModifiedWithoutClaim { account_id: AccountId },
|
DefaultAccountModifiedWithoutClaim { account_id: AccountId },
|
||||||
|
|
||||||
|
|||||||
@ -85,7 +85,6 @@ pub fn execute_and_prove(
|
|||||||
instruction_data,
|
instruction_data,
|
||||||
pre_states,
|
pre_states,
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]);
|
let mut chained_calls = VecDeque::from_iter([(initial_call, initial_program, None)]);
|
||||||
|
|||||||
@ -292,16 +292,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[must_use]
|
|
||||||
pub fn private_pda_claimer() -> Self {
|
|
||||||
use test_program_methods::{PRIVATE_PDA_CLAIMER_ELF, PRIVATE_PDA_CLAIMER_ID};
|
|
||||||
|
|
||||||
Self {
|
|
||||||
id: PRIVATE_PDA_CLAIMER_ID,
|
|
||||||
elf: PRIVATE_PDA_CLAIMER_ELF.to_vec(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn pda_claimer() -> Self {
|
pub fn pda_claimer() -> Self {
|
||||||
use test_program_methods::{PDA_CLAIMER_ELF, PDA_CLAIMER_ID};
|
use test_program_methods::{PDA_CLAIMER_ELF, PDA_CLAIMER_ID};
|
||||||
@ -312,6 +302,16 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn private_pda_delegator() -> Self {
|
||||||
|
use test_program_methods::{PRIVATE_PDA_DELEGATOR_ELF, PRIVATE_PDA_DELEGATOR_ID};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
id: PRIVATE_PDA_DELEGATOR_ID,
|
||||||
|
elf: PRIVATE_PDA_DELEGATOR_ELF.to_vec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn changer_claimer() -> Self {
|
pub fn changer_claimer() -> Self {
|
||||||
use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID};
|
use test_program_methods::{CHANGER_CLAIMER_ELF, CHANGER_CLAIMER_ID};
|
||||||
|
|||||||
@ -2314,12 +2314,16 @@ pub mod tests {
|
|||||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A mask-3 account with no proven `Claim::PrivatePda` or `ChainedCall.private_pda_seeds`
|
/// A mask-3 account that no program claims via `Claim::Pda` and no caller authorizes via
|
||||||
/// attestation must be rejected by the circuit, since there is no binding from which to verify
|
/// `ChainedCall.pda_seeds` has no binding between its wallet-supplied npk and its
|
||||||
/// its npk.
|
/// `account_id`, so the circuit must reject. Here `simple_balance_transfer` emits no claim
|
||||||
|
/// for the second account, leaving position 1 unbound.
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_3_without_binding_panics() {
|
fn mask_3_without_binding_panics() {
|
||||||
let program = Program::simple_balance_transfer();
|
let program = Program::simple_balance_transfer();
|
||||||
|
let keys = test_private_account_keys_1();
|
||||||
|
let npk = keys.npk();
|
||||||
|
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
|
||||||
let public_account_1 = AccountWithMetadata::new(
|
let public_account_1 = AccountWithMetadata::new(
|
||||||
Account {
|
Account {
|
||||||
program_owner: program.id(),
|
program_owner: program.id(),
|
||||||
@ -2329,31 +2333,31 @@ pub mod tests {
|
|||||||
true,
|
true,
|
||||||
AccountId::new([0; 32]),
|
AccountId::new([0; 32]),
|
||||||
);
|
);
|
||||||
let public_account_2 =
|
let mask3_account =
|
||||||
AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32]));
|
AccountWithMetadata::new(Account::default(), false, AccountId::new([1; 32]));
|
||||||
|
|
||||||
let visibility_mask = [0, 3];
|
let visibility_mask = [0, 3];
|
||||||
let result = execute_and_prove(
|
let result = execute_and_prove(
|
||||||
vec![public_account_1, public_account_2],
|
vec![public_account_1, mask3_account],
|
||||||
Program::serialize_instruction(10_u128).unwrap(),
|
Program::serialize_instruction(10_u128).unwrap(),
|
||||||
visibility_mask.to_vec(),
|
visibility_mask.to_vec(),
|
||||||
|
vec![(npk, shared_secret)],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![None],
|
||||||
vec![],
|
|
||||||
&program.into(),
|
&program.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Happy path: a program claims a new mask-3 account via `Claim::PrivatePda { seed, npk }`.
|
/// Happy path: a program claims a new mask-3 account via `Claim::Pda(seed)`. The circuit
|
||||||
/// The circuit derives the `AccountId` via `private_pda_account_id(program_id, seed, npk)`
|
/// reads the npk for that `pre_state` from `private_account_keys` at the `pre_state`'s
|
||||||
/// and matches it against the proven claim; the wallet-supplied npk in `private_account_keys`
|
/// position, derives `AccountId` via `private_pda_account_id(program_id, seed, npk)`, and
|
||||||
/// matches the attested npk from the bindings map; a commitment, nullifier and ciphertext are
|
/// asserts it equals the `pre_state`'s `account_id`. The equality both validates the claim
|
||||||
/// produced.
|
/// and binds the wallet-supplied npk to the `account_id`.
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_3_private_pda_claim_succeeds() {
|
fn mask_3_private_pda_claim_succeeds() {
|
||||||
let program = Program::private_pda_claimer();
|
let program = Program::pda_claimer();
|
||||||
let keys = test_private_account_keys_1();
|
let keys = test_private_account_keys_1();
|
||||||
let npk = keys.npk();
|
let npk = keys.npk();
|
||||||
let seed = PdaSeed::new([42; 32]);
|
let seed = PdaSeed::new([42; 32]);
|
||||||
@ -2364,7 +2368,7 @@ pub mod tests {
|
|||||||
|
|
||||||
let result = execute_and_prove(
|
let result = execute_and_prove(
|
||||||
vec![pre_state],
|
vec![pre_state],
|
||||||
Program::serialize_instruction((seed, npk)).unwrap(),
|
Program::serialize_instruction(seed).unwrap(),
|
||||||
vec![3],
|
vec![3],
|
||||||
vec![(npk, shared_secret)],
|
vec![(npk, shared_secret)],
|
||||||
vec![],
|
vec![],
|
||||||
@ -2380,11 +2384,11 @@ pub mod tests {
|
|||||||
assert!(output.public_post_states.is_empty());
|
assert!(output.public_post_states.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The wallet supplies an npk in `private_account_keys` that differs from the npk attested
|
/// The wallet supplies an npk that does not match the `pre_state`'s `account_id` under
|
||||||
/// by the program's `Claim::PrivatePda`. The circuit's mask-3 binding check must reject.
|
/// `private_pda_account_id(program, claim_seed, npk)`. The claim equality check rejects.
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_3_wallet_npk_mismatch_panics() {
|
fn mask_3_wallet_npk_mismatch_panics() {
|
||||||
let program = Program::private_pda_claimer();
|
let program = Program::pda_claimer();
|
||||||
let attested_keys = test_private_account_keys_1();
|
let attested_keys = test_private_account_keys_1();
|
||||||
let wallet_keys = test_private_account_keys_2();
|
let wallet_keys = test_private_account_keys_2();
|
||||||
let attested_npk = attested_keys.npk();
|
let attested_npk = attested_keys.npk();
|
||||||
@ -2392,14 +2396,15 @@ pub mod tests {
|
|||||||
let seed = PdaSeed::new([42; 32]);
|
let seed = PdaSeed::new([42; 32]);
|
||||||
let shared_secret = SharedSecretKey::new(&[55; 32], &wallet_keys.vpk());
|
let shared_secret = SharedSecretKey::new(&[55; 32], &wallet_keys.vpk());
|
||||||
|
|
||||||
// The account_id derives from the attested npk (what the program claims). The wallet
|
// account_id is derived from `attested_npk`, but the wallet provides `wallet_npk` for
|
||||||
// supplies a different npk in private_account_keys, which must fail the binding check.
|
// this pre_state. `private_pda_account_id(program, seed, wallet_npk) != account_id`, so
|
||||||
|
// the claim check in the circuit must reject.
|
||||||
let account_id = private_pda_account_id(&program.id(), &seed, &attested_npk);
|
let account_id = private_pda_account_id(&program.id(), &seed, &attested_npk);
|
||||||
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
|
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
|
||||||
|
|
||||||
let result = execute_and_prove(
|
let result = execute_and_prove(
|
||||||
vec![pre_state],
|
vec![pre_state],
|
||||||
Program::serialize_instruction((seed, attested_npk)).unwrap(),
|
Program::serialize_instruction(seed).unwrap(),
|
||||||
vec![3],
|
vec![3],
|
||||||
vec![(wallet_npk, shared_secret)],
|
vec![(wallet_npk, shared_secret)],
|
||||||
vec![],
|
vec![],
|
||||||
@ -2410,52 +2415,112 @@ pub mod tests {
|
|||||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A program must not be allowed to claim a mask-0 (public) account via `Claim::PrivatePda`.
|
/// Happy path for the caller-seeds authorization of a mask-3 PDA. The delegator claims a
|
||||||
/// The circuit panics in `validate_and_sync_states` when the visibility and claim kind
|
/// private PDA via `Claim::Pda(seed)`, then chains to a callee (`noop`) delegating the same
|
||||||
/// disagree.
|
/// seed via `ChainedCall.pda_seeds`. In the callee's step, the `pre_state` is already in
|
||||||
|
/// `self.pre_states` (Occupied branch) and its authorization is established via the private
|
||||||
|
/// derivation `private_pda_account_id(delegator, seed, npk) == pre.account_id`. This exercises
|
||||||
|
/// the `authorized_via_private` path in `validate_and_sync_states`.
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_0_cannot_be_claimed_as_private_pda_panics() {
|
fn caller_pda_seeds_authorize_mask_3_private_pda_for_callee() {
|
||||||
let program = Program::private_pda_claimer();
|
let delegator = Program::private_pda_delegator();
|
||||||
|
let noop = Program::noop();
|
||||||
let keys = test_private_account_keys_1();
|
let keys = test_private_account_keys_1();
|
||||||
let npk = keys.npk();
|
let npk = keys.npk();
|
||||||
let seed = PdaSeed::new([42; 32]);
|
let seed = PdaSeed::new([77; 32]);
|
||||||
|
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
|
||||||
|
|
||||||
// Public account: program_owner = DEFAULT, account_id arbitrary.
|
let account_id = private_pda_account_id(&delegator.id(), &seed, &npk);
|
||||||
let pre_state =
|
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
|
||||||
AccountWithMetadata::new(Account::default(), false, AccountId::new([7; 32]));
|
|
||||||
|
let noop_id = noop.id();
|
||||||
|
let program_with_deps = ProgramWithDependencies::new(delegator, [(noop_id, noop)].into());
|
||||||
|
|
||||||
let result = execute_and_prove(
|
let result = execute_and_prove(
|
||||||
vec![pre_state],
|
vec![pre_state],
|
||||||
Program::serialize_instruction((seed, npk)).unwrap(),
|
Program::serialize_instruction((seed, seed, noop_id)).unwrap(),
|
||||||
vec![0],
|
vec![3],
|
||||||
|
vec![(npk, shared_secret)],
|
||||||
vec![],
|
vec![],
|
||||||
|
vec![None],
|
||||||
|
&program_with_deps,
|
||||||
|
);
|
||||||
|
|
||||||
|
let (output, _proof) =
|
||||||
|
result.expect("caller-seeds authorization of mask-3 private PDA should succeed");
|
||||||
|
assert_eq!(output.new_commitments.len(), 1);
|
||||||
|
assert_eq!(output.new_nullifiers.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The delegator chains with a different seed than the one it claimed with. In the callee
|
||||||
|
/// step, neither public nor private caller-seeds authorization matches; `pre.is_authorized`
|
||||||
|
/// was set to `true` by the delegator but no proven source supports it, so the consistency
|
||||||
|
/// assertion rejects.
|
||||||
|
#[test]
|
||||||
|
fn caller_pda_seeds_with_wrong_seed_rejects_mask_3_for_callee() {
|
||||||
|
let delegator = Program::private_pda_delegator();
|
||||||
|
let noop = Program::noop();
|
||||||
|
let keys = test_private_account_keys_1();
|
||||||
|
let npk = keys.npk();
|
||||||
|
let claim_seed = PdaSeed::new([77; 32]);
|
||||||
|
let wrong_delegated_seed = PdaSeed::new([88; 32]);
|
||||||
|
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
|
||||||
|
|
||||||
|
let account_id = private_pda_account_id(&delegator.id(), &claim_seed, &npk);
|
||||||
|
let pre_state = AccountWithMetadata::new(Account::default(), false, account_id);
|
||||||
|
|
||||||
|
let noop_id = noop.id();
|
||||||
|
let program_with_deps = ProgramWithDependencies::new(delegator, [(noop_id, noop)].into());
|
||||||
|
|
||||||
|
let result = execute_and_prove(
|
||||||
|
vec![pre_state],
|
||||||
|
Program::serialize_instruction((claim_seed, wrong_delegated_seed, noop_id)).unwrap(),
|
||||||
|
vec![3],
|
||||||
|
vec![(npk, shared_secret)],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![None],
|
||||||
&program.into(),
|
&program_with_deps,
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
assert!(matches!(result, Err(NssaError::CircuitProvingError(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A program must not be allowed to claim a mask-3 (private PDA) account via `Claim::Pda`.
|
/// Pins the current limitation: a mask-3 PDA that was claimed in a previous transaction
|
||||||
/// Private PDAs use a distinct derivation and must be claimed with `Claim::PrivatePda`.
|
/// cannot be re-used in a new transaction as-is, because this PR only binds wallet-supplied
|
||||||
|
/// npks via a fresh `Claim::Pda` or a caller's `ChainedCall.pda_seeds` — neither is present
|
||||||
|
/// when a program operates on an already-owned private PDA at top level. The circuit rejects.
|
||||||
|
///
|
||||||
|
/// TODO: a follow-up PR in the Private PDAs series needs to let the wallet supply a
|
||||||
|
/// `(seed, original_owner_program_id)` side input per mask-3 `pre_state` so the circuit
|
||||||
|
/// can re-verify `private_pda_account_id(owner, seed, npk) == pre.account_id` without a
|
||||||
|
/// claim.
|
||||||
#[test]
|
#[test]
|
||||||
fn mask_3_cannot_be_claimed_as_public_pda_panics() {
|
fn mask_3_reuse_across_txs_currently_unsupported() {
|
||||||
let program = Program::pda_claimer();
|
let program = Program::noop();
|
||||||
let seed = PdaSeed::new([42; 32]);
|
let keys = test_private_account_keys_1();
|
||||||
|
let npk = keys.npk();
|
||||||
|
let shared_secret = SharedSecretKey::new(&[55; 32], &keys.vpk());
|
||||||
|
let seed = PdaSeed::new([99; 32]);
|
||||||
|
|
||||||
// The account_id does not need to match any private-PDA derivation; the circuit panics on
|
// Simulate a previously-claimed private PDA: program_owner != DEFAULT, is_authorized = true,
|
||||||
// the mask-3 / `Claim::Pda` mismatch before any derivation check.
|
// account_id derived via the private formula.
|
||||||
let pre_state =
|
let account_id = private_pda_account_id(&program.id(), &seed, &npk);
|
||||||
AccountWithMetadata::new(Account::default(), false, AccountId::new([7; 32]));
|
let owned_pre_state = AccountWithMetadata::new(
|
||||||
|
Account {
|
||||||
|
program_owner: program.id(),
|
||||||
|
..Account::default()
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
account_id,
|
||||||
|
);
|
||||||
|
|
||||||
let result = execute_and_prove(
|
let result = execute_and_prove(
|
||||||
vec![pre_state],
|
vec![owned_pre_state],
|
||||||
Program::serialize_instruction(seed).unwrap(),
|
Program::serialize_instruction(()).unwrap(),
|
||||||
vec![3],
|
vec![3],
|
||||||
|
vec![(npk, shared_secret)],
|
||||||
vec![],
|
vec![],
|
||||||
vec![],
|
vec![None],
|
||||||
vec![],
|
|
||||||
&program.into(),
|
&program.into(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -8,8 +8,7 @@ use nssa_core::{
|
|||||||
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp,
|
BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp,
|
||||||
account::{Account, AccountId, AccountWithMetadata},
|
account::{Account, AccountId, AccountWithMetadata},
|
||||||
program::{
|
program::{
|
||||||
ChainedCall, Claim, DEFAULT_PROGRAM_ID, compute_authorized_pdas, private_pda_account_id,
|
ChainedCall, Claim, DEFAULT_PROGRAM_ID, compute_authorized_pdas, validate_execution,
|
||||||
validate_execution,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -99,7 +98,6 @@ impl ValidatedStateDiff {
|
|||||||
instruction_data: message.instruction_data.clone(),
|
instruction_data: message.instruction_data.clone(),
|
||||||
pre_states: input_pre_states,
|
pre_states: input_pre_states,
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut chained_calls = VecDeque::from_iter([(initial_call, None)]);
|
let mut chained_calls = VecDeque::from_iter([(initial_call, None)]);
|
||||||
@ -130,11 +128,8 @@ impl ValidatedStateDiff {
|
|||||||
chained_call.program_id, program_output
|
chained_call.program_id, program_output
|
||||||
);
|
);
|
||||||
|
|
||||||
let authorized_pdas = compute_authorized_pdas(
|
let authorized_pdas =
|
||||||
caller_program_id,
|
compute_authorized_pdas(caller_program_id, &chained_call.pda_seeds);
|
||||||
&chained_call.pda_seeds,
|
|
||||||
&chained_call.private_pda_seeds,
|
|
||||||
);
|
|
||||||
|
|
||||||
let is_authorized = |account_id: &AccountId| {
|
let is_authorized = |account_id: &AccountId| {
|
||||||
signer_account_ids.contains(account_id) || authorized_pdas.contains(account_id)
|
signer_account_ids.contains(account_id) || authorized_pdas.contains(account_id)
|
||||||
@ -228,7 +223,8 @@ impl ValidatedStateDiff {
|
|||||||
}
|
}
|
||||||
Claim::Pda(seed) => {
|
Claim::Pda(seed) => {
|
||||||
// The program can only claim accounts that correspond to the PDAs it is
|
// The program can only claim accounts that correspond to the PDAs it is
|
||||||
// authorized to claim.
|
// authorized to claim. The public-execution path only sees mask-0
|
||||||
|
// accounts, so the public-PDA derivation is the correct formula here.
|
||||||
let pda = AccountId::from((&chained_call.program_id, &seed));
|
let pda = AccountId::from((&chained_call.program_id, &seed));
|
||||||
ensure!(
|
ensure!(
|
||||||
account_id == pda,
|
account_id == pda,
|
||||||
@ -238,16 +234,6 @@ impl ValidatedStateDiff {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Claim::PrivatePda { seed, npk } => {
|
|
||||||
let pda = private_pda_account_id(&chained_call.program_id, &seed, &npk);
|
|
||||||
ensure!(
|
|
||||||
account_id == pda,
|
|
||||||
InvalidProgramBehaviorError::MismatchedPrivatePdaClaim {
|
|
||||||
expected: pda,
|
|
||||||
actual: account_id
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post.account_mut().program_owner = chained_call.program_id;
|
post.account_mut().program_owner = chained_call.program_id;
|
||||||
|
|||||||
@ -23,18 +23,20 @@ struct ExecutionState {
|
|||||||
post_states: HashMap<AccountId, Account>,
|
post_states: HashMap<AccountId, Account>,
|
||||||
block_validity_window: BlockValidityWindow,
|
block_validity_window: BlockValidityWindow,
|
||||||
timestamp_validity_window: TimestampValidityWindow,
|
timestamp_validity_window: TimestampValidityWindow,
|
||||||
/// Map from private-PDA `AccountId` to the npk used to derive it, sourced entirely from
|
/// Positions (in `pre_states`) of mask-3 accounts whose wallet-supplied npk has been bound
|
||||||
/// Risc0-proven `Claim::PrivatePda` in `post_states` and `private_pda_seeds` in chained
|
/// to their `AccountId` via a proven `private_pda_account_id(program_id, seed, npk)` check.
|
||||||
/// calls. `compute_circuit_output` uses this to verify that the npk supplied via
|
/// The binding happens when the circuit validates a `Claim::Pda(seed)` on that `pre_state`,
|
||||||
/// `private_account_keys` for a mask-3 account matches the npk attested by some program's
|
/// or when it authorizes that `pre_state` via a caller's `ChainedCall.pda_seeds`. After the
|
||||||
/// proof.
|
/// main loop, every mask-3 position must appear in this set; otherwise the npk is unbound
|
||||||
private_pda_bindings: HashMap<AccountId, NullifierPublicKey>,
|
/// and the circuit rejects.
|
||||||
|
mask3_bound_positions: HashSet<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ExecutionState {
|
impl ExecutionState {
|
||||||
/// Validate program outputs and derive the overall execution state.
|
/// Validate program outputs and derive the overall execution state.
|
||||||
pub fn derive_from_outputs(
|
pub fn derive_from_outputs(
|
||||||
visibility_mask: &[u8],
|
visibility_mask: &[u8],
|
||||||
|
mask3_npk_by_position: &HashMap<usize, NullifierPublicKey>,
|
||||||
program_id: ProgramId,
|
program_id: ProgramId,
|
||||||
program_outputs: Vec<ProgramOutput>,
|
program_outputs: Vec<ProgramOutput>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@ -72,7 +74,7 @@ impl ExecutionState {
|
|||||||
post_states: HashMap::new(),
|
post_states: HashMap::new(),
|
||||||
block_validity_window,
|
block_validity_window,
|
||||||
timestamp_validity_window,
|
timestamp_validity_window,
|
||||||
private_pda_bindings: HashMap::new(),
|
mask3_bound_positions: HashSet::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let Some(first_output) = program_outputs.first() else {
|
let Some(first_output) = program_outputs.first() else {
|
||||||
@ -84,7 +86,6 @@ impl ExecutionState {
|
|||||||
instruction_data: first_output.instruction_data.clone(),
|
instruction_data: first_output.instruction_data.clone(),
|
||||||
pre_states: first_output.pre_states.clone(),
|
pre_states: first_output.pre_states.clone(),
|
||||||
pda_seeds: Vec::new(),
|
pda_seeds: Vec::new(),
|
||||||
private_pda_seeds: Vec::new(),
|
|
||||||
};
|
};
|
||||||
let mut chained_calls = VecDeque::from_iter([(initial_call, None)]);
|
let mut chained_calls = VecDeque::from_iter([(initial_call, None)]);
|
||||||
|
|
||||||
@ -145,40 +146,21 @@ impl ExecutionState {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect private-PDA bindings from this program_output's proven data. Each
|
|
||||||
// `private_pda_seeds` entry in an outgoing chained call attests that the caller
|
|
||||||
// (this program) authorizes the callee to mutate the PDA derived from
|
|
||||||
// `(self_program_id, seed, npk)`. Each `Claim::PrivatePda` in this program's
|
|
||||||
// post_states attests that it claims the PDA derived from the same formula with
|
|
||||||
// its own program_id.
|
|
||||||
for next_call in &program_output.chained_calls {
|
|
||||||
for (seed, npk) in &next_call.private_pda_seeds {
|
|
||||||
let account_id = private_pda_account_id(&chained_call.program_id, seed, npk);
|
|
||||||
execution_state
|
|
||||||
.private_pda_bindings
|
|
||||||
.insert(account_id, *npk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for post in &program_output.post_states {
|
|
||||||
if let Some(Claim::PrivatePda { seed, npk }) = post.required_claim() {
|
|
||||||
let account_id = private_pda_account_id(&chained_call.program_id, &seed, &npk);
|
|
||||||
execution_state.private_pda_bindings.insert(account_id, npk);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for next_call in program_output.chained_calls.iter().rev() {
|
for next_call in program_output.chained_calls.iter().rev() {
|
||||||
chained_calls.push_front((next_call.clone(), Some(chained_call.program_id)));
|
chained_calls.push_front((next_call.clone(), Some(chained_call.program_id)));
|
||||||
}
|
}
|
||||||
|
|
||||||
let authorized_pdas = nssa_core::program::compute_authorized_pdas(
|
let authorized_public_pdas = nssa_core::program::compute_authorized_pdas(
|
||||||
caller_program_id,
|
caller_program_id,
|
||||||
&chained_call.pda_seeds,
|
&chained_call.pda_seeds,
|
||||||
&chained_call.private_pda_seeds,
|
|
||||||
);
|
);
|
||||||
execution_state.validate_and_sync_states(
|
execution_state.validate_and_sync_states(
|
||||||
visibility_mask,
|
visibility_mask,
|
||||||
|
mask3_npk_by_position,
|
||||||
chained_call.program_id,
|
chained_call.program_id,
|
||||||
&authorized_pdas,
|
caller_program_id,
|
||||||
|
&chained_call.pda_seeds,
|
||||||
|
&authorized_public_pdas,
|
||||||
program_output.pre_states,
|
program_output.pre_states,
|
||||||
program_output.post_states,
|
program_output.post_states,
|
||||||
);
|
);
|
||||||
@ -192,6 +174,19 @@ impl ExecutionState {
|
|||||||
"Inner call without a chained call found",
|
"Inner call without a chained call found",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Every mask-3 pre_state must have had its npk bound to its account_id, either via a
|
||||||
|
// `Claim::Pda(seed)` in some program's post_state or via a caller's `pda_seeds` matching
|
||||||
|
// the private derivation. An unbound mask-3 pre_state has no cryptographic link between
|
||||||
|
// the wallet-supplied npk and the account_id, and must be rejected.
|
||||||
|
for (pos, &mask) in visibility_mask.iter().enumerate() {
|
||||||
|
if mask == 3 {
|
||||||
|
assert!(
|
||||||
|
execution_state.mask3_bound_positions.contains(&pos),
|
||||||
|
"mask-3 pre_state at position {pos} has no proven (seed, npk) binding via Claim::Pda or caller pda_seeds"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check that all modified uninitialized accounts were claimed
|
// Check that all modified uninitialized accounts were claimed
|
||||||
for (account_id, post) in execution_state
|
for (account_id, post) in execution_state
|
||||||
.pre_states
|
.pre_states
|
||||||
@ -217,11 +212,15 @@ impl ExecutionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Validate program pre and post states and populate the execution state.
|
/// Validate program pre and post states and populate the execution state.
|
||||||
|
#[expect(clippy::too_many_arguments, reason = "breaking out a context struct does not buy us anything here")]
|
||||||
fn validate_and_sync_states(
|
fn validate_and_sync_states(
|
||||||
&mut self,
|
&mut self,
|
||||||
visibility_mask: &[u8],
|
visibility_mask: &[u8],
|
||||||
|
mask3_npk_by_position: &HashMap<usize, NullifierPublicKey>,
|
||||||
program_id: ProgramId,
|
program_id: ProgramId,
|
||||||
authorized_pdas: &HashSet<AccountId>,
|
caller_program_id: Option<ProgramId>,
|
||||||
|
caller_pda_seeds: &[nssa_core::program::PdaSeed],
|
||||||
|
authorized_public_pdas: &HashSet<AccountId>,
|
||||||
pre_states: Vec<AccountWithMetadata>,
|
pre_states: Vec<AccountWithMetadata>,
|
||||||
post_states: Vec<AccountPostState>,
|
post_states: Vec<AccountPostState>,
|
||||||
) {
|
) {
|
||||||
@ -248,19 +247,40 @@ impl ExecutionState {
|
|||||||
"Inconsistent pre state for account {pre_account_id}",
|
"Inconsistent pre state for account {pre_account_id}",
|
||||||
);
|
);
|
||||||
|
|
||||||
let previous_is_authorized = self
|
let (previous_is_authorized, pre_state_position) = self
|
||||||
.pre_states
|
.pre_states
|
||||||
.iter()
|
.iter()
|
||||||
.find(|acc| acc.account_id == pre_account_id)
|
.enumerate()
|
||||||
|
.find(|(_, acc)| acc.account_id == pre_account_id)
|
||||||
.map_or_else(
|
.map_or_else(
|
||||||
|| panic!(
|
|| panic!(
|
||||||
"Pre state must exist in execution state for account {pre_account_id}",
|
"Pre state must exist in execution state for account {pre_account_id}",
|
||||||
),
|
),
|
||||||
|acc| acc.is_authorized
|
|(pos, acc)| (acc.is_authorized, pos)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let authorized_via_public = authorized_public_pdas.contains(&pre_account_id);
|
||||||
|
// Mask-3 PDAs are authorized by matching a caller seed against the private
|
||||||
|
// derivation with this pre_state's npk. The equality check binds the npk.
|
||||||
|
// 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 below.
|
||||||
|
let authorized_via_private = mask3_npk_by_position
|
||||||
|
.get(&pre_state_position)
|
||||||
|
.and_then(|npk| {
|
||||||
|
let caller = caller_program_id?;
|
||||||
|
caller_pda_seeds.iter().find(|seed| {
|
||||||
|
private_pda_account_id(&caller, seed, npk) == pre_account_id
|
||||||
|
})?;
|
||||||
|
Some(())
|
||||||
|
})
|
||||||
|
.is_some();
|
||||||
|
if authorized_via_private {
|
||||||
|
self.mask3_bound_positions.insert(pre_state_position);
|
||||||
|
}
|
||||||
|
|
||||||
let is_authorized =
|
let is_authorized =
|
||||||
previous_is_authorized || authorized_pdas.contains(&pre_account_id);
|
previous_is_authorized || authorized_via_public || authorized_via_private;
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pre_is_authorized, is_authorized,
|
pre_is_authorized, is_authorized,
|
||||||
@ -287,10 +307,9 @@ impl ExecutionState {
|
|||||||
.position(|acc| acc.account_id == pre_account_id)
|
.position(|acc| acc.account_id == pre_account_id)
|
||||||
.expect("Pre state must exist at this point");
|
.expect("Pre state must exist at this point");
|
||||||
|
|
||||||
let is_public_account = visibility_mask[pre_state_position] == 0;
|
let mask = visibility_mask[pre_state_position];
|
||||||
let is_private_pda = visibility_mask[pre_state_position] == 3;
|
match mask {
|
||||||
if is_public_account {
|
0 => match claim {
|
||||||
match claim {
|
|
||||||
Claim::Authorized => {
|
Claim::Authorized => {
|
||||||
// Note: no need to check authorized pdas because we have already
|
// Note: no need to check authorized pdas because we have already
|
||||||
// checked consistency of authorization above.
|
// checked consistency of authorization above.
|
||||||
@ -306,35 +325,31 @@ impl ExecutionState {
|
|||||||
"Invalid PDA claim for account {pre_account_id} which does not match derived PDA {pda}"
|
"Invalid PDA claim for account {pre_account_id} which does not match derived PDA {pda}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Claim::PrivatePda { .. } => {
|
},
|
||||||
panic!(
|
3 => match claim {
|
||||||
"Public account {pre_account_id} cannot be claimed via Claim::PrivatePda"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if is_private_pda {
|
|
||||||
match claim {
|
|
||||||
Claim::Authorized => {
|
Claim::Authorized => {
|
||||||
assert!(
|
assert!(
|
||||||
pre_is_authorized,
|
pre_is_authorized,
|
||||||
"Cannot claim unauthorized private PDA {pre_account_id}"
|
"Cannot claim unauthorized private PDA {pre_account_id}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Claim::PrivatePda { seed, npk } => {
|
Claim::Pda(seed) => {
|
||||||
let pda = private_pda_account_id(&program_id, &seed, &npk);
|
let npk = mask3_npk_by_position
|
||||||
|
.get(&pre_state_position)
|
||||||
|
.expect("mask-3 pre_state must have an npk in the position map");
|
||||||
|
let pda = private_pda_account_id(&program_id, &seed, npk);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
pre_account_id, pda,
|
pre_account_id, pda,
|
||||||
"Invalid private PDA claim for account {pre_account_id}"
|
"Invalid private PDA claim for account {pre_account_id}"
|
||||||
);
|
);
|
||||||
|
self.mask3_bound_positions.insert(pre_state_position);
|
||||||
}
|
}
|
||||||
Claim::Pda(_) => {
|
},
|
||||||
panic!(
|
_ => {
|
||||||
"Private PDA {pre_account_id} must be claimed via Claim::PrivatePda, not Claim::Pda"
|
// Mask 1/2: standard private accounts don't enforce the claim semantics.
|
||||||
);
|
// Unauthorized private claiming is intentionally allowed since operating
|
||||||
}
|
// these accounts requires the npk/nsk keypair anyway.
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// Mask 1/2: standard private accounts don't use PDA claims.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
post.account_mut().program_owner = program_id;
|
post.account_mut().program_owner = program_id;
|
||||||
@ -359,7 +374,7 @@ impl ExecutionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn compute_circuit_output(
|
fn compute_circuit_output(
|
||||||
mut execution_state: ExecutionState,
|
execution_state: ExecutionState,
|
||||||
visibility_mask: &[u8],
|
visibility_mask: &[u8],
|
||||||
private_account_keys: &[(NullifierPublicKey, SharedSecretKey)],
|
private_account_keys: &[(NullifierPublicKey, SharedSecretKey)],
|
||||||
private_account_nsks: &[NullifierSecretKey],
|
private_account_nsks: &[NullifierSecretKey],
|
||||||
@ -374,7 +389,6 @@ fn compute_circuit_output(
|
|||||||
block_validity_window: execution_state.block_validity_window,
|
block_validity_window: execution_state.block_validity_window,
|
||||||
timestamp_validity_window: execution_state.timestamp_validity_window,
|
timestamp_validity_window: execution_state.timestamp_validity_window,
|
||||||
};
|
};
|
||||||
let private_pda_bindings = std::mem::take(&mut execution_state.private_pda_bindings);
|
|
||||||
|
|
||||||
let states_iter = execution_state.into_states_iter();
|
let states_iter = execution_state.into_states_iter();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@ -495,21 +509,16 @@ fn compute_circuit_output(
|
|||||||
.unwrap_or_else(|| panic!("Too many private accounts, output index overflow"));
|
.unwrap_or_else(|| panic!("Too many private accounts, output index overflow"));
|
||||||
}
|
}
|
||||||
3 => {
|
3 => {
|
||||||
// Private PDA account. The npk supplied via private_account_keys must match the
|
// Private PDA account. The wallet-supplied npk has already been bound to
|
||||||
// npk attested by some program's Risc0-proven output (either a `Claim::PrivatePda`
|
// `pre_state.account_id` upstream in `validate_and_sync_states`, either via a
|
||||||
// in post_states or a `private_pda_seeds` entry in a chained call). The bindings
|
// `Claim::Pda(seed)` match or via a caller `pda_seeds` match, both of which
|
||||||
// map is built entirely from proven data in `derive_from_outputs`.
|
// assert `private_pda_account_id(owner, seed, npk) == account_id`. The post-loop
|
||||||
|
// assertion in `derive_from_outputs` (see the `mask3_bound_positions` check)
|
||||||
|
// guarantees that every mask-3 position has been through at least one such
|
||||||
|
// binding, so this branch can safely use the wallet npk without re-verifying.
|
||||||
let Some((npk, shared_secret)) = private_keys_iter.next() else {
|
let Some((npk, shared_secret)) = private_keys_iter.next() else {
|
||||||
panic!("Missing private account key");
|
panic!("Missing private account key");
|
||||||
};
|
};
|
||||||
let attested_npk = private_pda_bindings.get(&pre_state.account_id).expect(
|
|
||||||
"mask-3 account must be attested by a proven Claim::PrivatePda or ChainedCall.private_pda_seeds entry",
|
|
||||||
);
|
|
||||||
assert_eq!(
|
|
||||||
npk, attested_npk,
|
|
||||||
"Private PDA npk does not match proven attestation for {}",
|
|
||||||
pre_state.account_id
|
|
||||||
);
|
|
||||||
|
|
||||||
let (new_nullifier, new_nonce) = if pre_state.is_authorized {
|
let (new_nullifier, new_nonce) = if pre_state.is_authorized {
|
||||||
// Existing private PDA with authentication (like mask 1)
|
// Existing private PDA with authentication (like mask 1)
|
||||||
@ -636,8 +645,33 @@ fn main() {
|
|||||||
program_id,
|
program_id,
|
||||||
} = env::read();
|
} = env::read();
|
||||||
|
|
||||||
let execution_state =
|
// Build a position → npk map for mask-3 pre_states. `private_account_keys` is consumed in
|
||||||
ExecutionState::derive_from_outputs(&visibility_mask, program_id, program_outputs);
|
// pre_state order across all masks 1/2/3, so walk `visibility_mask` in lock-step. The
|
||||||
|
// downstream `compute_circuit_output` also consumes the same iterator and its trailing
|
||||||
|
// assertions catch an over-supply of keys; under-supply surfaces here.
|
||||||
|
let mut mask3_npk_by_position: HashMap<usize, NullifierPublicKey> = HashMap::new();
|
||||||
|
{
|
||||||
|
let mut keys_iter = private_account_keys.iter();
|
||||||
|
for (pos, &mask) in visibility_mask.iter().enumerate() {
|
||||||
|
if matches!(mask, 1..=3) {
|
||||||
|
let (npk, _) = keys_iter.next().unwrap_or_else(|| {
|
||||||
|
panic!(
|
||||||
|
"private_account_keys shorter than visibility_mask demands: no key for masked position {pos} (mask {mask})"
|
||||||
|
)
|
||||||
|
});
|
||||||
|
if mask == 3 {
|
||||||
|
mask3_npk_by_position.insert(pos, *npk);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let execution_state = ExecutionState::derive_from_outputs(
|
||||||
|
&visibility_mask,
|
||||||
|
&mask3_npk_by_position,
|
||||||
|
program_id,
|
||||||
|
program_outputs,
|
||||||
|
);
|
||||||
|
|
||||||
let output = compute_circuit_output(
|
let output = compute_circuit_output(
|
||||||
execution_state,
|
execution_state,
|
||||||
|
|||||||
@ -41,7 +41,6 @@ fn main() {
|
|||||||
instruction_data: instruction_data.clone(),
|
instruction_data: instruction_data.clone(),
|
||||||
pre_states: vec![running_sender_pre.clone(), running_recipient_pre.clone()], /* <- Account order permutation here */
|
pre_states: vec![running_sender_pre.clone(), running_recipient_pre.clone()], /* <- Account order permutation here */
|
||||||
pda_seeds: pda_seed.iter().copied().collect(),
|
pda_seeds: pda_seed.iter().copied().collect(),
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
chained_calls.push(new_chained_call);
|
chained_calls.push(new_chained_call);
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,6 @@ fn main() {
|
|||||||
instruction_data: to_vec(×tamp).unwrap(),
|
instruction_data: to_vec(×tamp).unwrap(),
|
||||||
pre_states: pre_states.clone(),
|
pre_states: pre_states.clone(),
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ProgramOutput::new(
|
ProgramOutput::new(
|
||||||
|
|||||||
@ -71,7 +71,6 @@ fn main() {
|
|||||||
pre_states: vec![receiver_authorized, vault_pre.clone()],
|
pre_states: vec![receiver_authorized, vault_pre.clone()],
|
||||||
instruction_data: transfer_instruction,
|
instruction_data: transfer_instruction,
|
||||||
pda_seeds: vec![PdaSeed::new([1_u8; 32])],
|
pda_seeds: vec![PdaSeed::new([1_u8; 32])],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Malicious path (return_funds = false): emit no chained calls.
|
// Malicious path (return_funds = false): emit no chained calls.
|
||||||
|
|||||||
@ -129,7 +129,6 @@ fn main() {
|
|||||||
pre_states: vec![vault_authorized, receiver_pre.clone()],
|
pre_states: vec![vault_authorized, receiver_pre.clone()],
|
||||||
instruction_data: transfer_instruction,
|
instruction_data: transfer_instruction,
|
||||||
pda_seeds: vec![PdaSeed::new([0_u8; 32])],
|
pda_seeds: vec![PdaSeed::new([0_u8; 32])],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chained call 2: User callback.
|
// Chained call 2: User callback.
|
||||||
@ -140,7 +139,6 @@ fn main() {
|
|||||||
pre_states: vec![vault_after_transfer, receiver_after_transfer],
|
pre_states: vec![vault_after_transfer, receiver_after_transfer],
|
||||||
instruction_data: callback_instruction_data,
|
instruction_data: callback_instruction_data,
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Chained call 3: Self-call to enforce the invariant.
|
// Chained call 3: Self-call to enforce the invariant.
|
||||||
@ -159,7 +157,6 @@ fn main() {
|
|||||||
pre_states: vec![vault_after_callback],
|
pre_states: vec![vault_after_callback],
|
||||||
instruction_data: invariant_instruction,
|
instruction_data: invariant_instruction,
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// The initiator itself makes no direct state changes.
|
// The initiator itself makes no direct state changes.
|
||||||
|
|||||||
@ -39,7 +39,6 @@ fn main() {
|
|||||||
instruction_data,
|
instruction_data,
|
||||||
pre_states: vec![authorised_sender, receiver.clone()],
|
pre_states: vec![authorised_sender, receiver.clone()],
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ProgramOutput::new(
|
ProgramOutput::new(
|
||||||
|
|||||||
@ -1,34 +0,0 @@
|
|||||||
use nssa_core::{
|
|
||||||
NullifierPublicKey,
|
|
||||||
program::{AccountPostState, Claim, PdaSeed, ProgramInput, ProgramOutput, read_nssa_inputs},
|
|
||||||
};
|
|
||||||
|
|
||||||
type Instruction = (PdaSeed, NullifierPublicKey);
|
|
||||||
|
|
||||||
fn main() {
|
|
||||||
let (
|
|
||||||
ProgramInput {
|
|
||||||
self_program_id,
|
|
||||||
caller_program_id,
|
|
||||||
pre_states,
|
|
||||||
instruction: (seed, npk),
|
|
||||||
},
|
|
||||||
instruction_words,
|
|
||||||
) = read_nssa_inputs::<Instruction>();
|
|
||||||
|
|
||||||
let Ok([pre]) = <[_; 1]>::try_from(pre_states) else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let account_post =
|
|
||||||
AccountPostState::new_claimed(pre.account.clone(), Claim::PrivatePda { seed, npk });
|
|
||||||
|
|
||||||
ProgramOutput::new(
|
|
||||||
self_program_id,
|
|
||||||
caller_program_id,
|
|
||||||
instruction_words,
|
|
||||||
vec![pre],
|
|
||||||
vec![account_post],
|
|
||||||
)
|
|
||||||
.write();
|
|
||||||
}
|
|
||||||
51
test_program_methods/guest/src/bin/private_pda_delegator.rs
Normal file
51
test_program_methods/guest/src/bin/private_pda_delegator.rs
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
use nssa_core::program::{
|
||||||
|
AccountPostState, ChainedCall, Claim, PdaSeed, ProgramId, ProgramInput, ProgramOutput,
|
||||||
|
read_nssa_inputs,
|
||||||
|
};
|
||||||
|
use risc0_zkvm::serde::to_vec;
|
||||||
|
|
||||||
|
/// Claims the sole `pre_state` as a PDA with `claim_seed`, then chains to `callee_program_id`
|
||||||
|
/// delegating authorization with `delegated_seed` in `pda_seeds`. When `claim_seed ==
|
||||||
|
/// delegated_seed` this exercises the happy caller-seeds authorization path for mask-3 private
|
||||||
|
/// PDAs in `validate_and_sync_states`; when they differ, the callee's mask-3 `pre_state` has
|
||||||
|
/// no matching authorization source and the circuit must reject.
|
||||||
|
type Instruction = (PdaSeed, PdaSeed, ProgramId);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let (
|
||||||
|
ProgramInput {
|
||||||
|
self_program_id,
|
||||||
|
caller_program_id,
|
||||||
|
pre_states,
|
||||||
|
instruction: (claim_seed, delegated_seed, callee_program_id),
|
||||||
|
},
|
||||||
|
instruction_words,
|
||||||
|
) = read_nssa_inputs::<Instruction>();
|
||||||
|
|
||||||
|
let Ok([pre]) = <[_; 1]>::try_from(pre_states) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let claimed = AccountPostState::new_claimed(pre.account.clone(), Claim::Pda(claim_seed));
|
||||||
|
|
||||||
|
let mut pre_for_callee = pre.clone();
|
||||||
|
pre_for_callee.is_authorized = true;
|
||||||
|
pre_for_callee.account.program_owner = self_program_id;
|
||||||
|
|
||||||
|
let chained_call = ChainedCall {
|
||||||
|
program_id: callee_program_id,
|
||||||
|
instruction_data: to_vec(&()).unwrap(),
|
||||||
|
pre_states: vec![pre_for_callee],
|
||||||
|
pda_seeds: vec![delegated_seed],
|
||||||
|
};
|
||||||
|
|
||||||
|
ProgramOutput::new(
|
||||||
|
self_program_id,
|
||||||
|
caller_program_id,
|
||||||
|
instruction_words,
|
||||||
|
vec![pre],
|
||||||
|
vec![claimed],
|
||||||
|
)
|
||||||
|
.with_chained_calls(vec![chained_call])
|
||||||
|
.write();
|
||||||
|
}
|
||||||
@ -37,7 +37,6 @@ fn main() {
|
|||||||
instruction_data: chained_instruction,
|
instruction_data: chained_instruction,
|
||||||
pre_states,
|
pre_states,
|
||||||
pda_seeds: vec![],
|
pda_seeds: vec![],
|
||||||
private_pda_seeds: vec![],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ProgramOutput::new(
|
ProgramOutput::new(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user