#![expect( clippy::tests_outside_test_module, reason = "We don't care about these in tests" )] use std::time::Duration; use anyhow::Context as _; use common::transaction::LeeTransaction; use integration_tests::{ TIME_TO_WAIT_FOR_BLOCK_SECONDS, TestContext, account_balance, get_account, }; use lee::{ execute_and_prove, privacy_preserving_transaction, program::Program, public_transaction, }; use lee_core::{InputAccountIdentity, account::AccountWithMetadata}; use sequencer_service_rpc::RpcClient as _; use tokio::test; // const TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK: Duration = Duration::from_mins(2); #[test] async fn public_bridge_deposit_invocation_is_dropped() -> 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 receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]); let message = public_transaction::Message::try_new( programs::bridge().id(), vec![bridge_account_id, recipient_vault_id, receipt_id], vec![], bridge_core::Instruction::Deposit { l1_deposit_op_id: [0_u8; 32], vault_program_id, recipient_id, amount: 1, }, ) .context("Failed to build 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 = account_balance(&ctx, bridge_account_id).await?; let vault_balance_before = account_balance(&ctx, 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 = account_balance(&ctx, bridge_account_id).await?; let vault_balance_after = account_balance(&ctx, 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(), "Direct public bridge::Deposit invocation should be rejected" ); 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 receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]); let message = public_transaction::Message::try_new( programs::bridge().id(), vec![bridge_account_id, recipient_vault_id, receipt_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?; 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 receipt_id = bridge_core::deposit_receipt_account_id(programs::bridge().id(), [0_u8; 32]); // Get pre-state of bridge and vault accounts; the receipt is unminted (a // default account), so the program would create it on a first mint. let bridge_pre = AccountWithMetadata::new( get_account(&ctx, bridge_account_id).await?, false, bridge_account_id, ); let vault_pre = AccountWithMetadata::new( get_account(&ctx, recipient_vault_id).await?, false, recipient_vault_id, ); let receipt_pre = AccountWithMetadata::new(lee_core::account::Account::default(), false, receipt_id); // Create program with dependencies let program_with_deps = lee::privacy_preserving_transaction::circuit::ProgramWithDependencies::new( programs::bridge(), [ (vault_program_id, programs::vault()), ( programs::authenticated_transfer().id(), programs::authenticated_transfer(), ), ] .into(), ); // Serialize the bridge deposit instruction let instruction = Program::serialize_instruction(bridge_core::Instruction::Deposit { l1_deposit_op_id: [0_u8; 32], vault_program_id, recipient_id, amount: 1, }) .context("Failed to serialize bridge deposit instruction")?; // Execute and prove the bridge deposit let (output, proof) = execute_and_prove( vec![bridge_pre.clone(), vault_pre.clone(), receipt_pre.clone()], instruction, vec![ InputAccountIdentity::Public, InputAccountIdentity::Public, InputAccountIdentity::Public, ], &program_with_deps, ) .context("Failed to execute/prove bridge deposit")?; // Create privacy-preserving transaction from circuit output let message = privacy_preserving_transaction::Message::try_from_circuit_output( vec![bridge_account_id, recipient_vault_id, receipt_id], vec![ bridge_pre.account.nonce, vault_pre.account.nonce, receipt_pre.account.nonce, ], output, ) .context("Failed to build privacy-preserving bridge deposit message")?; let witness_set = privacy_preserving_transaction::WitnessSet::for_message(&message, proof, &[]); let attack_tx = LeeTransaction::PrivacyPreserving(lee::PrivacyPreservingTransaction::new( message, witness_set, )); let bridge_balance_before = account_balance(&ctx, bridge_account_id).await?; let vault_balance_before = account_balance(&ctx, 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 = account_balance(&ctx, bridge_account_id).await?; let vault_balance_after = account_balance(&ctx, 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(), "Privacy-preserving bridge::Deposit invocation should be rejected" ); Ok(()) } // async fn submit_bedrock_deposit( // bedrock_addr: std::net::SocketAddr, // bedrock_account_pk: &str, // recipient_id: AccountId, // amount: u64, // ) -> anyhow::Result<()> { // #[derive(BorshSerialize)] // struct DepositMetadata { // recipient_id: AccountId, // } // // Encode deposit metadata // let metadata = borsh::to_vec(&DepositMetadata { recipient_id }) // .context("Failed to encode deposit metadata")? // .try_into() // .context("Encoded metadata is too big")?; // let channel_id = integration_tests::config::bedrock_channel_id(); // let client = reqwest::Client::new(); // let mut balance = bedrock_wallet_balance(bedrock_addr, bedrock_account_pk).await?; // info!( // "Queried Bedrock balance for key {bedrock_account_pk}: {:?}", // balance.balance // ); // if balance.balance < amount { // anyhow::bail!( // "Bedrock wallet with key {bedrock_account_pk} has insufficient balance {:?} for // deposit amount {:?}", balance.balance, // amount // ); // } // let mut selected_note_id = balance // .notes // .iter() // .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); // if selected_note_id.is_none() { // let transfer_body = WalletTransferFundsRequestBody { // tip: None, // change_public_key: balance.address, // funding_public_keys: vec![balance.address], // recipient_public_key: balance.address, // amount, // }; // let transfer_response = client // .post(format!( // "http://{bedrock_addr}/wallet/transactions/transfer-funds" // )) // .json(&transfer_body) // .send() // .await // .context("Failed to submit Bedrock transfer-funds request")?; // let transfer_response = check_response_success(transfer_response).await?; // let transfer: WalletTransferFundsResponseBody = transfer_response // .json() // .await // .context("Failed to decode Bedrock transfer-funds response")?; // info!( // "Submitted transfer-funds to create exact deposit note, tx hash {:?}", // transfer.hash // ); // let mut found_note = None; // for _ in 0..20 { // tokio::time::sleep(Duration::from_millis(500)).await; // balance = bedrock_wallet_balance(bedrock_addr, bedrock_account_pk).await?; // found_note = balance // .notes // .iter() // .find_map(|(note_id, value)| (*value == amount).then_some(*note_id)); // if found_note.is_some() { // break; // } // } // selected_note_id = found_note; // } // let Some(selected_note_id) = selected_note_id else { // anyhow::bail!( // "Failed to locate exact-value note {amount:?} for Bedrock deposit; available notes: // {:?}", balance.notes, // ); // }; // let body = ChannelDepositRequestBody { // tip: None, // deposit: DepositOp { // channel_id, // inputs: Inputs::new(selected_note_id), // metadata, // }, // change_public_key: balance.address, // funding_public_keys: vec![balance.address], // max_tx_fee: u64::MAX.into(), // }; // let response = client // .post(format!("http://{bedrock_addr}/channel/deposit")) // .json(&body) // .send() // .await // .context("Failed to submit Bedrock deposit request")?; // let response = check_response_success(response).await?; // let body_text = response // .text() // .await // .unwrap_or_else(|_| "".to_owned()); // info!( // "Successfully submitted Bedrock deposit request for recipient {recipient_id} and amount // {amount}, response body: {body_text}", ); // Ok(()) // } // /// The Bedrock wallet state of `bedrock_account_pk`: its total balance and the // /// notes it owns, keyed by note id. // async fn bedrock_wallet_balance( // bedrock_addr: std::net::SocketAddr, // bedrock_account_pk: &str, // ) -> anyhow::Result { // let response = reqwest::Client::new() // .get(format!( // "http://{bedrock_addr}/wallet/{bedrock_account_pk}/balance" // )) // .send() // .await // .context("Failed to query Bedrock wallet balance")?; // check_response_success(response) // .await? // .json::() // .await // .context("Failed to decode Bedrock balance response") // } // async fn check_response_success(response: reqwest::Response) -> anyhow::Result // { if response.status().is_success() { // Ok(response) // } else { // let status = response.status(); // let body_text = response.text().await.unwrap_or_default(); // anyhow::bail!("Request failed with status {status} and body {body_text}"); // } // } // async fn wait_for_vault_balance( // ctx: &TestContext, // vault_id: AccountId, // expected_balance: u128, // ) -> anyhow::Result<()> { // let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK // + Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS); // tokio::time::timeout(timeout, async { // loop { // let balance = account_balance(ctx, vault_id).await?; // if balance == expected_balance { // return Ok(()); // } // tokio::time::sleep(Duration::from_millis(500)).await; // } // }) // .await // .with_context(|| { // format!("Timed out waiting for vault {vault_id} balance to reach {expected_balance}") // })? // } // /// Test deposit and withdraw round trip. // /// // /// Implemented as one test instead of two separate tests for deposit and withdraw, because the // /// withdraw test depends on the deposit to set up the necessary state (funds in vault) for // testing /// withdraw functionality. // #[test] // async fn bedrock_deposit_claim_and_withdraw_round_trip_succeeds() -> anyhow::Result<()> { // let mut ctx = TestContext::new().await?; // let bedrock_account_pk = "2e03b2eff5a45478e7e79668d2a146cf2c5c7925bce927f2b1c67f2ab4fc0d26"; // let recipient_id = ctx.existing_public_accounts()[0]; // let amount = 1_u64; // let vault_program_id = programs::vault().id(); // let recipient_vault_id = vault_core::compute_vault_account_id(vault_program_id, // recipient_id); // let vault_balance_before = account_balance(&ctx, recipient_vault_id).await?; // let recipient_balance_before = account_balance(&ctx, recipient_id).await?; // // Submit deposit to Bedrock // submit_bedrock_deposit(ctx.bedrock_addr(), bedrock_account_pk, recipient_id, amount) // .await // .context("Failed to submit Bedrock deposit for round-trip setup")?; // // Wait for vault to receive the deposit (minted from bridge to vault) // wait_for_vault_balance( // &ctx, // recipient_vault_id, // vault_balance_before + u128::from(amount), // ) // .await?; // // Now claim funds from vault back to recipient // let nonces = ctx // .wallet() // .get_accounts_nonces(&[recipient_id]) // .await // .context("Failed to get nonce for vault claim")?; // let signing_key = ctx // .wallet() // .storage() // .key_chain() // .pub_account_signing_key(recipient_id) // .with_context(|| format!("Missing signing key for account {recipient_id}"))?; // let claim_message = public_transaction::Message::try_new( // vault_program_id, // vec![recipient_id, recipient_vault_id], // nonces, // vault_core::Instruction::Claim { // amount: u128::from(amount), // }, // ) // .context("Failed to build vault claim message")?; // let claim_witness_set = // public_transaction::WitnessSet::for_message(&claim_message, &[signing_key]); // let claim_tx = LeeTransaction::Public(lee::PublicTransaction::new( // claim_message, // claim_witness_set, // )); // let claim_hash = ctx.sequencer_client().send_transaction(claim_tx).await?; // tokio::time::sleep(Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS)).await; // let claim_on_chain = ctx.sequencer_client().get_transaction(claim_hash).await?; // let vault_balance_after_claim = account_balance(&ctx, recipient_vault_id).await?; // let recipient_balance_after_claim = account_balance(&ctx, recipient_id).await?; // assert!( // claim_on_chain.is_some(), // "Vault claim transaction must be included on-chain" // ); // assert_eq!( // vault_balance_after_claim, vault_balance_before, // "Vault balance should return to initial state after claim" // ); // assert_eq!( // recipient_balance_after_claim, // recipient_balance_before + u128::from(amount), // "Recipient balance should increase by claimed amount" // ); // // The indexer must replay the deposit and claim blocks and reach the same // // state as the sequencer — including the bridge system account the deposit // // modifies, which is the case the hot fix unblocks. // wait_for_indexer_to_catch_up(&ctx).await?; // let bridge_account_id = system_accounts::bridge_account_id(); // for account_id in [recipient_id, recipient_vault_id, bridge_account_id] { // let indexer_account = indexer_service_rpc::RpcClient::get_account( // // `deref` is needed for correct trait resolution // // of the async `get_account` method on `RpcClient` // ctx.indexer_client().deref(), // account_id.into(), // ) // .await?; // let sequencer_account = get_account(&ctx, account_id).await?; // assert_eq!( // indexer_account, // sequencer_account.into(), // "Indexer and sequencer diverged for account {account_id} after deposit" // ); // } // // Withdraw back to Bedrock and wait for finalized withdraw event. // let sender_id = recipient_id; // let observer = create_zone_indexer_observer(ctx.bedrock_addr())?; // let observe_fut = // wait_for_finalized_withdraw_op(&observer, ctx.bedrock_addr(), amount, // bedrock_account_pk); // let withdraw_fut = execute_subcommand( // ctx.wallet_mut(), // Command::Bridge(BridgeSubcommand::Withdraw { // from: public_mention(sender_id), // amount, // bedrock_account_pk: bedrock_account_pk.to_owned(), // }), // ); // let (observe_result, withdraw_result) = tokio::join!(observe_fut, withdraw_fut); // withdraw_result.context("Failed to execute wallet bridge withdraw command")?; // observe_result // .context("Failed while waiting for finalized withdraw event from zone indexer")?; // // Sleep to observe sequencer log about validated withdraw event // tokio::time::sleep(Duration::from_secs(1)).await; // Ok(()) // } // fn create_zone_indexer_observer( // bedrock_addr: std::net::SocketAddr, // ) -> anyhow::Result> { // let bedrock_url = integration_tests::config::addr_to_url( // integration_tests::config::UrlProtocol::Http, // bedrock_addr, // ) // .context("Failed to convert Bedrock addr to URL for zone indexer observer")?; // let node = NodeHttpClient::new(CommonHttpClient::new(None), bedrock_url); // Ok(ZoneIndexer::new( // integration_tests::config::bedrock_channel_id(), // node, // )) // } // /// Waits for a finalized withdraw that pays `expected_amount` to `receiver_pk`. // /// // /// A withdraw op releases channel-owned notes and carries nothing but their // /// ids — the value and recipient live in the note itself. A released note keeps // /// its id, value and public key, so the pairing is checked on the receiver's // /// Bedrock wallet: one of the released notes must land there with the expected // /// value. // async fn wait_for_finalized_withdraw_op( // observer: &ZoneIndexer, // bedrock_addr: std::net::SocketAddr, // expected_amount: u64, // receiver_pk: &str, // ) -> anyhow::Result<()> { // let timeout = TIME_TO_FINALIZE_DEPOSIT_EVENT_ON_BEDROCK // + Duration::from_secs(TIME_TO_WAIT_FOR_BLOCK_SECONDS); // tokio::time::timeout(timeout, async { // // The wallet can trail the channel event, so released notes accumulate // // across polls instead of being checked once when first observed. // let mut released_notes = HashSet::new(); // loop { // let stream = observer // .follow() // .await // .context("Failed to read zone indexer message batch")?; // let mut stream = std::pin::pin!(stream); // while let Some(message) = stream.next().await { // info!("Observed zone message {message:?}"); // if let ZoneMessage::Withdraw(withdraw) = message { // released_notes.extend(withdraw.inputs.iter().copied()); // } // } // if !released_notes.is_empty() { // let balance = bedrock_wallet_balance(bedrock_addr, receiver_pk).await?; // if released_notes // .iter() // .any(|note_id| balance.notes.get(note_id) == Some(&expected_amount)) // { // return Ok(()); // } // } // tokio::time::sleep(Duration::from_millis(500)).await; // } // }) // .await // .with_context(|| { // format!("Timed out waiting for finalized withdraw message with amount {expected_amount}") // })? // }