From d6e68a52ca4e79d1d00b8c9b1ef226cb9b621fad Mon Sep 17 00:00:00 2001 From: Daniil Polyakov Date: Tue, 2 Jun 2026 15:56:46 +0300 Subject: [PATCH] feat(state): forbid private bridge withdrawals --- Cargo.lock | 1 + lee/state_machine/core/src/commitment.rs | 1 + lee/state_machine/core/src/nullifier.rs | 1 + lez/sequencer/core/Cargo.toml | 1 + lez/sequencer/core/src/lib.rs | 109 +++++++++++++++++++++++ 5 files changed, 113 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 7dae1215..7ccc172b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9011,6 +9011,7 @@ dependencies = [ "futures", "hex", "humantime-serde", + "key_protocol", "lee", "lee_core", "log", diff --git a/lee/state_machine/core/src/commitment.rs b/lee/state_machine/core/src/commitment.rs index 7c81c12c..92085d7d 100644 --- a/lee/state_machine/core/src/commitment.rs +++ b/lee/state_machine/core/src/commitment.rs @@ -52,6 +52,7 @@ impl std::fmt::Debug for Commitment { impl Commitment { /// Generates the commitment to a private account owned by user for `account_id`: /// SHA256( `Comm_DS` || `account_id` || `program_owner` || balance || nonce || SHA256(data)). + // TODO: Accept account_id by value as it's Copy #[must_use] pub fn new(account_id: &AccountId, account: &Account) -> Self { const COMMITMENT_PREFIX: &[u8; 32] = diff --git a/lee/state_machine/core/src/nullifier.rs b/lee/state_machine/core/src/nullifier.rs index d1fbae42..0490ac00 100644 --- a/lee/state_machine/core/src/nullifier.rs +++ b/lee/state_machine/core/src/nullifier.rs @@ -97,6 +97,7 @@ impl Nullifier { } /// Computes a nullifier for an account initialization. + // TODO: Accept account_id by value as it's Copy #[must_use] pub fn for_account_initialization(account_id: &AccountId) -> Self { const INIT_PREFIX: &[u8; 32] = b"/LEE/v0.3/Nullifier/Initialize/\x00"; diff --git a/lez/sequencer/core/Cargo.toml b/lez/sequencer/core/Cargo.toml index d5bd5c3a..8a6a8c08 100644 --- a/lez/sequencer/core/Cargo.toml +++ b/lez/sequencer/core/Cargo.toml @@ -47,3 +47,4 @@ mock = [] futures.workspace = true test_program_methods.workspace = true lee = { workspace = true, features = ["test-utils"] } +key_protocol.workspace = true diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index ce0cadeb..60345357 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -744,6 +744,20 @@ mod tests { test_utils::sequencer_sign_key_for_testing, transaction::{LeeTransaction, clock_invocation}, }; + use key_protocol::key_management::KeyChain; + use lee::{ + Account, AccountId, Data, EphemeralPublicKey, PrivacyPreservingTransaction, + SharedSecretKey, V03State, + error::LeeError, + execute_and_prove, + privacy_preserving_transaction::{Message, circuit::ProgramWithDependencies}, + program::Program, + system_bridge_account_id, + }; + use lee_core::{ + Commitment, InputAccountIdentity, Nullifier, + account::{AccountWithMetadata, Nonce}, + }; use logos_blockchain_core::mantle::ops::channel::ChannelId; use mempool::MemPoolHandle; use storage::sequencer::sequencer_cells::PendingDepositEventRecord; @@ -1514,4 +1528,99 @@ mod tests { "Block production should abort when clock account data is corrupted" ); } + + #[test] + fn private_bridge_withdraw_invocation_is_dropped() { + let sender_keys = KeyChain::new_os_random(); + let sender_account_id = + AccountId::for_regular_private_account(&sender_keys.nullifier_public_key, 0); + let sender_private_account = Account { + program_owner: Program::authenticated_transfer_program().id(), + balance: 100, + nonce: Nonce(0xdead_beef), + data: Data::default(), + }; + + let mut state = V03State::new_with_genesis_accounts( + &[], + vec![( + Commitment::new(&sender_account_id, &sender_private_account), + Nullifier::for_account_initialization(&sender_account_id), + )], + 0, + ); + + let sender_commitment = Commitment::new(&sender_account_id, &sender_private_account); + let bridge_account_id = system_bridge_account_id(); + + let sender_pre = AccountWithMetadata::new( + sender_private_account, + true, + (&sender_keys.nullifier_public_key, 0), + ); + let bridge_pre = AccountWithMetadata::new( + state.get_account_by_id(bridge_account_id), + false, + bridge_account_id, + ); + + let shared_secret = SharedSecretKey::encapsulate(&sender_keys.viewing_public_key).0; + + let instruction = Program::serialize_instruction(bridge_core::Instruction::Withdraw { + amount: 1, + bedrock_account_pk: [0; 32], + }) + .unwrap(); + + let program_with_deps = ProgramWithDependencies::new( + Program::bridge(), + [( + Program::authenticated_transfer_program().id(), + Program::authenticated_transfer_program(), + )] + .into(), + ); + + let (output, proof) = execute_and_prove( + vec![sender_pre, bridge_pre], + instruction, + vec![ + InputAccountIdentity::PrivateAuthorizedUpdate { + ssk: shared_secret, + nsk: sender_keys.private_key_holder.nullifier_secret_key, + membership_proof: state + .get_proof_for_commitment(&sender_commitment) + .expect("sender commitment must be in state"), + identifier: 0, + }, + InputAccountIdentity::Public, + ], + &program_with_deps, + ) + .expect("Execution should succeed"); + + let message = Message::try_from_circuit_output( + vec![bridge_account_id], + vec![], + vec![( + sender_keys.nullifier_public_key, + sender_keys.viewing_public_key, + EphemeralPublicKey(vec![12_u8; 1088]), + )], + output, + ) + .expect("Message construction should succeed"); + let witness_set = + lee::privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); + let tx = LeeTransaction::PrivacyPreserving(PrivacyPreservingTransaction::new( + message, + witness_set, + )); + let res = tx.execute_check_on_state(&mut state, 1, 0); + + assert!( + matches!(res, Err(LeeError::InvalidInput(_))), + "Bridge withdraw invocation should be rejected in private execution" + ); + } }