From 77fc3176c05b9a7bcbba60fb182ec4113a0d89ca Mon Sep 17 00:00:00 2001 From: Artem Gureev Date: Mon, 24 Aug 2026 18:40:58 +0000 Subject: [PATCH] test(lee): pin the System Program clear branch invariants --- .../circuit/tests.rs | 136 ++++++------------ 1 file changed, 44 insertions(+), 92 deletions(-) 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 ee63e8569..b5f2ca0ae 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -6,8 +6,8 @@ use lee_core::{ PrivacyPreservingCircuitOutput, PrivateWitness, SharedSecretKey, WitnessKind, account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, program::{ - AccountPostState, DEFAULT_PROGRAM_ID, DEFAULT_PROGRAM_OWNER, PdaSeed, PrivateAccountKind, - ProgramOutput, SystemInstruction, + AccountPostState, ChainedCall, Claim, DEFAULT_PROGRAM_ID, DEFAULT_PROGRAM_OWNER, PdaSeed, + PrivateAccountKind, ProgramOutput, SystemInstruction, }, }; @@ -1281,59 +1281,6 @@ fn private_default_owner_credited_without_claim_succeeds() { assert!(recipient_post.balance > 0); } -/// The credit is one-way: a default-owned private note cannot be debited, because rule 5 gates -/// balance decreases on the owner executing and the default owner never executes. -#[test] -fn private_default_owner_debit_is_rejected() { - let program = crate::test_methods::modified_transfer_program(); - let keys = test_private_account_keys_1(); - let account_id = AccountId::for_regular_private_account(&keys.npk(), &keys.vpk(), 0); - let sender_account = Account { - program_owner: DEFAULT_PROGRAM_OWNER, - balance: 500_000, - data: Data::default(), - nonce: Nonce(0), - }; - let commitment = Commitment::new(&account_id, &sender_account); - let mut commitment_set = CommitmentSet::with_capacity(1); - commitment_set.extend(std::slice::from_ref(&commitment)); - let membership_proof = commitment_set.get_proof_for(&commitment).unwrap(); - let sender = AccountWithMetadata::new(sender_account, true, account_id); - - let recipient = AccountWithMetadata::new( - Account { - program_owner: program.id().into(), - ..Account::default() - }, - false, - AccountId::new([88; 32]), - ); - - let result = execute_and_prove( - vec![sender, recipient], - Program::serialize_instruction(1_u128).unwrap(), - vec![ - InputAccountIdentity::Private(PrivateWitness { - vpk: keys.vpk(), - random_seed: [0; 32], - identifier: 0, - kind: WitnessKind::Regular { - ask: Some(keys.ask), - }, - nullifier: NullifierWitness::Update { - view_tag: 0, - nsk: keys.nsk(), - membership_proof, - }, - }), - InputAccountIdentity::Public, - ], - &program.into(), - ); - - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); -} - fn prove_forged_clear( new_owner: AccountId, forged_post: Account, @@ -1344,10 +1291,10 @@ fn prove_forged_clear( prove_synthesized_input(&circuit_input) } -/// A clear cannot mint: a post claiming more balance than the note carried is not the canonical -/// cleared account, so the circuit refuses to prove. +/// The post must be exactly the canonical cleared account: a forged post — here one minting +/// balance the note never had — is rejected. #[test] -fn private_clear_inflating_balance_is_rejected() { +fn private_clear_with_forged_post_is_rejected() { let forged = Account { program_owner: DEFAULT_PROGRAM_OWNER, balance: 56, @@ -1361,40 +1308,6 @@ fn private_clear_inflating_balance_is_rejected() { )); } -/// A clear must zero the data: a post that keeps the note's data is not the canonical cleared -/// account, so the circuit refuses to prove. -#[test] -fn private_clear_retaining_data_is_rejected() { - let forged = Account { - program_owner: DEFAULT_PROGRAM_OWNER, - balance: 55, - data: vec![1_u8, 2, 3].try_into().unwrap(), - nonce: Nonce(3), - }; - - assert!(matches!( - prove_forged_clear(DEFAULT_PROGRAM_OWNER, forged), - Err(LeeError::CircuitProvingError(_)) - )); -} - -/// The new owner is pinned by the declared instruction: a post handing the note to an owner the -/// `Clear` did not declare is rejected. -#[test] -fn private_clear_to_undeclared_owner_is_rejected() { - let forged = Account { - program_owner: AccountId::new([9; 32]), - balance: 55, - data: Data::default(), - nonce: Nonce(3), - }; - - assert!(matches!( - prove_forged_clear(AccountId::new([7; 32]), forged), - Err(LeeError::CircuitProvingError(_)) - )); -} - /// A clear must post exactly one state per cleared note: dropping the post state is rejected /// before any post is inspected. #[test] @@ -1405,3 +1318,42 @@ fn private_clear_length_mismatch_is_rejected() { let result = prove_synthesized_input(&circuit_input); assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); } + +/// The System Program is a leaf: a clear that emits a chained call is rejected even when the +/// follow-on call and its output are themselves valid. +#[test] +fn private_clear_emitting_chained_calls_is_rejected() { + let (account_id, _, cleared, mut circuit_input) = + clear_note_circuit_input(DEFAULT_PROGRAM_OWNER, true); + let instruction_data = circuit_input.program_outputs[0].instruction_data.clone(); + let follow_on_pre = AccountWithMetadata::new(cleared.clone(), true, account_id); + + circuit_input.program_outputs[0].chained_calls = vec![ChainedCall { + program_id: DEFAULT_PROGRAM_ID, + instruction_data: instruction_data.clone(), + pre_states: vec![follow_on_pre.clone()], + pda_seeds: Vec::new(), + }]; + circuit_input.program_outputs.push(ProgramOutput::new( + DEFAULT_PROGRAM_ID, + Some(DEFAULT_PROGRAM_ID), + instruction_data, + vec![follow_on_pre], + vec![AccountPostState::new(cleared)], + )); + + let result = prove_synthesized_input(&circuit_input); + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +} + +/// A clear takes no ownership: a post carrying a claim is rejected, so the System Program can +/// never route an account through the claim path. +#[test] +fn private_clear_requesting_a_claim_is_rejected() { + let (.., cleared, mut circuit_input) = clear_note_circuit_input(DEFAULT_PROGRAM_OWNER, true); + circuit_input.program_outputs[0].post_states = + vec![AccountPostState::new_claimed(cleared, Claim::Authorized)]; + + let result = prove_synthesized_input(&circuit_input); + assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); +}