diff --git a/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin b/artifacts/lee/privacy_preserving_circuit/privacy_preserving_circuit.bin index 7ec44810d..16f6c4db1 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 00637d3d7..463a5f578 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 b1053d2d6..0babddd0d 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 d9f22291d..5c73a275e 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 6dd655a3f..0a8d8ccf3 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 0d65aac75..4361df29d 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 0133ff3ab..0125e9722 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 bf6bea73d..2646112e3 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 f3a36cfd5..9ffd10e41 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 91f1a44be..cf998e78f 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 599e5b274..69a284c56 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 b066ed411..6fea3fa88 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 1d124a6c9..5f47bee4d 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 8fc3c747c..999d0b1fe 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 ea42e6e37..be0dcd1f8 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 568bbd22d..199393894 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 af70e7f8b..ceaaa7770 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 038110478..7f3bfd7cf 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 d2e55af50..2cb2b4fca 100644 --- a/lee/privacy_preserving_circuit/src/execution_state.rs +++ b/lee/privacy_preserving_circuit/src/execution_state.rs @@ -4,7 +4,7 @@ use std::{ }; use lee_core::{ - Identifier, InputAccountIdentity, NullifierPublicKey, PrivateWitness, WitnessKind, + Identifier, InputAccountIdentity, NullifierPublicKey, PrivateWitness, PublicDiff, WitnessKind, account::{Account, AccountId, AccountWithMetadata, Data, apply_balance_diff}, encryption::ViewingPublicKey, program::{ @@ -54,6 +54,16 @@ pub struct ExecutionState { /// The set containing non-PDA accounts authorized at their first sight, anywhere in the /// call tree, remaining authorized throughout all calls. globally_authorized: HashSet, + /// The canonical, committed signer set. `is_authorized` for a public account's first sighting + /// is derived from membership in this list (or PDA-authorization via a caller's seeds) — + /// never trusted as an independently-reported witness — and the list itself is echoed in the + /// circuit's output so the sequencer can cross-check it against real signatures. + signer_account_ids: Vec, + /// Raw, per-call, unaggregated diffs for public accounts. This, not `post_states`, is what + /// the circuit ultimately outputs for public accounts: `post_states` is only ever used + /// internally, to give a later call in the same chain a concrete `pre_state` for an account + /// an earlier call already touched. See `PrivacyPreservingCircuitOutput::public_diffs`. + public_diffs: Vec, } impl ExecutionState { @@ -62,6 +72,7 @@ impl ExecutionState { account_identities: &[InputAccountIdentity], program_id: ProgramId, program_outputs: Vec, + signer_account_ids: &[AccountId], update_from_diff_results: Vec, ) -> Self { let mut update_from_diff_results: VecDeque = update_from_diff_results.into(); @@ -117,6 +128,8 @@ impl ExecutionState { pda_family_binding: HashMap::new(), private_pda_by_position, globally_authorized: HashSet::new(), + signer_account_ids: signer_account_ids.to_vec(), + public_diffs: Vec::new(), }; let Some(first_output) = program_outputs.first() else { @@ -400,6 +413,24 @@ impl ExecutionState { pre_is_authorized, ); } + // First sighting of a non-PDA-init account (`!has_private_pda_witness`): + // this still runs unconditionally for its caller-PDA-seed-matching side + // effects (a private PDA without an external seed legitimately authorizes + // via the caller's `pda_seeds`), but the signer-list-derived result is only + // *enforced* against `pre.is_authorized` for public accounts, inside + // `authorize_first_sight_without_pda_witness`: `is_authorized` has no + // downstream security consequence for private accounts (claim semantics are + // entirely bypassed for them), and a private `AccountId` can never + // legitimately appear in `signer_account_ids` (it's derived from `npk`/`vpk`, + // not a real-world signature). + // + // Replaces the guarantee live-state reconstruction used to provide: once the + // sequencer stops reconstructing pre-states from live state (PR3), this is + // the circuit's only independent check that `is_authorized` for a top-level + // public account was derived honestly rather than self-reported. + let is_public = account_identities + .get(pre_state_position) + .is_some_and(InputAccountIdentity::is_public); if !has_private_pda_witness && authorize_first_sight_without_pda_witness( &mut self.pda_family_binding, @@ -408,6 +439,8 @@ impl ExecutionState { caller_pda_seeds, pre_account_id, pre_is_authorized, + is_public, + self.signer_account_ids.contains(&pre_account_id), ) { // authorize_first_sight_without_pda_witness is only true for PDAs @@ -428,6 +461,13 @@ impl ExecutionState { authorized_output_accounts.push(pre_account_id); } + let pre_state_position = self + .pre_states + .iter() + .position(|acc| acc.account_id == pre_account_id) + .expect("Pre state must exist at this point"); + let account_identity = &account_identities[pre_state_position]; + let diff = diff_output.diff(); let balance = apply_balance_diff(pre_account.balance, diff.diff_balance) @@ -469,13 +509,6 @@ impl ExecutionState { "Cannot claim an initialized account {pre_account_id}" ); - let pre_state_position = self - .pre_states - .iter() - .position(|acc| acc.account_id == pre_account_id) - .expect("Pre state must exist at this point"); - - let account_identity = &account_identities[pre_state_position]; if account_identity.is_public() { match claim { Claim::Authorized => { @@ -544,6 +577,14 @@ impl ExecutionState { pre_account.program_owner }; + if account_identity.is_public() { + self.public_diffs.push(PublicDiff { + account_id: pre_account_id, + executing_program_id: program_id, + diff: diff_output, + }); + } + post_states_entry.insert_entry(Account { program_owner: post_program_owner, balance, @@ -558,12 +599,16 @@ impl ExecutionState { } /// Consume self and yield the validity windows, the per-position PDA seed/program map - /// (recorded during `derive_from_outputs`), and an iterator over pre and post states of each - /// account involved in the execution. Returning everything together keeps the - /// fields module-private rather than forcing them visible to downstream consumers. + /// (recorded during `derive_from_outputs`), the committed signer set, the raw per-call + /// public diffs, and an iterator over pre and (internally materialized) post states of every + /// account involved in the execution. The materialized post state is only authoritative for + /// private accounts — for public ones it was only ever needed internally, for + /// chain-threading; `public_diffs` is the real output for those. Returning everything + /// together keeps the fields module-private rather than forcing them visible to downstream + /// consumers. #[expect( clippy::type_complexity, - reason = "tuple bundles four exit values from one consuming call so all fields stay private; a struct would only rename it" + reason = "tuple bundles several exit values from one consuming call so all fields stay private; a struct would only rename it" )] pub fn into_parts( mut self, @@ -571,11 +616,15 @@ impl ExecutionState { BlockValidityWindow, TimestampValidityWindow, HashMap, + Vec, + Vec, impl ExactSizeIterator, ) { let block_validity_window = self.block_validity_window; let timestamp_validity_window = self.timestamp_validity_window; let pda_seed_by_position = std::mem::take(&mut self.private_pda_bound_positions); + let signer_account_ids = std::mem::take(&mut self.signer_account_ids); + let public_diffs = std::mem::take(&mut self.public_diffs); let states_iter = self.pre_states.into_iter().map(move |pre| { let post = self .post_states @@ -587,6 +636,8 @@ impl ExecutionState { block_validity_window, timestamp_validity_window, pda_seed_by_position, + signer_account_ids, + public_diffs, states_iter, ) } @@ -689,6 +740,8 @@ fn authorize_first_sight_without_pda_witness( caller_pda_seeds: &[PdaSeed], pre_account_id: AccountId, pre_is_authorized: bool, + is_public: bool, + is_signer: bool, ) -> bool { if let Some((seed, caller_program_id)) = match_caller_seed_as_public_pda(caller, caller_pda_seeds, pre_account_id) @@ -700,6 +753,17 @@ fn authorize_first_sight_without_pda_witness( assert_family_binding(pda_family_binding, caller_program_id, seed, pre_account_id); true } else { + // Replaces the guarantee live-state reconstruction used to provide: once the sequencer + // stops reconstructing pre-states from live state, this is the circuit's only + // independent check that `is_authorized` for a top-level public account was derived + // honestly rather than self-reported. `is_authorized` has no downstream security + // consequence for private accounts (claim semantics are entirely bypassed for them), + // and a private `AccountId` can never legitimately appear in `signer_account_ids` (it's + // derived from `npk`/`vpk`, not a real-world signature), so the check is public-only. + assert!( + !is_public || pre_is_authorized == is_signer, + "is_authorized for account {pre_account_id} doesn't match the canonical signer/PDA-authorization sources", + ); // If an authorized account is a non-PDA one, it is globally authorized. if pre_is_authorized { globally_authorized.insert(pre_account_id); diff --git a/lee/privacy_preserving_circuit/src/main.rs b/lee/privacy_preserving_circuit/src/main.rs index 06e09f3ce..ff6d24cdb 100644 --- a/lee/privacy_preserving_circuit/src/main.rs +++ b/lee/privacy_preserving_circuit/src/main.rs @@ -11,12 +11,14 @@ fn main() { program_id, dummy_inputs, update_from_diff_results, + signer_account_ids, } = env::read(); let execution_state = execution_state::ExecutionState::derive_from_outputs( &account_identities, program_id, program_outputs, + &signer_account_ids, update_from_diff_results, ); diff --git a/lee/privacy_preserving_circuit/src/output.rs b/lee/privacy_preserving_circuit/src/output.rs index aa6e4dae5..01d3cf7d9 100644 --- a/lee/privacy_preserving_circuit/src/output.rs +++ b/lee/privacy_preserving_circuit/src/output.rs @@ -2,7 +2,7 @@ use lee_core::{ Commitment, CommitmentSetDigest, DummyInput, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey, InputAccountIdentity, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, NullifierWitness, PrivacyPreservingCircuitOutput, PrivateAccountKind, - PrivateAction, PrivateWitness, PublicAction, SharedSecretKey, WitnessKind, + PrivateAction, PrivateWitness, SharedSecretKey, WitnessKind, account::{Account, AccountId, Nonce}, compute_digest_for_path, encryption::{ViewTag, ViewingPublicKey}, @@ -15,13 +15,21 @@ pub fn compute_circuit_output( account_identities: &[InputAccountIdentity], dummy_inputs: Vec, ) -> PrivacyPreservingCircuitOutput { - let (block_validity_window, timestamp_validity_window, pda_seed_by_position, states_iter) = - execution_state.into_parts(); + let ( + block_validity_window, + timestamp_validity_window, + pda_seed_by_position, + signer_account_ids, + public_diffs, + states_iter, + ) = execution_state.into_parts(); let mut output = PrivacyPreservingCircuitOutput { - public_actions: Vec::new(), + public_pre_states: Vec::new(), + public_diffs, private_actions: Vec::new(), block_validity_window, timestamp_validity_window, + signer_account_ids, }; assert_eq!( @@ -35,10 +43,7 @@ pub fn compute_circuit_output( { match account_identity { InputAccountIdentity::Public => { - output.public_actions.push(PublicAction { - pre: pre_state, - post: post_state, - }); + output.public_pre_states.push(pre_state); } InputAccountIdentity::Private(PrivateWitness { vpk, diff --git a/lee/state_machine/core/src/account.rs b/lee/state_machine/core/src/account.rs index c04b1d7c3..37627f3af 100644 --- a/lee/state_machine/core/src/account.rs +++ b/lee/state_machine/core/src/account.rs @@ -123,7 +123,7 @@ pub struct Account { pub nonce: Nonce, } -#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, Eq, PartialEq, Serialize, Deserialize, BorshSerialize, BorshDeserialize)] pub struct AccountWithMetadata { pub account: Account, pub is_authorized: bool, diff --git a/lee/state_machine/core/src/circuit_io.rs b/lee/state_machine/core/src/circuit_io.rs index 932f896c7..e9dd6fdc4 100644 --- a/lee/state_machine/core/src/circuit_io.rs +++ b/lee/state_machine/core/src/circuit_io.rs @@ -4,9 +4,12 @@ use serde::{Deserialize, Serialize}; use crate::{ AuthorizationSecretKey, Commitment, CommitmentSetDigest, Identifier, MembershipProof, Nullifier, NullifierPublicKey, NullifierSecretKey, - account::{Account, AccountWithMetadata, Data}, + account::{AccountId, AccountWithMetadata, Data}, encryption::{EncryptedAccountData, ViewTag, ViewingPublicKey}, - program::{BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, TimestampValidityWindow}, + program::{ + AccountDiffOutput, BlockValidityWindow, PdaSeed, ProgramId, ProgramOutput, + TimestampValidityWindow, + }, }; #[derive(Serialize, Deserialize)] @@ -30,6 +33,12 @@ pub struct PrivacyPreservingCircuitInput { /// the pre-state), `diff_data` (known from the diff), and this `data`, then checks it via /// `env::verify` — so this value is untrusted input, made trustworthy only by that check. pub update_from_diff_results: Vec, + /// The accounts this transaction claims are signers. `is_authorized` for every account is + /// *derived* from membership in this single list — never accepted as an independent + /// per-account witness — and the list itself is committed to the output so the sequencer can + /// cross-check it against real signatures. Without that, a prover could satisfy + /// claim-eligibility's authorization check for an account it never actually controls. + pub signer_account_ids: Vec, } #[derive(Serialize, Deserialize, Clone)] @@ -164,20 +173,40 @@ pub struct PrivateAction { pub encrypted_post_state: EncryptedAccountData, } -#[derive(Serialize, Deserialize)] -#[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq))] -pub struct PublicAction { - pub pre: AccountWithMetadata, - pub post: Account, +/// One call's raw, unaggregated diff to a public account. +/// +/// Deliberately not collapsed into one diff per account: `AccountDiff` has no "combine two +/// diffs" operation, especially for `diff_data`, which only composes by being applied in +/// sequence. The sequencer replays these one at a time against its own live state — never +/// trusting anything the circuit internally materialized for a public account, which is why the +/// account's *value* (as opposed to its diffs) never appears here. +#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[cfg_attr(any(feature = "host", test), derive(Debug, Clone, PartialEq, Eq))] +pub struct PublicDiff { + pub account_id: AccountId, + /// Carried alongside the diff because the sequencer's replay-time authorization re-check + /// (and PDA claim resolution) needs to know which program produced it — the same role + /// `chained_call.program_id` plays in the public-transaction path's live materialize loop. + pub executing_program_id: ProgramId, + pub diff: AccountDiffOutput, } #[derive(Serialize, Deserialize)] #[cfg_attr(any(feature = "host", test), derive(Debug, PartialEq, Eq, Default))] pub struct PrivacyPreservingCircuitOutput { - pub public_actions: Vec, + /// What the circuit witnessed as each public account's pre-state — deliberately *not* + /// reconciled against live sequencer state. Used only to verify the proof is internally + /// consistent (see `check_privacy_preserving_circuit_proof_is_valid`); materialization uses + /// live state instead, via `public_diffs`, which is what actually avoids tying this + /// transaction's validity to a specific public-account snapshot. + pub public_pre_states: Vec, + pub public_diffs: Vec, pub private_actions: Vec, pub block_validity_window: BlockValidityWindow, pub timestamp_validity_window: TimestampValidityWindow, + /// Committed so the sequencer can verify every account this circuit treated as authorized + /// really did sign the transaction — see `PrivacyPreservingCircuitInput::signer_account_ids`. + pub signer_account_ids: Vec, } #[cfg(any(feature = "host", test))] @@ -216,51 +245,56 @@ mod tests { use super::*; use crate::{ Commitment, Nullifier, - account::{Account, AccountId, AccountWithMetadata, Nonce}, + account::{Account, AccountDiff, AccountId, AccountWithMetadata, BalanceDiff, Nonce}, encryption::{Ciphertext, EphemeralPublicKey}, }; #[test] fn privacy_preserving_circuit_output_to_bytes_is_compatible_with_from_slice() { let output = PrivacyPreservingCircuitOutput { - public_actions: vec![ - PublicAction { - pre: AccountWithMetadata::new( - Account { - program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), - balance: 12_345_678_901_234_567_890, - data: b"test data".to_vec().try_into().unwrap(), - nonce: Nonce(0xFFFF_FFFF_FFFF_FFFE), - }, - true, - AccountId::new([0; 32]), - ), - post: Account { + public_pre_states: vec![ + AccountWithMetadata::new( + Account { program_owner: [1, 2, 3, 4, 5, 6, 7, 8].into(), - balance: 100, - data: b"post state data".to_vec().try_into().unwrap(), - nonce: Nonce(0xFFFF_FFFF_FFFF_FFFF), + balance: 12_345_678_901_234_567_890, + data: b"test data".to_vec().try_into().unwrap(), + nonce: Nonce(0xFFFF_FFFF_FFFF_FFFE), }, + true, + AccountId::new([0; 32]), + ), + AccountWithMetadata::new( + Account { + program_owner: [9, 9, 9, 8, 8, 8, 7, 7].into(), + balance: 123_123_123_456_456_567_112, + data: b"test data".to_vec().try_into().unwrap(), + nonce: Nonce(9_999_999_999_999_999_999_999), + }, + false, + AccountId::new([1; 32]), + ), + ], + public_diffs: vec![ + PublicDiff { + account_id: AccountId::new([0; 32]), + executing_program_id: [1, 2, 3, 4, 5, 6, 7, 8], + diff: AccountDiffOutput::new(AccountDiff { + id: AccountId::new([0; 32]), + diff_balance: BalanceDiff::Add(100), + diff_data: Some(b"post state data".to_vec().try_into().unwrap()), + }), }, - PublicAction { - pre: AccountWithMetadata::new( - Account { - program_owner: [9, 9, 9, 8, 8, 8, 7, 7].into(), - balance: 123_123_123_456_456_567_112, - data: b"test data".to_vec().try_into().unwrap(), - nonce: Nonce(9_999_999_999_999_999_999_999), - }, - false, - AccountId::new([1; 32]), - ), - post: Account { - program_owner: [2, 3, 4, 5, 6, 7, 8, 9].into(), - balance: 200, - data: b"post state data 2".to_vec().try_into().unwrap(), - nonce: Nonce(0xFFFF_FFFF_FFFF_FFFD), - }, + PublicDiff { + account_id: AccountId::new([1; 32]), + executing_program_id: [2, 3, 4, 5, 6, 7, 8, 9], + diff: AccountDiffOutput::new(AccountDiff { + id: AccountId::new([1; 32]), + diff_balance: BalanceDiff::Sub(200), + diff_data: Some(b"post state data 2".to_vec().try_into().unwrap()), + }), }, ], + signer_account_ids: vec![AccountId::new([0; 32])], private_actions: vec![PrivateAction { nullifier: Nullifier::for_account_update( &Commitment::new(&AccountId::new([2; 32]), &Account::default()), diff --git a/lee/state_machine/core/src/lib.rs b/lee/state_machine/core/src/lib.rs index ab7b40f36..d4bf38df9 100644 --- a/lee/state_machine/core/src/lib.rs +++ b/lee/state_machine/core/src/lib.rs @@ -5,7 +5,7 @@ pub use circuit_io::{ DummyInput, InputAccountIdentity, NullifierWitness, PrivacyPreservingCircuitInput, - PrivacyPreservingCircuitOutput, PrivateAction, PrivateWitness, PublicAction, WitnessKind, + PrivacyPreservingCircuitOutput, PrivateAction, PrivateWitness, PublicDiff, WitnessKind, }; pub use commitment::{ Commitment, CommitmentSetDigest, DUMMY_COMMITMENT, DUMMY_COMMITMENT_HASH, MembershipProof, diff --git a/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs b/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs index bc2ebf840..42f795821 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/mod.rs @@ -4,8 +4,11 @@ use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ DummyInput, InputAccountIdentity, PrivacyPreservingCircuitInput, PrivacyPreservingCircuitOutput, - account::AccountWithMetadata, - program::{ChainedCall, InstructionData, ProgramId, ProgramOutput, UpdateFromDiffOutput}, + account::{AccountId, AccountWithMetadata}, + program::{ + ChainedCall, DEFAULT_PROGRAM_OWNER, InstructionData, ProgramId, ProgramOutput, + UpdateFromDiffOutput, + }, }; use risc0_zkvm::{ExecutorEnv, InnerReceipt, ProverOpts, Receipt, default_prover}; @@ -95,6 +98,20 @@ pub fn execute_and_prove_with_padded_inputs( let mut program_outputs = Vec::new(); let mut update_from_diff_results = Vec::new(); + // Captured before `pre_states` moves into `initial_call` below — this is the only place + // `is_authorized` is caller-supplied (every later pre_state's authorization is resolved + // inside the circuit from this same list), so it's also the only place we need to derive + // the committed signer set from. Scoped to public accounts only: this list is committed and + // cross-checked by the sequencer against real, signature-derived `AccountId`s, which a + // private (`npk`/`vpk`-derived) `AccountId` can never match — including a private account + // here would make it permanently unverifiable. + let signer_account_ids: Vec = pre_states + .iter() + .zip(&account_identities) + .filter(|(pre, identity)| pre.is_authorized && identity.is_public()) + .map(|(pre, _)| pre.account_id) + .collect(); + let initial_call = ChainedCall { program_id: initial_program.id(), instruction_data, @@ -124,6 +141,9 @@ pub fn execute_and_prove_with_padded_inputs( // Prove `update_from_diff` for every account this call's diff writes data to, in the // same order `execution_state::derive_from_outputs` will visit them, so // `update_from_diff_results` lines up positionally with the circuit's own traversal. + // The diff's materialization logic belongs to the account's *owner* program, not + // necessarily the calling program — falling back to the caller only when the account + // is still unclaimed (default owner), mirroring the claim-eligibility rule elsewhere. for (pre, diff_output) in program_output .pre_states .iter() @@ -132,7 +152,21 @@ pub fn execute_and_prove_with_padded_inputs( let Some(diff_data) = diff_output.diff().diff_data.clone() else { continue; }; - let update_receipt = program.prove_update_from_diff(&pre.account, &diff_data)?; + let owner_id: ProgramId = if pre.account.program_owner == DEFAULT_PROGRAM_OWNER { + chained_call.program_id + } else { + pre.account.program_owner.into() + }; + let owner_program = if owner_id == program.id() { + program + } else { + dependencies.get(&owner_id).ok_or( + InvalidProgramBehaviorError::UndeclaredProgramDependency { + program_id: owner_id, + }, + )? + }; + let update_receipt = owner_program.prove_update_from_diff(&pre.account, &diff_data)?; let update_output: UpdateFromDiffOutput = update_receipt .journal .decode() @@ -167,6 +201,7 @@ pub fn execute_and_prove_with_padded_inputs( program_id: program_with_dependencies.program.id(), dummy_inputs, update_from_diff_results, + signer_account_ids, }; env_builder.write(&circuit_input).unwrap(); 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 80a415e9c..a6f2db6e4 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/circuit/tests.rs @@ -4,7 +4,7 @@ use lee_core::{ Commitment, DUMMY_COMMITMENT_HASH, EncryptedAccountData, EncryptionScheme, EphemeralSecretKey, Nullifier, NullifierPublicKey, NullifierWitness, PrivacyPreservingCircuitOutput, PrivateWitness, SharedSecretKey, WitnessKind, - account::{Account, AccountId, AccountWithMetadata, Nonce, data::Data}, + account::{Account, AccountId, AccountWithMetadata, BalanceDiff, Nonce, data::Data}, program::{PdaSeed, PrivateAccountKind}, }; @@ -64,13 +64,6 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() let balance_to_move: u128 = 37; - let expected_sender_post = Account { - program_owner: program.id().into(), - balance: 100 - balance_to_move, - nonce: Nonce::default(), - data: Data::default(), - }; - let expected_recipient_post = Account { program_owner: program.id().into(), balance: balance_to_move, @@ -108,10 +101,23 @@ fn prove_privacy_preserving_execution_circuit_public_and_private_pre_accounts() assert!(proof.is_valid_for(&output)); - let [action] = output.public_actions.try_into().unwrap(); - let (sender_pre, sender_post) = (action.pre, action.post); + let [sender_pre] = output.public_pre_states.try_into().unwrap(); assert_eq!(sender_pre, expected_sender_pre); - assert_eq!(sender_post, expected_sender_post); + + // The sender's `AccountDiff`, not a materialized post-state — this is the whole point of + // `AccountDiff`: the circuit never commits to a specific public post-state, only to what + // changed, so the sequencer can replay it against whatever the account's live state is by + // the time it processes this transaction. + let [public_diff] = output.public_diffs.try_into().unwrap(); + assert_eq!(public_diff.account_id, expected_sender_pre.account_id); + assert_eq!(public_diff.executing_program_id, program.id()); + assert_eq!( + public_diff.diff.diff().diff_balance, + BalanceDiff::Sub(balance_to_move) + ); + assert!(public_diff.diff.diff().diff_data.is_none()); + assert!(public_diff.diff.required_claim().is_none()); + assert_eq!(output.private_actions.len(), 1); let (_identifier, recipient_post) = EncryptionScheme::decrypt( @@ -230,7 +236,8 @@ fn prove_privacy_preserving_execution_circuit_fully_private() { .unwrap(); assert!(proof.is_valid_for(&output)); - assert!(output.public_actions.is_empty()); + assert!(output.public_pre_states.is_empty()); + assert!(output.public_diffs.is_empty()); let sender_nullifier = expected_new_nullifiers[0].0; let recipient_nullifier = expected_new_nullifiers[1].0; diff --git a/lee/state_machine/src/privacy_preserving_transaction/message.rs b/lee/state_machine/src/privacy_preserving_transaction/message.rs index 9df452366..2243235fb 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/message.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/message.rs @@ -1,7 +1,8 @@ use borsh::{BorshDeserialize, BorshSerialize}; use lee_core::{ Commitment, CommitmentSetDigest, Nullifier, PrivacyPreservingCircuitOutput, PrivateAction, - account::{Account, Nonce}, + PublicDiff, + account::{AccountWithMetadata, Nonce}, program::{BlockValidityWindow, TimestampValidityWindow}, }; pub use lee_core::{EncryptedAccountData, ViewTag}; @@ -11,19 +12,24 @@ use crate::AccountId; const PREFIX: &[u8; 32] = b"/LEE/v0.3/Message/Privacy/\x00\x00\x00\x00\x00\x00"; -#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize)] -pub struct PublicActionWithID { - pub account_id: AccountId, - pub post_state: Account, -} - #[derive(Clone, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize)] pub struct Message { - pub public_actions: Vec, + /// What the circuit witnessed as each public account's pre-state — deliberately *not* + /// reconciled against live sequencer state. Used only to verify the proof is internally + /// consistent (see `check_privacy_preserving_circuit_proof_is_valid`); materialization uses + /// live state instead, via `public_diffs` below, which is what actually avoids tying this + /// transaction's validity to a specific public-account snapshot. + pub public_pre_states: Vec, + /// Raw, per-call, unaggregated diffs for public accounts. See + /// `PrivacyPreservingCircuitOutput::public_diffs`. + pub public_diffs: Vec, pub nonces: Vec, pub private_actions: Vec, pub block_validity_window: BlockValidityWindow, pub timestamp_validity_window: TimestampValidityWindow, + /// The accounts the circuit claims are signers — cross-checked by the sequencer against real + /// signatures. See `PrivacyPreservingCircuitOutput::signer_account_ids`. + pub signer_account_ids: Vec, } impl std::fmt::Debug for Message { @@ -47,11 +53,13 @@ impl std::fmt::Debug for Message { }) .collect(); f.debug_struct("Message") - .field("public_actions", &self.public_actions) + .field("public_pre_states", &self.public_pre_states) + .field("public_diffs", &self.public_diffs) .field("nonces", &self.nonces) .field("private_actions", &private_actions) .field("block_validity_window", &self.block_validity_window) .field("timestamp_validity_window", &self.timestamp_validity_window) + .field("signer_account_ids", &self.signer_account_ids) .finish() } } @@ -59,20 +67,14 @@ impl std::fmt::Debug for Message { impl Message { #[must_use] pub fn from_circuit_output(nonces: Vec, output: PrivacyPreservingCircuitOutput) -> Self { - let public_actions = output - .public_actions - .into_iter() - .map(|action| PublicActionWithID { - account_id: action.pre.account_id, - post_state: action.post, - }) - .collect(); Self { - public_actions, + public_pre_states: output.public_pre_states, + public_diffs: output.public_diffs, nonces, private_actions: output.private_actions, block_validity_window: output.block_validity_window, timestamp_validity_window: output.timestamp_validity_window, + signer_account_ids: output.signer_account_ids, } } @@ -92,11 +94,15 @@ impl Message { .collect() } + /// The unique set of public accounts this transaction touches — sourced from + /// `public_pre_states`, not `public_diffs`, since a diff can legitimately repeat an account + /// (multiple calls touching the same account within one transaction), while a pre-state is + /// witnessed exactly once per account. #[must_use] pub fn public_account_ids(&self) -> Vec { - self.public_actions + self.public_pre_states .iter() - .map(|action| action.account_id) + .map(|pre| pre.account_id) .collect() } @@ -120,14 +126,14 @@ impl Message { pub mod tests { use lee_core::{ Commitment, EncryptionScheme, EphemeralPublicKey, EphemeralSecretKey, Nullifier, - NullifierPublicKey, PrivateAccountKind, PrivateAction, SharedSecretKey, - account::{Account, AccountId, Nonce}, + NullifierPublicKey, PrivateAccountKind, PrivateAction, PublicDiff, SharedSecretKey, + account::{Account, AccountDiff, AccountId, AccountWithMetadata, BalanceDiff, Nonce}, encryption::{Ciphertext, ViewingPublicKey}, - program::{BlockValidityWindow, TimestampValidityWindow}, + program::{AccountDiffOutput, BlockValidityWindow, TimestampValidityWindow}, }; use sha2::{Digest as _, Sha256}; - use super::{EncryptedAccountData, Message, PREFIX, PublicActionWithID}; + use super::{EncryptedAccountData, Message, PREFIX}; #[must_use] pub fn message_for_tests() -> Message { @@ -150,10 +156,21 @@ pub mod tests { let old_commitment = Commitment::new(&account_id1, &account1); let nullifier = Nullifier::for_account_update(&old_commitment, &nsk1); + let public_account_id = AccountId::new([1; 32]); Message { - public_actions: vec![PublicActionWithID { - account_id: AccountId::new([1; 32]), - post_state: Account::default(), + public_pre_states: vec![AccountWithMetadata::new( + Account::default(), + false, + public_account_id, + )], + public_diffs: vec![PublicDiff { + account_id: public_account_id, + executing_program_id: [1, 2, 3, 4, 5, 6, 7, 8], + diff: AccountDiffOutput::new(AccountDiff { + id: public_account_id, + diff_balance: BalanceDiff::Add(0), + diff_data: None, + }), }], nonces, private_actions: vec![PrivateAction { @@ -168,32 +185,36 @@ pub mod tests { }], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + signer_account_ids: vec![], } } #[test] fn hash_privacy_pinned() { let msg = Message { - public_actions: vec![], + public_pre_states: vec![], + public_diffs: vec![], nonces: vec![Nonce(5)], private_actions: vec![], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + signer_account_ids: vec![], }; // empty vec fields: u32 len=0 - let public_actions_bytes: &[u8] = &[0, 0, 0, 0]; + let empty_vec_bytes: &[u8] = &[0, 0, 0, 0]; let nonces_bytes: &[u8] = &[1, 0, 0, 0, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - let private_actions_bytes: &[u8] = &[0, 0, 0, 0]; // validity windows: unbounded = {from: None (0_u8), to: None (0_u8)} let unbounded_window_bytes: &[u8] = &[0, 0]; let expected_borsh_vec: Vec = [ - public_actions_bytes, + empty_vec_bytes, // public_pre_states + empty_vec_bytes, // public_diffs nonces_bytes, - private_actions_bytes, + empty_vec_bytes, // private_actions unbounded_window_bytes, // block_validity_window unbounded_window_bytes, // timestamp_validity_window + empty_vec_bytes, // signer_account_ids ] .concat(); let expected_borsh: &[u8] = &expected_borsh_vec; diff --git a/lee/state_machine/src/privacy_preserving_transaction/transaction.rs b/lee/state_machine/src/privacy_preserving_transaction/transaction.rs index 1b0a6567c..a899d1bde 100644 --- a/lee/state_machine/src/privacy_preserving_transaction/transaction.rs +++ b/lee/state_machine/src/privacy_preserving_transaction/transaction.rs @@ -53,12 +53,7 @@ impl PrivacyPreservingTransaction { .signer_account_ids() .into_iter() .collect::>(); - acc_set.extend( - self.message - .public_actions - .iter() - .map(|action| action.account_id), - ); + acc_set.extend(self.message.public_account_ids()); acc_set.into_iter().collect() } diff --git a/lee/state_machine/src/state/tests/circuit.rs b/lee/state_machine/src/state/tests/circuit.rs index 7ba63f7b3..b77f85b2d 100644 --- a/lee/state_machine/src/state/tests/circuit.rs +++ b/lee/state_machine/src/state/tests/circuit.rs @@ -448,7 +448,8 @@ fn private_pda_claim_succeeds() { let (output, _proof) = result.expect("private PDA claim should succeed"); assert_eq!(output.private_actions.len(), 1); - assert!(output.public_actions.is_empty()); + assert!(output.public_pre_states.is_empty()); + assert!(output.public_diffs.is_empty()); } /// An npk is supplied that does not match the `pre_state`'s `account_id` under diff --git a/lee/state_machine/src/state/tests/privacy_preserving.rs b/lee/state_machine/src/state/tests/privacy_preserving.rs index 86cf7585b..f47d99fd7 100644 --- a/lee/state_machine/src/state/tests/privacy_preserving.rs +++ b/lee/state_machine/src/state/tests/privacy_preserving.rs @@ -306,7 +306,17 @@ fn data_changer_program_should_fail_for_non_owned_account_in_privacy_preserving_ &program.into(), ); - assert!(matches!(result, Err(LeeError::CircuitProvingError(_)))); + // The account's owner ([0,1,2,3,4,5,6,7]) is not `data_changer` itself and isn't declared + // as a dependency, so the host can't resolve whose `update_from_diff` logic should interpret + // this diff — caught here, before the outer circuit is even proven, rather than surfacing as + // a `validate_execution` panic inside the circuit (`CircuitProvingError`) the way it would if + // the account were instead owned by some other *declared* program. + assert!(matches!( + result, + Err(LeeError::InvalidProgramBehavior( + InvalidProgramBehaviorError::UndeclaredProgramDependency { .. } + )) + )); } #[test] diff --git a/lee/state_machine/src/validated_state_diff/mod.rs b/lee/state_machine/src/validated_state_diff/mod.rs index c8cc32c45..f1cb0cb7d 100644 --- a/lee/state_machine/src/validated_state_diff/mod.rs +++ b/lee/state_machine/src/validated_state_diff/mod.rs @@ -5,11 +5,11 @@ use std::{ }; use lee_core::{ - BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, PublicAction, Timestamp, - account::{Account, AccountId, AccountWithMetadata, apply_balance_diff}, + BlockId, Commitment, Nullifier, PrivacyPreservingCircuitOutput, Timestamp, + account::{Account, AccountId, AccountWithMetadata, BalanceDiff, apply_balance_diff}, program::{ - CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, compute_public_authorized_pdas, - validate_execution, + CallerData, ChainedCall, Claim, DEFAULT_PROGRAM_OWNER, ExecutionValidationError, + ProgramId, compute_public_authorized_pdas, validate_execution, }, }; use log::debug; @@ -224,8 +224,8 @@ impl ValidatedStateDiff { let balance = apply_balance_diff(pre.account.balance, diff.diff_balance) .map_err(InvalidProgramBehaviorError::BalanceDiffFailed)?; - let data = if let Some(diff_data) = diff.diff_data.clone() { - program.execute_update_from_diff(&pre.account, &diff_data)? + let data = if let Some(diff_data) = &diff.diff_data { + program.execute_update_from_diff(&pre.account, diff_data)? } else { pre.account.data.clone() }; @@ -420,24 +420,22 @@ impl ValidatedStateDiff { LeeError::OutOfValidityWindow ); - // Build pre_states for proof verification - let public_pre_states: Vec<_> = public_account_ids - .iter() - .map(|account_id| { - AccountWithMetadata::new( - state.get_account_by_id(*account_id), - signer_account_ids.contains(account_id), - *account_id, - ) - }) - .collect(); + // Anchor the circuit's claimed signer set against real, cryptographically-verified + // signatures. Without this, a prover could satisfy claim-eligibility's authorization + // check for an account it never actually controls, since `is_authorized` inside the + // circuit is derived entirely from this list. + ensure!( + message + .signer_account_ids + .iter() + .all(|id| signer_account_ids.contains(id)), + LeeError::InvalidInput( + "Circuit claims a signer account with no valid signature".into() + ) + ); // 4. Proof verification - check_privacy_preserving_circuit_proof_is_valid( - &witness_set.proof, - &public_pre_states, - message, - )?; + check_privacy_preserving_circuit_proof_is_valid(&witness_set.proof, message)?; // 5. Commitment freshness state.check_commitments_are_new(&commitments)?; @@ -445,11 +443,108 @@ impl ValidatedStateDiff { // 6. Nullifier uniqueness state.check_nullifiers_are_valid(&nullifiers)?; - let public_diff = message - .public_actions - .iter() - .map(|action| (action.account_id, action.post_state.clone())) - .collect(); + // Replay each public diff against live state, one at a time — never trusting anything + // the circuit internally materialized for a public account. This, not proof + // verification above, is what actually avoids tying this transaction's validity to a + // stale public-account snapshot: this step only cares about the diff's shape and this + // program's ownership, both independent of whatever pre-state the circuit witnessed + // while proving. + let mut public_diff: HashMap = HashMap::new(); + for public_diff_entry in &message.public_diffs { + let account_id = public_diff_entry.account_id; + let executing_program_id = public_diff_entry.executing_program_id; + let pre_account = public_diff + .get(&account_id) + .cloned() + .unwrap_or_else(|| state.get_account_by_id(account_id)); + let diff = public_diff_entry.diff.diff(); + let account_program_owner = pre_account.program_owner; + // `program_owner` is `AccountId`-typed; convert once up front rather than at each + // comparison below (see `From for AccountId`'s doc comment). + let executing_account_id = AccountId::from(executing_program_id); + + // Re-check authorization against *live* state — the circuit's own + // `validate_execution` check ran against a witnessed pre-state that isn't trusted + // for this purpose (see `check_privacy_preserving_circuit_proof_is_valid`). + ensure!( + !matches!(diff.diff_balance, BalanceDiff::Sub(amount) if amount > 0) + || account_program_owner == executing_account_id, + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedBalanceDecrease { + account_id, + owner_account_id: account_program_owner, + executing_program_id, + } + ) + ); + ensure!( + diff.diff_data.is_none() + || pre_account == Account::default() + || account_program_owner == executing_account_id, + InvalidProgramBehaviorError::ExecutionValidationFailed( + ExecutionValidationError::UnauthorizedDataModification { + account_id, + executing_program_id, + } + ) + ); + + // Claim-eligibility, same rules as the public-transaction path's — checked against + // real signatures (`signer_account_ids`), not the circuit's witnessed claim. + let claimed_owner = if let Some(claim) = public_diff_entry.diff.required_claim() { + ensure!( + account_program_owner == DEFAULT_PROGRAM_OWNER, + InvalidProgramBehaviorError::ClaimedNonDefaultAccount { account_id } + ); + match claim { + Claim::Authorized => { + ensure!( + signer_account_ids.contains(&account_id), + InvalidProgramBehaviorError::ClaimedUnauthorizedAccount { account_id } + ); + } + Claim::Pda(seed) => { + let pda = AccountId::for_public_pda(&executing_program_id, &seed); + ensure!( + account_id == pda, + InvalidProgramBehaviorError::MismatchedPdaClaim { + expected: pda, + actual: account_id + } + ); + } + } + Some(executing_account_id) + } else { + None + }; + + // Materialize: apply_balance_diff always; dispatch update_from_diff when diff_data + // is Some, via the same unproven, sequencer-trusted mechanism the + // public-transaction path already uses. + let mut post = pre_account.clone(); + post.balance = apply_balance_diff(pre_account.balance, diff.diff_balance) + .map_err(InvalidProgramBehaviorError::BalanceDiffFailed)?; + if let Some(diff_data) = &diff.diff_data { + let owner_id = claimed_owner.unwrap_or(account_program_owner); + let Some(owner_program_account) = state.get_program(owner_id.into()) else { + return Err(InvalidProgramBehaviorError::NoOwnerProgramForDataUpdate { + account_id, + } + .into()); + }; + let owner_program = Program::new_unchecked( + owner_id.into(), + Cow::Owned(owner_program_account.data.to_vec()), + ); + post.data = owner_program.execute_update_from_diff(&pre_account, diff_data)?; + } + if let Some(owner) = claimed_owner { + post.program_owner = owner; + } + public_diff.insert(account_id, post); + } + let new_nullifiers = nullifiers.iter().map(|(nullifier, _)| *nullifier).collect(); Ok(Self(StateDiff { @@ -524,24 +619,23 @@ fn authenticate_public_transaction_signers( Ok(signer_account_ids) } +/// Verifies the proof against exactly what the circuit witnessed and output — deliberately *not* +/// reconciled against live sequencer state for public accounts. Reconciling `public_pre_states` +/// against live state here is exactly the race condition `AccountDiff` exists to avoid: it would +/// tie this proof's validity to a specific public-account snapshot, invalidating it the moment +/// that account changes before this transaction is processed. Materialization (which *does* use +/// live state) happens separately, later, via `message.public_diffs`. fn check_privacy_preserving_circuit_proof_is_valid( proof: &Proof, - public_pre_states: &[AccountWithMetadata], message: &Message, ) -> Result<(), LeeError> { let output = PrivacyPreservingCircuitOutput { - public_actions: public_pre_states - .iter() - .cloned() - .zip(&message.public_actions) - .map(|(pre, action)| PublicAction { - pre, - post: action.post_state.clone(), - }) - .collect(), + public_pre_states: message.public_pre_states.clone(), + public_diffs: message.public_diffs.clone(), private_actions: message.private_actions.clone(), block_validity_window: message.block_validity_window, timestamp_validity_window: message.timestamp_validity_window, + signer_account_ids: message.signer_account_ids.clone(), }; proof .is_valid_for(&output) diff --git a/lee/state_machine/src/validated_state_diff/tests.rs b/lee/state_machine/src/validated_state_diff/tests.rs index 0f274aaaf..d87fe3bf8 100644 --- a/lee/state_machine/src/validated_state_diff/tests.rs +++ b/lee/state_machine/src/validated_state_diff/tests.rs @@ -62,19 +62,18 @@ fn public_diff_reflects_a_successful_transfer() { ); } -/// Privacy-path version of the authorization-injection attack. The test passes when the -/// attack is rejected and the victim's balance is left untouched. +/// Privacy-path version of the authorization-injection attack. The test passes when the attack +/// is rejected. /// -/// `execute_and_prove` succeeds because each inner receipt is individually valid and the -/// outer circuit faithfully commits whatever the attacker's program output says, including -/// `victim(is_authorized=true)`. The circuit has no access to chain state and cannot know -/// the victim never signed. -/// -/// The host-side validator is what catches the attack: it independently reconstructs -/// `public_pre_states` from chain state using `signer_account_ids.contains(victim_id) = false`, -/// so it expects `victim(is_authorized=false)`. The committed journal and the reconstructed -/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction` -/// returns an error before any state is applied. +/// `signer_account_ids` is derived once, before the chain even starts, strictly from the +/// *top-level* `pre_states` passed to `execute_and_prove` — here, just the attacker's own +/// private account. The victim is only ever introduced later, inside `malicious_injector`'s +/// chained call, so it can never be part of `signer_account_ids` regardless of what P1 forges. +/// The circuit's own Vacant-branch consistency check (scoped to public accounts) derives the +/// victim's expected `is_authorized` from `signer_account_ids` membership, finds it absent, and +/// asserts that against the witnessed `is_authorized=true` — which fails, panicking inside the +/// guest. So the attack is caught during proving itself: `execute_and_prove` returns +/// `Err(CircuitProvingError)`, and never even reaches `from_privacy_preserving_transaction`. #[test] fn privacy_malicious_programs_cannot_drain_public_victim() { use lee_core::{ @@ -83,12 +82,7 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { }; use crate::{ - PrivacyPreservingTransaction, - privacy_preserving_transaction::{ - circuit::{ProgramWithDependencies, execute_and_prove}, - message::Message, - witness_set::WitnessSet, - }, + privacy_preserving_transaction::circuit::{ProgramWithDependencies, execute_and_prove}, state::{CommitmentSet, tests::test_private_account_keys_1}, }; @@ -181,54 +175,32 @@ fn privacy_malicious_programs_cannot_drain_public_victim() { InputAccountIdentity::Public, // recipient ]; - // execute_and_prove succeeds: all inner receipts are valid. - // The outer circuit commits victim(is_authorized=true) to its journal. - let (circuit_output, proof) = execute_and_prove( + let result = execute_and_prove( vec![attacker_pre], instruction_data, account_identities, &program_with_deps, - ) - .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); - - // public_account_ids lists the Public entries from account_identities, in order. - // The single ciphertext belongs to attacker's private account update. - let message = Message::from_circuit_output( - vec![], // no public signers, no nonces - circuit_output, ); - let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); - assert!( - matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)), - "attack privacy transaction should be rejected with InvalidPrivacyPreservingProof" + matches!(result, Err(LeeError::CircuitProvingError(_))), + "forged victim(is_authorized=true) should be caught inside the circuit itself, since \ + signer_account_ids is derived from the top-level pre_states only, and the victim is \ + only ever introduced via a chained call" ); assert_eq!(state.get_account_by_id(victim_id).balance, victim_balance); assert_eq!(state.get_account_by_id(recipient_id).balance, 0); } -/// Private-victim variant of the authorization-injection attack. The test passes when the -/// attack is rejected and the recipient's balance remains zero. -/// -/// After the circuit's Vacant branch accepts the injected `victim(is_authorized=true)` -/// verbatim, the attacker must choose how to declare the victim in `account_identities`. -/// There are two routes, both closed: -/// -/// - **mask=1 (regular update)**: the circuit derives `account_id = -/// AccountId::for_regular_private_account(&npk_from(nsk), identifier)` and asserts it matches -/// `pre_state.account_id`. Passing this check requires the victim's `nsk`, which the attacker -/// does not have. `execute_and_prove` panics inside the ZKVM and no proof is produced. -/// -/// - **mask=0 (`Public`)**: the circuit places the account in `public_pre_states` and -/// `execute_and_prove` succeeds. The host-side validator then reconstructs `public_pre_states` -/// from chain state; `state.get_account_by_id(victim_id)` returns the default account (balance=0) -/// because the victim has no public state entry. The committed journal and the reconstructed -/// expected output diverge, `receipt.verify` fails, and `from_privacy_preserving_transaction` -/// returns an error before any state is applied. This test exercises this route. +/// Private-victim variant of the authorization-injection attack. The attacker has no `nsk` for +/// the victim's private account, so a regular update isn't an option — the only route is to +/// declare the victim `InputAccountIdentity::Public` and inject its data directly, since the +/// circuit has no access to chain state and can't detect the values are fabricated. That's the +/// exact same route `privacy_malicious_programs_cannot_drain_public_victim` exercises, so the +/// same mechanism catches it: the victim is only ever introduced via `malicious_injector`'s +/// chained call, never the top-level `pre_states` `signer_account_ids` is derived from, so the +/// circuit's Vacant-branch consistency check rejects the forged `is_authorized=true` and +/// `execute_and_prove` fails with `CircuitProvingError` before any proof is produced. #[test] fn privacy_malicious_programs_cannot_drain_private_victim() { use lee_core::{ @@ -237,12 +209,7 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { }; use crate::{ - PrivacyPreservingTransaction, - privacy_preserving_transaction::{ - circuit::{ProgramWithDependencies, execute_and_prove}, - message::Message, - witness_set::WitnessSet, - }, + privacy_preserving_transaction::circuit::{ProgramWithDependencies, execute_and_prove}, state::{ CommitmentSet, tests::{test_private_account_keys_1, test_private_account_keys_2}, @@ -273,15 +240,6 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { let recipient_id = AccountId::new([42_u8; 32]); - // Victim has no public state entry; only recipient is registered at genesis. - let state = V03State::new() - .with_public_accounts(public_state_from_balances(&[(recipient_id, 0)])) - .with_programs([ - crate::test_methods::simple_balance_transfer(), - crate::test_methods::malicious_injector(), - crate::test_methods::malicious_launderer(), - ]); - // Build attacker's private account and its local commitment tree. let attacker_account = Account { program_owner: crate::test_methods::simple_balance_transfer().id().into(), @@ -345,36 +303,18 @@ fn privacy_malicious_programs_cannot_drain_private_victim() { InputAccountIdentity::Public, // recipient ]; - // execute_and_prove succeeds: simple_balance_transfer runs against the injected - // victim(balance=5000, is_authorized=true) and produces valid inner receipts. - // The outer circuit commits victim(is_authorized=true) to public_pre_states. - let (circuit_output, proof) = execute_and_prove( + let result = execute_and_prove( vec![attacker_pre], instruction_data, account_identities, &program_with_deps, - ) - .expect("execute_and_prove should succeed \u{2014} the programs execute correctly"); - - // public_account_ids lists the Public entries from account_identities, in order. - // The single ciphertext belongs to attacker's private account update. - let message = Message::from_circuit_output( - vec![], // no public signers, no nonces - circuit_output, ); - let witness_set = WitnessSet::for_message(&message, proof, &[]); // no signatures - let tx = PrivacyPreservingTransaction::new(message, witness_set); - - let result = ValidatedStateDiff::from_privacy_preserving_transaction(&tx, &state, 1, 0); - assert!( - matches!(result, Err(LeeError::InvalidPrivacyPreservingProof)), - "attack on private victim should be rejected with InvalidPrivacyPreservingProof" + matches!(result, Err(LeeError::CircuitProvingError(_))), + "forged victim(is_authorized=true) should be caught inside the circuit itself, the \ + same way as the public-victim variant of this attack" ); - // Victim has no public balance to check; confirming the recipient received nothing - // is sufficient to show no funds moved. - assert_eq!(state.get_account_by_id(recipient_id).balance, 0); } /// Two malicious programs (injector + launderer) attempt to drain a victim's balance @@ -510,7 +450,8 @@ fn privacy_garbage_proof_is_rejected() { )); let commitment = Commitment::new(&account_id, &Account::default()); let message = Message { - public_actions: vec![], + public_pre_states: vec![], + public_diffs: vec![], nonces: vec![], private_actions: vec![PrivateAction { nullifier: Nullifier::for_account_initialization(&account_id), @@ -524,6 +465,7 @@ fn privacy_garbage_proof_is_rejected() { }], block_validity_window: BlockValidityWindow::new_unbounded(), timestamp_validity_window: TimestampValidityWindow::new_unbounded(), + signer_account_ids: vec![], }; // Garbage proof bytes: not a valid borsh-encoded `InnerReceipt`. @@ -539,3 +481,103 @@ fn privacy_garbage_proof_is_rejected() { Ok(_) => panic!("garbage proof was accepted instead of rejected"), } } + +/// The race condition this whole `AccountDiff` design exists to fix: a public account touched +/// by a privacy transaction changes on-chain *after* the proof was generated but *before* the +/// sequencer validates it (e.g. an unrelated public transfer into/out of the same account +/// landing first). Proof validity no longer depends on a specific public-account snapshot — only +/// on what the circuit itself witnessed and output — so the proof still verifies, and the +/// diff it carries gets replayed against whatever the live balance actually is by the time the +/// sequencer processes it, not the stale balance captured at proving time. +#[test] +fn privacy_transaction_survives_public_state_changing_after_proving() { + use lee_core::{ + DUMMY_COMMITMENT_HASH, InputAccountIdentity, NullifierWitness, PrivateWitness, WitnessKind, + account::AccountWithMetadata, + }; + + use crate::{ + PrivacyPreservingTransaction, + privacy_preserving_transaction::{ + circuit::execute_and_prove, message::Message, witness_set::WitnessSet, + }, + state::tests::test_private_account_keys_1, + }; + + let program = crate::test_methods::simple_balance_transfer(); + let recipient_keys = test_private_account_keys_1(); + + let sender_key = PrivateKey::try_new([3_u8; 32]).unwrap(); + let sender_id = AccountId::from(&PublicKey::new_from_private_key(&sender_key)); + let balance_at_proving_time = 100_u128; + let balance_to_move = 37_u128; + + // State as it looked when the prover captured its pre-state. + let state_at_proving_time = V03State::new() + .with_public_accounts(public_state_from_balances(&[( + sender_id, + balance_at_proving_time, + )])) + .with_programs(std::iter::once(program.clone())); + + let sender_pre = AccountWithMetadata::new( + state_at_proving_time.get_account_by_id(sender_id), + true, + sender_id, + ); + + let recipient_account_id = + AccountId::for_regular_private_account(&recipient_keys.npk(), &recipient_keys.vpk(), 0); + let recipient_pre = AccountWithMetadata::new(Account::default(), false, recipient_account_id); + + let (circuit_output, proof) = execute_and_prove( + vec![sender_pre, recipient_pre], + Program::serialize_instruction(balance_to_move).unwrap(), + vec![ + InputAccountIdentity::Public, + InputAccountIdentity::Private(PrivateWitness { + vpk: recipient_keys.vpk(), + random_seed: [0; 32], + identifier: 0, + kind: WitnessKind::Regular { ask: None }, + nullifier: NullifierWitness::Init { + npk: recipient_keys.npk(), + commitment_root: DUMMY_COMMITMENT_HASH, + }, + }), + ], + &program.clone().into(), + ) + .expect("execute_and_prove should succeed"); + + let message = Message::from_circuit_output(vec![Nonce(0)], circuit_output); + let witness_set = WitnessSet::for_message(&message, proof, &[&sender_key]); + let tx = PrivacyPreservingTransaction::new(message, witness_set); + + // Simulates an unrelated public transaction landing on the sender's account between + // proving and sequencer validation: live state now disagrees with what the prover + // witnessed as `sender_pre`. + let balance_at_validation_time = 250_u128; + let state_at_validation_time = state_at_proving_time.with_public_accounts( + public_state_from_balances(&[(sender_id, balance_at_validation_time)]), + ); + + let diff = ValidatedStateDiff::from_privacy_preserving_transaction( + &tx, + &state_at_validation_time, + 1, + 0, + ) + .expect( + "proof validity must not depend on live public state matching the witnessed \ + pre-state", + ); + let public_diff = diff.public_diff(); + + assert_eq!( + public_diff[&sender_id].balance, + balance_at_validation_time - balance_to_move, + "the diff must be replayed against live state at validation time, not the stale \ + balance captured when the proof was generated", + ); +} diff --git a/lez/explorer_service/src/components/transaction_details.rs b/lez/explorer_service/src/components/transaction_details.rs index 01e5bc485..8f33c43ea 100644 --- a/lez/explorer_service/src/components/transaction_details.rs +++ b/lez/explorer_service/src/components/transaction_details.rs @@ -68,16 +68,18 @@ pub fn PrivacyPreservingTxDetails(tx: PrivacyPreservingTransaction) -> impl Into witness_set, } = tx; let PrivacyPreservingMessage { - public_actions, + public_pre_states, + public_diffs: _, nonces, private_actions, block_validity_window, timestamp_validity_window, + signer_account_ids: _, } = message; let private_action_count = private_actions.len(); - let public_account_ids: Vec<_> = public_actions + let public_account_ids: Vec<_> = public_pre_states .into_iter() - .map(|action| action.account_id) + .map(|pre_state| pre_state.account_id) .collect(); let public_account_count = public_account_ids.len(); let WitnessSet { diff --git a/lez/explorer_service/src/components/transaction_preview.rs b/lez/explorer_service/src/components/transaction_preview.rs index 20c26235a..ec60fb23e 100644 --- a/lez/explorer_service/src/components/transaction_preview.rs +++ b/lez/explorer_service/src/components/transaction_preview.rs @@ -40,7 +40,7 @@ pub fn TransactionPreview(transaction: Transaction) -> impl IntoView { } = tx; format!( "{} public accounts, {} commitments", - message.public_actions.len(), + message.public_pre_states.len(), message.private_actions.len() ) } diff --git a/lez/indexer/ffi/indexer_ffi.h b/lez/indexer/ffi/indexer_ffi.h index 561c5e5f2..9e2c6cd65 100644 --- a/lez/indexer/ffi/indexer_ffi.h +++ b/lez/indexer/ffi/indexer_ffi.h @@ -225,18 +225,27 @@ typedef struct FfiAccount { struct FfiU128 nonce; } FfiAccount; -typedef struct FfiPublicAction { +typedef struct FfiAccountWithMetadata { + struct FfiAccount account; + bool is_authorized; FfiAccountId account_id; - struct FfiAccount post_state; -} FfiPublicAction; +} FfiAccountWithMetadata; -typedef struct FfiVec_FfiPublicAction { - struct FfiPublicAction *entries; +typedef struct FfiVec_FfiAccountWithMetadata { + struct FfiAccountWithMetadata *entries; uintptr_t len; uintptr_t capacity; -} FfiVec_FfiPublicAction; +} FfiVec_FfiAccountWithMetadata; -typedef struct FfiVec_FfiPublicAction FfiPublicActionList; +typedef struct FfiVec_FfiAccountWithMetadata FfiPublicPreStateList; + +/** + * C-compatible tagged `BalanceDiff`: `is_sub` selects `Sub` over `Add`. + */ +typedef struct FfiBalanceDiff { + bool is_sub; + struct FfiU128 amount; +} FfiBalanceDiff; typedef struct FfiVec_u8 { uint8_t *entries; @@ -246,6 +255,50 @@ typedef struct FfiVec_u8 { typedef struct FfiVec_u8 FfiVecU8; +typedef struct FfiOption_FfiVecU8 { + FfiVecU8 *value; + bool is_some; +} FfiOption_FfiVecU8; + +typedef struct FfiAccountDiff { + FfiAccountId id; + struct FfiBalanceDiff diff_balance; + struct FfiOption_FfiVecU8 diff_data; +} FfiAccountDiff; + +/** + * C-compatible tagged `Claim`: `is_pda` selects `Pda(seed)` over `Authorized`, in which case + * `pda_seed` is meaningless. + */ +typedef struct FfiClaim { + bool is_pda; + struct FfiBytes32 pda_seed; +} FfiClaim; + +typedef struct FfiOption_FfiClaim { + struct FfiClaim *value; + bool is_some; +} FfiOption_FfiClaim; + +typedef struct FfiAccountDiffOutput { + struct FfiAccountDiff diff; + struct FfiOption_FfiClaim claim; +} FfiAccountDiffOutput; + +typedef struct FfiPublicDiff { + FfiAccountId account_id; + struct FfiProgramId executing_program_id; + struct FfiAccountDiffOutput diff; +} FfiPublicDiff; + +typedef struct FfiVec_FfiPublicDiff { + struct FfiPublicDiff *entries; + uintptr_t len; + uintptr_t capacity; +} FfiVec_FfiPublicDiff; + +typedef struct FfiVec_FfiPublicDiff FfiPublicDiffList; + typedef struct FfiEncryptedAccountData { FfiVecU8 ciphertext; FfiVecU8 epk; @@ -268,11 +321,13 @@ typedef struct FfiVec_FfiPrivateAction { typedef struct FfiVec_FfiPrivateAction FfiPrivateActionList; typedef struct FfiPrivacyPreservingMessage { - FfiPublicActionList public_actions; + FfiPublicPreStateList public_pre_states; + FfiPublicDiffList public_diffs; FfiNonceList nonces; FfiPrivateActionList private_actions; uint64_t block_validity_window[2]; uint64_t timestamp_validity_window[2]; + FfiAccountIdList signer_account_ids; } FfiPrivacyPreservingMessage; typedef FfiVecU8 FfiProof; diff --git a/lez/indexer/ffi/src/api/types/transaction.rs b/lez/indexer/ffi/src/api/types/transaction.rs index 83f50ede5..c95ea8f99 100644 --- a/lez/indexer/ffi/src/api/types/transaction.rs +++ b/lez/indexer/ffi/src/api/types/transaction.rs @@ -1,19 +1,20 @@ use indexer_service_protocol::{ - AccountId, Ciphertext, Commitment, CommitmentSetDigest, EncryptedAccountData, - EphemeralPublicKey, HashType, Nullifier, PrivacyPreservingMessage, - PrivacyPreservingTransaction, PrivateAction, ProgramDeploymentMessage, - ProgramDeploymentTransaction, ProgramId, Proof, PublicActionWithID, PublicKey, PublicMessage, - PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, + AccountDiff, AccountDiffOutput, AccountId, AccountWithMetadata, BalanceDiff, Ciphertext, Claim, + Commitment, CommitmentSetDigest, EncryptedAccountData, EphemeralPublicKey, HashType, Nullifier, + PdaSeed, PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, + ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, Proof, PublicDiff, + PublicKey, PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, + WitnessSet, }; use crate::api::types::{ FfiAccountId, FfiBytes32, FfiHashType, FfiOption, FfiProgramId, FfiPublicKey, FfiSignature, - FfiVec, + FfiU128, FfiVec, account::FfiAccount, vectors::{ FfiAccountIdList, FfiInstructionDataList, FfiNonceList, FfiPrivateActionList, - FfiProgramDeploymentMessage, FfiProof, FfiPublicActionList, FfiSignaturePubKeyList, - FfiVecU8, + FfiProgramDeploymentMessage, FfiProof, FfiPublicDiffList, FfiPublicPreStateList, + FfiSignaturePubKeyList, FfiVecU8, }, }; @@ -158,17 +159,13 @@ impl From> for PrivacyPreservingTransaction { Self { hash: HashType(value.hash.data), message: PrivacyPreservingMessage { - public_actions: { - let std_vec: Vec<_> = value.message.public_actions.into(); - std_vec - .into_iter() - .map(|ffi_val| PublicActionWithID { - account_id: AccountId { - value: ffi_val.account_id.data, - }, - post_state: ffi_val.post_state.into(), - }) - .collect() + public_pre_states: { + let std_vec: Vec<_> = value.message.public_pre_states.into(); + std_vec.into_iter().map(Into::into).collect() + }, + public_diffs: { + let std_vec: Vec<_> = value.message.public_diffs.into(); + std_vec.into_iter().map(Into::into).collect() }, nonces: { let std_vec: Vec<_> = value.message.nonces.into(); @@ -198,6 +195,15 @@ impl From> for PrivacyPreservingTransaction { timestamp_validity_window: cast_ffi_validity_window( value.message.timestamp_validity_window, ), + signer_account_ids: { + let std_vec: Vec<_> = value.message.signer_account_ids.into(); + std_vec + .into_iter() + .map(|ffi_val| AccountId { + value: ffi_val.data, + }) + .collect() + }, }, witness_set: WitnessSet { signatures_and_public_keys: { @@ -219,20 +225,228 @@ impl From> for PrivacyPreservingTransaction { } #[repr(C)] -pub struct FfiPublicAction { +pub struct FfiAccountWithMetadata { + pub account: FfiAccount, + pub is_authorized: bool, pub account_id: FfiAccountId, - pub post_state: FfiAccount, } -impl From for FfiPublicAction { - fn from(value: PublicActionWithID) -> Self { - let post_state: lee::Account = value - .post_state - .try_into() - .expect("Source is in blocks, must fit"); +impl From for FfiAccountWithMetadata { + fn from(value: AccountWithMetadata) -> Self { + let AccountWithMetadata { + account, + is_authorized, + account_id, + } = value; + let account: lee::Account = account.try_into().expect("Source is in blocks, must fit"); Self { - account_id: value.account_id.into(), - post_state: post_state.into(), + account: account.into(), + is_authorized, + account_id: account_id.into(), + } + } +} + +impl From for AccountWithMetadata { + fn from(value: FfiAccountWithMetadata) -> Self { + let FfiAccountWithMetadata { + account, + is_authorized, + account_id, + } = value; + Self { + account: account.into(), + is_authorized, + account_id: AccountId { + value: account_id.data, + }, + } + } +} + +/// C-compatible tagged `BalanceDiff`: `is_sub` selects `Sub` over `Add`. +#[repr(C)] +pub struct FfiBalanceDiff { + pub is_sub: bool, + pub amount: FfiU128, +} + +impl From for FfiBalanceDiff { + fn from(value: BalanceDiff) -> Self { + match value { + BalanceDiff::Add(amount) => Self { + is_sub: false, + amount: amount.into(), + }, + BalanceDiff::Sub(amount) => Self { + is_sub: true, + amount: amount.into(), + }, + } + } +} + +impl From for BalanceDiff { + fn from(value: FfiBalanceDiff) -> Self { + let amount: u128 = value.amount.into(); + if value.is_sub { + Self::Sub(amount) + } else { + Self::Add(amount) + } + } +} + +/// C-compatible tagged `Claim`: `is_pda` selects `Pda(seed)` over `Authorized`, in which case +/// `pda_seed` is meaningless. +#[repr(C)] +pub struct FfiClaim { + pub is_pda: bool, + pub pda_seed: FfiBytes32, +} + +impl From for FfiClaim { + fn from(value: Claim) -> Self { + match value { + Claim::Authorized => Self { + is_pda: false, + pda_seed: FfiBytes32::default(), + }, + Claim::Pda(seed) => Self { + is_pda: true, + pda_seed: FfiBytes32 { data: seed.0 }, + }, + } + } +} + +impl From for Claim { + fn from(value: FfiClaim) -> Self { + if value.is_pda { + Self::Pda(PdaSeed(value.pda_seed.data)) + } else { + Self::Authorized + } + } +} + +#[repr(C)] +pub struct FfiAccountDiff { + pub id: FfiAccountId, + pub diff_balance: FfiBalanceDiff, + pub diff_data: FfiOption, +} + +impl From for FfiAccountDiff { + fn from(value: AccountDiff) -> Self { + let AccountDiff { + id, + diff_balance, + diff_data, + } = value; + Self { + id: id.into(), + diff_balance: diff_balance.into(), + diff_data: match diff_data { + Some(bytes) => FfiOption::from_value(bytes.into()), + None => FfiOption::from_none(), + }, + } + } +} + +impl From for AccountDiff { + fn from(value: FfiAccountDiff) -> Self { + let FfiAccountDiff { + id, + diff_balance, + diff_data, + } = value; + let diff_data = if diff_data.is_some { + let boxed = unsafe { Box::from_raw(diff_data.value) }; + let bytes: Vec = (*boxed).into(); + Some(bytes) + } else { + None + }; + Self { + id: AccountId { value: id.data }, + diff_balance: diff_balance.into(), + diff_data, + } + } +} + +#[repr(C)] +pub struct FfiAccountDiffOutput { + pub diff: FfiAccountDiff, + pub claim: FfiOption, +} + +impl From for FfiAccountDiffOutput { + fn from(value: AccountDiffOutput) -> Self { + let AccountDiffOutput { diff, claim } = value; + Self { + diff: diff.into(), + claim: match claim { + Some(claim) => FfiOption::from_value(claim.into()), + None => FfiOption::from_none(), + }, + } + } +} + +impl From for AccountDiffOutput { + fn from(value: FfiAccountDiffOutput) -> Self { + let FfiAccountDiffOutput { diff, claim } = value; + let claim = if claim.is_some { + let boxed = unsafe { Box::from_raw(claim.value) }; + Some((*boxed).into()) + } else { + None + }; + Self { + diff: diff.into(), + claim, + } + } +} + +#[repr(C)] +pub struct FfiPublicDiff { + pub account_id: FfiAccountId, + pub executing_program_id: FfiProgramId, + pub diff: FfiAccountDiffOutput, +} + +impl From for FfiPublicDiff { + fn from(value: PublicDiff) -> Self { + let PublicDiff { + account_id, + executing_program_id, + diff, + } = value; + Self { + account_id: account_id.into(), + executing_program_id: executing_program_id.into(), + diff: diff.into(), + } + } +} + +impl From for PublicDiff { + fn from(value: FfiPublicDiff) -> Self { + let FfiPublicDiff { + account_id, + executing_program_id, + diff, + } = value; + Self { + account_id: AccountId { + value: account_id.data, + }, + executing_program_id: ProgramId(executing_program_id.data), + diff: diff.into(), } } } @@ -262,25 +476,34 @@ impl From for FfiPrivateAction { #[repr(C)] pub struct FfiPrivacyPreservingMessage { - pub public_actions: FfiPublicActionList, + pub public_pre_states: FfiPublicPreStateList, + pub public_diffs: FfiPublicDiffList, pub nonces: FfiNonceList, pub private_actions: FfiPrivateActionList, pub block_validity_window: [u64; 2], pub timestamp_validity_window: [u64; 2], + pub signer_account_ids: FfiAccountIdList, } impl From for FfiPrivacyPreservingMessage { fn from(value: PrivacyPreservingMessage) -> Self { let PrivacyPreservingMessage { - public_actions, + public_pre_states, + public_diffs, nonces, private_actions, block_validity_window, timestamp_validity_window, + signer_account_ids, } = value; Self { - public_actions: public_actions + public_pre_states: public_pre_states + .into_iter() + .map(Into::into) + .collect::>() + .into(), + public_diffs: public_diffs .into_iter() .map(Into::into) .collect::>() @@ -297,6 +520,11 @@ impl From for FfiPrivacyPreservingMessage { .into(), block_validity_window: cast_validity_window(block_validity_window), timestamp_validity_window: cast_validity_window(timestamp_validity_window), + signer_account_ids: signer_account_ids + .into_iter() + .map(Into::into) + .collect::>() + .into(), } } } diff --git a/lez/indexer/ffi/src/api/types/vectors.rs b/lez/indexer/ffi/src/api/types/vectors.rs index 4cccb949f..32c5f468e 100644 --- a/lez/indexer/ffi/src/api/types/vectors.rs +++ b/lez/indexer/ffi/src/api/types/vectors.rs @@ -1,6 +1,9 @@ use crate::api::types::{ FfiAccountId, FfiNonce, FfiVec, - transaction::{FfiPrivateAction, FfiPublicAction, FfiSignaturePubKeyEntry, FfiTransaction}, + transaction::{ + FfiAccountWithMetadata, FfiPrivateAction, FfiPublicDiff, FfiSignaturePubKeyEntry, + FfiTransaction, + }, }; pub type FfiVecU8 = FfiVec; @@ -19,6 +22,8 @@ pub type FfiProof = FfiVecU8; pub type FfiProgramDeploymentMessage = FfiVecU8; -pub type FfiPublicActionList = FfiVec; +pub type FfiPublicPreStateList = FfiVec; + +pub type FfiPublicDiffList = FfiVec; pub type FfiPrivateActionList = FfiVec; diff --git a/lez/indexer/service/protocol/src/convert.rs b/lez/indexer/service/protocol/src/convert.rs index c77c93d52..b2e25b264 100644 --- a/lez/indexer/service/protocol/src/convert.rs +++ b/lez/indexer/service/protocol/src/convert.rs @@ -3,11 +3,12 @@ use lee_core::account::Nonce; use crate::{ - Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockIngestError, Ciphertext, - Commitment, CommitmentSetDigest, CrossZoneHalt, Data, EncryptedAccountData, EphemeralPublicKey, - HashType, IndexerStatus, IndexerSyncState, Nullifier, PeerHealth, PeerStatus, + Account, AccountDiff, AccountDiffOutput, AccountId, AccountWithMetadata, BalanceDiff, + BedrockStatus, Block, BlockBody, BlockHeader, BlockIngestError, Ciphertext, Claim, Commitment, + CommitmentSetDigest, CrossZoneHalt, Data, EncryptedAccountData, EphemeralPublicKey, HashType, + IndexerStatus, IndexerSyncState, Nullifier, PdaSeed, PeerHealth, PeerStatus, PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, - ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, Proof, PublicActionWithID, + ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, Proof, PublicDiff, PublicKey, PublicMessage, PublicTransaction, Signature, StallReason, Transaction, ValidityWindow, WitnessSet, }; @@ -280,11 +281,162 @@ impl From for lee::public_transaction::Message { } } -impl From for PublicActionWithID { - fn from(value: lee::privacy_preserving_transaction::message::PublicActionWithID) -> Self { +impl From for AccountWithMetadata { + fn from(value: lee_core::account::AccountWithMetadata) -> Self { + let lee_core::account::AccountWithMetadata { + account, + is_authorized, + account_id, + } = value; Self { - account_id: value.account_id.into(), - post_state: value.post_state.into(), + account: account.into(), + is_authorized, + account_id: account_id.into(), + } + } +} + +impl TryFrom for lee_core::account::AccountWithMetadata { + type Error = lee_core::account::data::DataTooBigError; + + fn try_from(value: AccountWithMetadata) -> Result { + let AccountWithMetadata { + account, + is_authorized, + account_id, + } = value; + Ok(Self { + account: account.try_into()?, + is_authorized, + account_id: account_id.into(), + }) + } +} + +impl From for BalanceDiff { + fn from(value: lee_core::account::BalanceDiff) -> Self { + match value { + lee_core::account::BalanceDiff::Add(amount) => Self::Add(amount), + lee_core::account::BalanceDiff::Sub(amount) => Self::Sub(amount), + } + } +} + +impl From for lee_core::account::BalanceDiff { + fn from(value: BalanceDiff) -> Self { + match value { + BalanceDiff::Add(amount) => Self::Add(amount), + BalanceDiff::Sub(amount) => Self::Sub(amount), + } + } +} + +impl From for PdaSeed { + fn from(value: lee_core::program::PdaSeed) -> Self { + Self(*value.as_bytes()) + } +} + +impl From for lee_core::program::PdaSeed { + fn from(value: PdaSeed) -> Self { + Self::new(value.0) + } +} + +impl From for Claim { + fn from(value: lee_core::program::Claim) -> Self { + match value { + lee_core::program::Claim::Authorized => Self::Authorized, + lee_core::program::Claim::Pda(seed) => Self::Pda(seed.into()), + } + } +} + +impl From for lee_core::program::Claim { + fn from(value: Claim) -> Self { + match value { + Claim::Authorized => Self::Authorized, + Claim::Pda(seed) => Self::Pda(seed.into()), + } + } +} + +impl From for AccountDiff { + fn from(value: lee_core::account::AccountDiff) -> Self { + let lee_core::account::AccountDiff { + id, + diff_balance, + diff_data, + } = value; + Self { + id: id.into(), + diff_balance: diff_balance.into(), + diff_data, + } + } +} + +impl From for lee_core::account::AccountDiff { + fn from(value: AccountDiff) -> Self { + let AccountDiff { + id, + diff_balance, + diff_data, + } = value; + Self { + id: id.into(), + diff_balance: diff_balance.into(), + diff_data, + } + } +} + +impl From for AccountDiffOutput { + fn from(value: lee_core::program::AccountDiffOutput) -> Self { + let claim = value.required_claim().map(Into::into); + Self { + diff: value.into_diff().into(), + claim, + } + } +} + +impl From for lee_core::program::AccountDiffOutput { + fn from(value: AccountDiffOutput) -> Self { + let AccountDiffOutput { diff, claim } = value; + match claim { + Some(claim) => Self::new_claimed(diff.into(), claim.into()), + None => Self::new(diff.into()), + } + } +} + +impl From for PublicDiff { + fn from(value: lee_core::PublicDiff) -> Self { + let lee_core::PublicDiff { + account_id, + executing_program_id, + diff, + } = value; + Self { + account_id: account_id.into(), + executing_program_id: executing_program_id.into(), + diff: diff.into(), + } + } +} + +impl From for lee_core::PublicDiff { + fn from(value: PublicDiff) -> Self { + let PublicDiff { + account_id, + executing_program_id, + diff, + } = value; + Self { + account_id: account_id.into(), + executing_program_id: executing_program_id.into(), + diff: diff.into(), } } } @@ -303,38 +455,26 @@ impl From for PrivateAction { impl From for PrivacyPreservingMessage { fn from(value: lee::privacy_preserving_transaction::message::Message) -> Self { let lee::privacy_preserving_transaction::message::Message { - public_actions, + public_pre_states, + public_diffs, nonces, private_actions, block_validity_window, timestamp_validity_window, + signer_account_ids, } = value; Self { - public_actions: public_actions.into_iter().map(Into::into).collect(), + public_pre_states: public_pre_states.into_iter().map(Into::into).collect(), + public_diffs: public_diffs.into_iter().map(Into::into).collect(), nonces: nonces.iter().map(|x| x.0).collect(), private_actions: private_actions.into_iter().map(Into::into).collect(), block_validity_window: block_validity_window.into(), timestamp_validity_window: timestamp_validity_window.into(), + signer_account_ids: signer_account_ids.into_iter().map(Into::into).collect(), } } } -impl TryFrom - for lee::privacy_preserving_transaction::message::PublicActionWithID -{ - type Error = lee::error::LeeError; - - fn try_from(value: PublicActionWithID) -> Result { - Ok(Self { - account_id: value.account_id.into(), - post_state: value - .post_state - .try_into() - .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?, - }) - } -} - impl From for lee_core::PrivateAction { fn from(value: PrivateAction) -> Self { Self { @@ -351,21 +491,26 @@ impl TryFrom for lee::privacy_preserving_transaction:: fn try_from(value: PrivacyPreservingMessage) -> Result { let PrivacyPreservingMessage { - public_actions, + public_pre_states, + public_diffs, nonces, private_actions, block_validity_window, timestamp_validity_window, + signer_account_ids, } = value; - let public_actions = public_actions + let public_pre_states = public_pre_states .into_iter() .map(TryInto::try_into) - .collect::, _>>()?; + .collect::, _>>() + .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?; + let public_diffs = public_diffs.into_iter().map(Into::into).collect(); let private_actions = private_actions.into_iter().map(Into::into).collect(); Ok(Self { - public_actions, + public_pre_states, + public_diffs, nonces: nonces .iter() .map(|x| lee_core::account::Nonce(*x)) @@ -377,6 +522,7 @@ impl TryFrom for lee::privacy_preserving_transaction:: timestamp_validity_window: timestamp_validity_window .try_into() .map_err(|e| lee::error::LeeError::InvalidInput(format!("{e}")))?, + signer_account_ids: signer_account_ids.into_iter().map(Into::into).collect(), }) } } diff --git a/lez/indexer/service/protocol/src/lib.rs b/lez/indexer/service/protocol/src/lib.rs index 6f5df0461..64e715428 100644 --- a/lez/indexer/service/protocol/src/lib.rs +++ b/lez/indexer/service/protocol/src/lib.rs @@ -35,6 +35,35 @@ mod base64 { } } + pub mod opt { + use base64::prelude::{BASE64_STANDARD, Engine as _}; + + use super::{Deserializer, Serializer}; + + // `Option<&Vec>` isn't usable here: `#[serde(with = "base64::opt")]` always calls + // this with `&self.field`, i.e. `&Option>` verbatim — serde has no equivalent of + // `Option::as_ref` to redistribute the reference inward before the call. + #[expect( + clippy::ref_option, + reason = "signature is fixed by serde's `with` calling convention" + )] + pub fn serialize(v: &Option>, s: S) -> Result { + let encoded = v.as_ref().map(|bytes| BASE64_STANDARD.encode(bytes)); + serde::Serialize::serialize(&encoded, s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result>, D::Error> { + let encoded: Option = serde::Deserialize::deserialize(d)?; + encoded + .map(|s| { + BASE64_STANDARD + .decode(s.as_bytes()) + .map_err(serde::de::Error::custom) + }) + .transpose() + } + } + pub fn serialize(v: &[u8], s: S) -> Result { let base64 = BASE64_STANDARD.encode(v); String::serialize(&base64, s) @@ -227,9 +256,54 @@ pub struct PublicMessage { pub type InstructionData = Vec; #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] -pub struct PublicActionWithID { +pub struct AccountWithMetadata { + pub account: Account, + pub is_authorized: bool, pub account_id: AccountId, - pub post_state: Account, +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum BalanceDiff { + Add(u128), + Sub(u128), +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct PdaSeed( + #[serde(with = "base64::arr")] + #[schemars(with = "String", description = "base64-encoded PDA seed")] + pub [u8; 32], +); + +#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum Claim { + Authorized, + Pda(PdaSeed), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct AccountDiff { + pub id: AccountId, + pub diff_balance: BalanceDiff, + #[serde(with = "base64::opt")] + #[schemars( + with = "Option", + description = "base64-encoded account data diff" + )] + pub diff_data: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct AccountDiffOutput { + pub diff: AccountDiff, + pub claim: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub struct PublicDiff { + pub account_id: AccountId, + pub executing_program_id: ProgramId, + pub diff: AccountDiffOutput, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] @@ -245,11 +319,13 @@ pub struct PrivateAction { #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] pub struct PrivacyPreservingMessage { - pub public_actions: Vec, + pub public_pre_states: Vec, + pub public_diffs: Vec, pub nonces: Vec, pub private_actions: Vec, pub block_validity_window: ValidityWindow, pub timestamp_validity_window: ValidityWindow, + pub signer_account_ids: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] diff --git a/lez/indexer/service/src/mock_service.rs b/lez/indexer/service/src/mock_service.rs index e810e9a9d..4d9b4f494 100644 --- a/lez/indexer/service/src/mock_service.rs +++ b/lez/indexer/service/src/mock_service.rs @@ -9,11 +9,12 @@ use std::{collections::HashMap, sync::Arc, time::Duration}; use indexer_service_protocol::{ - Account, AccountId, BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment, - CommitmentSetDigest, Data, EncryptedAccountData, HashType, IndexerStatus, IndexerSyncState, - PrivacyPreservingMessage, PrivacyPreservingTransaction, PrivateAction, - ProgramDeploymentMessage, ProgramDeploymentTransaction, ProgramId, PublicActionWithID, - PublicMessage, PublicTransaction, Signature, Transaction, ValidityWindow, WitnessSet, + Account, AccountDiff, AccountDiffOutput, AccountId, AccountWithMetadata, BalanceDiff, + BedrockStatus, Block, BlockBody, BlockHeader, BlockId, Commitment, CommitmentSetDigest, Data, + EncryptedAccountData, HashType, IndexerStatus, IndexerSyncState, PrivacyPreservingMessage, + PrivacyPreservingTransaction, PrivateAction, ProgramDeploymentMessage, + ProgramDeploymentTransaction, ProgramId, PublicDiff, PublicMessage, PublicTransaction, + Signature, Transaction, ValidityWindow, WitnessSet, }; use jsonrpsee::{ core::{SubscriptionResult, async_trait}, @@ -304,9 +305,9 @@ impl indexer_service_rpc::RpcServer for MockIndexerService { Transaction::Public(pub_tx) => pub_tx.message.account_ids.contains(&account_id), Transaction::PrivacyPreserving(priv_tx) => priv_tx .message - .public_actions + .public_pre_states .iter() - .any(|action| action.account_id == account_id), + .any(|pre_state| pre_state.account_id == account_id), Transaction::ProgramDeployment(_) => false, }) .cloned() @@ -384,17 +385,31 @@ fn mock_privacy_preserving_tx( tx_idx: u64, account_ids: &[AccountId], ) -> Transaction { + let public_account_id = account_ids[tx_idx as usize % account_ids.len()]; Transaction::PrivacyPreserving(PrivacyPreservingTransaction { hash: tx_hash, message: PrivacyPreservingMessage { - public_actions: vec![PublicActionWithID { - account_id: account_ids[tx_idx as usize % account_ids.len()], - post_state: Account { + public_pre_states: vec![AccountWithMetadata { + account: Account { program_owner: AccountId { value: [1_u8; 32] }, balance: 500, data: Data(vec![0xdd, 0xee]), nonce: block_id as u128, }, + is_authorized: true, + account_id: public_account_id, + }], + public_diffs: vec![PublicDiff { + account_id: public_account_id, + executing_program_id: ProgramId([1_u32; 8]), + diff: AccountDiffOutput { + diff: AccountDiff { + id: public_account_id, + diff_balance: BalanceDiff::Add(0), + diff_data: None, + }, + claim: None, + }, }], nonces: vec![block_id as u128], private_actions: vec![PrivateAction { @@ -409,6 +424,7 @@ fn mock_privacy_preserving_tx( }], block_validity_window: ValidityWindow((None, None)), timestamp_validity_window: ValidityWindow((None, None)), + signer_account_ids: vec![public_account_id], }, witness_set: WitnessSet { signatures_and_public_keys: vec![], diff --git a/test_fixtures/fixtures/prebuilt_sequencer_db.dump b/test_fixtures/fixtures/prebuilt_sequencer_db.dump index 21b3c1103..f78c11e32 100644 Binary files a/test_fixtures/fixtures/prebuilt_sequencer_db.dump and b/test_fixtures/fixtures/prebuilt_sequencer_db.dump differ