feat(state): forbid private bridge withdrawals

This commit is contained in:
Daniil Polyakov 2026-06-02 15:56:46 +03:00
parent 9e3ec3ad0e
commit 63e4403cc5
6 changed files with 199 additions and 0 deletions

1
Cargo.lock generated
View File

@ -9035,6 +9035,7 @@ dependencies = [
"futures",
"hex",
"humantime-serde",
"key_protocol",
"lee",
"lee_core",
"log",

View File

@ -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] =

View File

@ -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";

View File

@ -4725,4 +4725,90 @@ pub mod tests {
assert_eq!(state.get_account_by_id(recipient_id).balance, amount);
}
#[test]
fn private_bridge_withdraw_invocation_is_dropped() {
let sender_keys = test_private_account_keys_1();
let sender_private_account = Account {
// Keep sender private account owned by a non-authenticated-transfer program
// so bridge::Withdraw is rejected.
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![], 0)
.with_private_account(&sender_keys, &sender_private_account);
let sender_account_id = AccountId::for_regular_private_account(&sender_keys.npk(), 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.npk(), 0));
let bridge_pre = AccountWithMetadata::new(
state.get_account_by_id(bridge_account_id),
false,
bridge_account_id,
);
let esk = [3; 32];
let shared_secret = SharedSecretKey::new(esk, &sender_keys.vpk());
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 bridge_nonce = bridge_pre.account.nonce;
let (output, proof) = execute_and_prove(
vec![sender_pre, bridge_pre],
instruction,
vec![
InputAccountIdentity::PrivateAuthorizedUpdate {
ssk: shared_secret,
nsk: sender_keys.nsk,
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![bridge_nonce],
vec![(
sender_keys.npk(),
sender_keys.vpk(),
EphemeralPublicKey::from_scalar(esk),
)],
output,
)
.expect("Message construction should succeed");
let witness_set = WitnessSet::for_message(&message, proof, &[]);
let tx = PrivacyPreservingTransaction::new(message, witness_set);
let res = state.transition_from_privacy_preserving_transaction(&tx, 1, 0);
assert!(
res.is_err(),
"Bridge withdraw invocation should be rejected in private execution"
);
}
}

View File

@ -47,3 +47,4 @@ mock = []
futures.workspace = true
test_program_methods.workspace = true
lee = { workspace = true, features = ["test-utils"] }
key_protocol.workspace = true

View File

@ -757,6 +757,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;
@ -1531,4 +1545,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"
);
}
}