diff --git a/integration_tests/tests/bridge.rs b/integration_tests/tests/bridge.rs index 9c2fa8c5..9efdd641 100644 --- a/integration_tests/tests/bridge.rs +++ b/integration_tests/tests/bridge.rs @@ -98,6 +98,66 @@ async fn public_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { Ok(()) } +#[test] +async fn public_bridge_deposit_with_zero_amount_is_rejected() -> anyhow::Result<()> { + let ctx = TestContext::new().await?; + + let recipient_id = ctx.existing_public_accounts()[0]; + let bridge_account_id = system_accounts::bridge_account_id(); + let vault_program_id = programs::vault().id(); + let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, recipient_id); + + let message = public_transaction::Message::try_new( + programs::bridge().id(), + vec![bridge_account_id, recipient_vault_id], + vec![], + bridge_core::Instruction::Deposit { + l1_deposit_op_id: [0_u8; 32], + vault_program_id, + recipient_id, + amount: 0, + }, + ) + .context("Failed to build zero-amount public bridge deposit transaction")?; + + let attack_tx = LeeTransaction::Public(lee::PublicTransaction::new( + message, + lee::public_transaction::WitnessSet::from_raw_parts(vec![]), + )); + + let bridge_balance_before = ctx + .sequencer_client() + .get_account_balance(bridge_account_id) + .await?; + let vault_balance_before = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + + let tx_hash = ctx.sequencer_client().send_transaction(attack_tx).await?; + + tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; + + let bridge_balance_after = ctx + .sequencer_client() + .get_account_balance(bridge_account_id) + .await?; + let vault_balance_after = ctx + .sequencer_client() + .get_account_balance(recipient_vault_id) + .await?; + let tx_on_chain = ctx.sequencer_client().get_transaction(tx_hash).await?; + + assert_eq!(bridge_balance_after, bridge_balance_before); + assert_eq!(vault_balance_after, vault_balance_before); + assert!( + tx_on_chain.is_none(), + "Public bridge::Deposit with zero amount should be rejected" + ); + + Ok(()) +} + #[test] async fn private_bridge_deposit_invocation_is_dropped() -> anyhow::Result<()> { let ctx = TestContext::new().await?; diff --git a/lez/common/src/transaction.rs b/lez/common/src/transaction.rs index eec01d4e..b5aee648 100644 --- a/lez/common/src/transaction.rs +++ b/lez/common/src/transaction.rs @@ -173,7 +173,7 @@ impl LeeTransaction { balance: pre.balance, ..post.clone() }; - (expected_pre == pre) && (pre.balance <= post.balance) + (expected_pre == pre) && (pre.balance < post.balance) }; if only_balance_increased { diff --git a/lez/sequencer/core/src/lib.rs b/lez/sequencer/core/src/lib.rs index b140fda8..35c0c33b 100644 --- a/lez/sequencer/core/src/lib.rs +++ b/lez/sequencer/core/src/lib.rs @@ -729,26 +729,23 @@ fn extract_bridge_withdraw_data(tx: &LeeTransaction) -> Option { risc0_zkvm::serde::from_slice::(&message.instruction_data) .ok()?; - match instruction { - bridge_core::Instruction::Withdraw { - amount, - bedrock_account_pk, - } => { - let recipient_pk = - logos_blockchain_key_management_system_service::keys::ZkPublicKey::from( - BigUint::from_bytes_le(&bedrock_account_pk), - ); + let bridge_core::Instruction::Withdraw { + amount, + bedrock_account_pk, + } = instruction + else { + return None; + }; - Some(WithdrawArg { - outputs: logos_blockchain_core::mantle::ledger::Outputs::new( - logos_blockchain_core::mantle::Note::new(amount, recipient_pk), - ), - }) - } - bridge_core::Instruction::Deposit { .. } => unreachable!( - "Deposit instructions from users should never pass validation, and thus should never be seen here" + let recipient_pk = logos_blockchain_key_management_system_service::keys::ZkPublicKey::from( + BigUint::from_bytes_le(&bedrock_account_pk), + ); + + Some(WithdrawArg { + outputs: logos_blockchain_core::mantle::ledger::Outputs::new( + logos_blockchain_core::mantle::Note::new(amount, recipient_pk), ), - } + }) } fn withdraw_event_reconciliation_key(