diff --git a/Cargo.lock b/Cargo.lock index 0270043a9d..8f25db6115 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -508,7 +508,6 @@ dependencies = [ "astria-config", "astria-core", "astria-eyre", - "astria-grpc-mock", "astria-sequencer-client", "astria-telemetry", "axum", @@ -523,7 +522,6 @@ dependencies = [ "once_cell", "pin-project-lite", "prost", - "reqwest", "serde", "serde_json", "sha2 0.10.8", @@ -531,7 +529,6 @@ dependencies = [ "tendermint", "tendermint-rpc", "tokio", - "tokio-stream", "tokio-util 0.7.10", "tonic 0.10.2", "tracing", diff --git a/crates/astria-bridge-withdrawer/Cargo.toml b/crates/astria-bridge-withdrawer/Cargo.toml index 10c0e53ee3..3bd51f1366 100644 --- a/crates/astria-bridge-withdrawer/Cargo.toml +++ b/crates/astria-bridge-withdrawer/Cargo.toml @@ -53,14 +53,11 @@ telemetry = { package = "astria-telemetry", path = "../astria-telemetry", featur [dev-dependencies] astria-core = { path = "../astria-core", features = ["server", "test-utils"] } -astria-grpc-mock = { path = "../astria-grpc-mock" } config = { package = "astria-config", path = "../astria-config", features = [ "tests", ] } -reqwest = { workspace = true, features = ["json"] } tempfile = { workspace = true } tendermint-rpc = { workspace = true } -tokio-stream = { workspace = true, features = ["net"] } wiremock = { workspace = true } [build-dependencies] diff --git a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/ethereum/watcher.rs b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/ethereum/watcher.rs index 3de1a631fd..87cbccad24 100644 --- a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/ethereum/watcher.rs +++ b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/ethereum/watcher.rs @@ -36,9 +36,7 @@ use ethers::{ use tokio::select; use tokio_util::sync::CancellationToken; use tracing::{ - debug, info, - instrument, warn, }; @@ -101,25 +99,38 @@ pub(crate) struct Watcher { state: Arc, } -struct FullyInitialized { - shutdown_token: CancellationToken, - submitter_handle: submitter::Handle, - state: Arc, - provider: Arc>, - action_fetcher: GetWithdrawalActions>, - starting_rollup_height: u64, -} - impl Watcher { - pub(crate) async fn run(self) -> Result<()> { - let fully_init = self + pub(crate) async fn run(mut self) -> Result<()> { + let (provider, action_fetcher, next_rollup_block_height) = self .startup() .await .wrap_err("watcher failed to start up")?; - fully_init.state.set_watcher_ready(); + let Self { + state, + shutdown_token, + submitter_handle, + .. + } = self; + + state.set_watcher_ready(); - fully_init.run().await + tokio::select! { + res = watch_for_blocks( + provider, + action_fetcher, + next_rollup_block_height, + submitter_handle, + shutdown_token.clone(), + ) => { + info!("block handler exited"); + res.context("block handler exited") + } + () = shutdown_token.cancelled() => { + info!("watcher shutting down"); + Ok(()) + } + } } /// Gets the startup data from the submitter and connects to the Ethereum node. @@ -131,39 +142,23 @@ impl Watcher { /// - If the fee asset ID provided in the config is not a valid fee asset on the sequencer. /// - If the Ethereum node cannot be connected to after several retries. /// - If the asset withdrawal decimals cannot be fetched. - #[instrument(skip_all, err)] - async fn startup(self) -> eyre::Result { - let Self { - shutdown_token, - mut startup_handle, - submitter_handle, - contract_address, - ethereum_rpc_endpoint, - rollup_asset_denom, - bridge_address, - state, - } = self; - + async fn startup( + &mut self, + ) -> eyre::Result<(Arc>, GetWithdrawalActions>, u64)> { let startup::Info { fee_asset, starting_rollup_height, .. } = select! { - () = shutdown_token.cancelled() => { + () = self.shutdown_token.cancelled() => { return Err(eyre!("watcher received shutdown signal while waiting for startup")); } - startup_info = startup_handle.get_info() => { + startup_info = self.startup_handle.get_info() => { startup_info.wrap_err("failed to receive startup info")? } }; - debug!( - fee_asset = %fee_asset, - starting_rollup_height = starting_rollup_height, - "received startup info" - ); - // connect to eth node let retry_config = tryhard::RetryFutureConfig::new(1024) .exponential_backoff(Duration::from_millis(500)) @@ -184,7 +179,7 @@ impl Watcher { ); let provider = tryhard::retry_fn(|| { - let url = ethereum_rpc_endpoint.clone(); + let url = self.ethereum_rpc_endpoint.clone(); async move { let websocket_client = Ws::connect_with_reconnects(url, 0).await?; Ok(Provider::new(websocket_client)) @@ -195,15 +190,15 @@ impl Watcher { .wrap_err("failed connecting to rollup after several retries; giving up")?; let provider = Arc::new(provider); - let ics20_asset_to_withdraw = if rollup_asset_denom.last_channel().is_some() { + let ics20_asset_to_withdraw = if self.rollup_asset_denom.last_channel().is_some() { info!( - %rollup_asset_denom, + rollup_asset_denom = %self.rollup_asset_denom, "configured rollup asset contains an ics20 channel; ics20 withdrawals will be emitted" ); - Some(rollup_asset_denom.clone()) + Some(self.rollup_asset_denom.clone()) } else { info!( - %rollup_asset_denom, + rollup_asset_denom = %self.rollup_asset_denom, "configured rollup asset does not contain an ics20 channel; ics20 withdrawals will not be emitted" ); None @@ -211,63 +206,46 @@ impl Watcher { let action_fetcher = GetWithdrawalActionsBuilder::new() .provider(provider.clone()) .fee_asset(fee_asset) - .contract_address(contract_address) - .bridge_address(bridge_address) - .sequencer_asset_to_withdraw(rollup_asset_denom.clone().into()) + .contract_address(self.contract_address) + .bridge_address(self.bridge_address) + .sequencer_asset_to_withdraw(self.rollup_asset_denom.clone().into()) .set_ics20_asset_to_withdraw(ics20_asset_to_withdraw) .try_build() .await .wrap_err("failed to construct contract event to sequencer action fetcher")?; - Ok(FullyInitialized { - shutdown_token, - submitter_handle, - state, - provider, - action_fetcher, - starting_rollup_height, - }) - } -} + self.state.set_watcher_ready(); -impl FullyInitialized { - async fn run(self) -> eyre::Result<()> { - tokio::select! { - res = watch_for_blocks( - self.provider, - self.action_fetcher, - self.starting_rollup_height, - self.submitter_handle, - self.shutdown_token.clone(), - ) => { - res.context("block handler exited") - } - () = self.shutdown_token.cancelled() => { - Ok(()) - } - } + Ok((provider.clone(), action_fetcher, starting_rollup_height)) } } -#[instrument(skip_all, fields(from_rollup_height, to_rollup_height), err)] -async fn sync_unprocessed_rollup_heights( +async fn sync_from_next_rollup_block_height( provider: Arc>, action_fetcher: &GetWithdrawalActions>, submitter_handle: &submitter::Handle, - from_rollup_height: u64, - to_rollup_height: u64, + next_rollup_block_height_to_check: u64, + current_rollup_block_height: u64, ) -> Result<()> { - for i in from_rollup_height..=to_rollup_height { - let block = provider + if current_rollup_block_height < next_rollup_block_height_to_check { + return Ok(()); + } + + for i in next_rollup_block_height_to_check..=current_rollup_block_height { + let Some(block) = provider .get_block(i) .await - .map_err(eyre::Report::new) - .and_then(|block| block.ok_or_eyre("block is missing")) - .wrap_err_with(|| format!("failed to get block at rollup height `{i}`"))?; - get_and_forward_block_events(action_fetcher, block, submitter_handle) + .wrap_err("failed to get block")? + else { + bail!("block with number {i} missing"); + }; + + get_and_send_events_at_block(action_fetcher, block, submitter_handle) .await .wrap_err("failed to get and send events at block")?; } + + info!("synced from {next_rollup_block_height_to_check} to {current_rollup_block_height}"); Ok(()) } @@ -293,15 +271,9 @@ async fn watch_for_blocks( bail!("current rollup block missing block number") }; - info!( - block.height = current_rollup_block_height.as_u64(), - block.hash = current_rollup_block.hash.map(tracing::field::display), - "got current block" - ); - // sync any blocks missing between `next_rollup_block_height` and the current latest // (inclusive). - sync_unprocessed_rollup_heights( + sync_from_next_rollup_block_height( provider.clone(), &action_fetcher, &submitter_handle, @@ -319,7 +291,7 @@ async fn watch_for_blocks( } block = block_rx.next() => { if let Some(block) = block { - get_and_forward_block_events( + get_and_send_events_at_block( &action_fetcher, block, &submitter_handle, @@ -334,11 +306,7 @@ async fn watch_for_blocks( } } -#[instrument(skip_all, fields( - block.hash = block.hash.map(tracing::field::display), - block.number = block.number.map(tracing::field::display), -), err)] -async fn get_and_forward_block_events( +async fn get_and_send_events_at_block( actions_fetcher: &GetWithdrawalActions>, block: Block, submitter_handle: &submitter::Handle, @@ -351,7 +319,12 @@ async fn get_and_forward_block_events( let actions = actions_fetcher .get_for_block_hash(block_hash) .await - .wrap_err("failed getting actions for block")?; + .wrap_err_with(|| { + format!( + "failed getting actions for block; block hash: `{block_hash}`, block height: \ + `{rollup_height}`" + ) + })?; if actions.is_empty() { info!( diff --git a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/mod.rs b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/mod.rs index 41479de736..77657953b7 100644 --- a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/mod.rs +++ b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/mod.rs @@ -147,10 +147,6 @@ impl BridgeWithdrawer { Ok((service, shutdown_handle)) } - pub fn local_addr(&self) -> SocketAddr { - self.api_server.local_addr() - } - // Panic won't happen because `startup_task` is unwraped lazily after checking if it's `Some`. #[allow(clippy::missing_panics_doc)] pub async fn run(self) { @@ -350,12 +346,12 @@ impl Shutdown { .await .map(flatten_result) { - Ok(Ok(())) => info!("submitter exited gracefully"), - Ok(Err(error)) => error!(%error, "submitter exited with an error"), + Ok(Ok(())) => info!("withdrawer exited gracefully"), + Ok(Err(error)) => error!(%error, "withdrawer exited with an error"), Err(_) => { error!( timeout_secs = limit.as_secs(), - "submitter did not shut down within timeout; killing it" + "watcher did not shut down within timeout; killing it" ); submitter_task.abort(); } diff --git a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/startup.rs b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/startup.rs index 07760f076b..8b6d4938d5 100644 --- a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/startup.rs +++ b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/startup.rs @@ -164,12 +164,13 @@ impl Startup { .await .wrap_err("failed to get next rollup block height")?; - // update the startup info in the global state for submitter and watcher to use + // send the startup info to the submitter let info = Info { chain_id: self.sequencer_chain_id.clone(), fee_asset: self.expected_fee_asset, starting_rollup_height, }; + state.set_startup_info(info); Ok(()) @@ -188,13 +189,13 @@ impl Startup { /// Confirms configuration values against the sequencer node. Values checked: /// /// - `self.sequencer_chain_id` matches the value returned from the sequencer node's genesis - /// - `self.fee_asset` is a valid fee asset on the sequencer node - /// - `self.sequencer_bridge_address` has a sufficient balance of `self.fee_asset` + /// - `self.fee_asset_id` is a valid fee asset on the sequencer node + /// - `self.sequencer_bridge_address` has a sufficient balance of `self.fee_asset_id` /// /// # Errors /// /// - `self.chain_id` does not match the value returned from the sequencer node - /// - `self.fee_asset` is not a valid fee asset on the sequencer node + /// - `self.fee_asset_id` is not a valid fee asset on the sequencer node /// - `self.sequencer_bridge_address` does not have a sufficient balance of `self.fee_asset`. async fn confirm_sequencer_config(&self) -> eyre::Result<()> { // confirm the sequencer chain id @@ -206,25 +207,20 @@ impl Startup { self.sequencer_chain_id == actual_chain_id.to_string(), "sequencer_chain_id provided in config does not match chain_id returned from sequencer" ); - info!(chain_id=%actual_chain_id, "confirmed chain id returned from sequencer matches config"); // confirm that the fee asset ID is valid - let allowed_fee_assets_resp = - get_allowed_fee_assets(self.sequencer_cometbft_client.clone(), self.state.clone()) + let allowed_fee_asset_ids_resp = + get_allowed_fee_asset_ids(self.sequencer_cometbft_client.clone(), self.state.clone()) .await .wrap_err("failed to get allowed fee asset ids from sequencer")?; let expected_fee_asset_ibc = self.expected_fee_asset.to_ibc_prefixed(); ensure!( - allowed_fee_assets_resp + allowed_fee_asset_ids_resp .fee_assets .iter() .any(|asset| asset.to_ibc_prefixed() == expected_fee_asset_ibc), "fee_asset provided in config is not a valid fee asset on the sequencer" ); - info!( - fee_asset = %self.expected_fee_asset, - "confirmed fee asset is valid on sequencer" - ); Ok(()) } @@ -330,11 +326,6 @@ impl Startup { .checked_add(1) .ok_or_eyre("failed to increment rollup height by 1")? } else { - info!( - bridge_account_address = %self.sequencer_bridge_address, - "no last transaction by the bridge account found. will process withdrawals from \ - the first rollup block." - ); 1 }; Ok(starting_rollup_height) @@ -353,12 +344,7 @@ async fn ensure_mempool_empty( let latest = get_latest_nonce(cometbft_client, state, address) .await .wrap_err("failed to get latest nonce")?; - - ensure!( - pending == latest, - "mempool is not empty, nonces did not match. pending nonce: {pending}, latest nonce: \ - {latest}" - ); + ensure!(pending == latest, "mempool is not yet emoty"); Ok(()) } @@ -409,13 +395,11 @@ async fn wait_for_empty_mempool( futures::future::ready(()) }, ); - let sequencer_client = - SequencerServiceClient::connect(format!("http://{}", sequencer_grpc_endpoint.clone())) - .await - .wrap_err_with(|| { - format!("failed to connect to sequencer at `{sequencer_grpc_endpoint}`") - })?; - + let sequencer_client = SequencerServiceClient::connect(sequencer_grpc_endpoint.clone()) + .await + .wrap_err_with(|| { + format!("failed to connect to sequencer at `{sequencer_grpc_endpoint}`") + })?; tryhard::retry_fn(|| { let sequencer_client = sequencer_client.clone(); let cometbft_client = cometbft_client.clone(); @@ -536,7 +520,7 @@ async fn get_sequencer_chain_id( } #[instrument(skip_all)] -async fn get_allowed_fee_assets( +async fn get_allowed_fee_asset_ids( client: sequencer_client::HttpClient, state: Arc, ) -> eyre::Result { diff --git a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/builder.rs b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/builder.rs index e93551138e..3104728058 100644 --- a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/builder.rs +++ b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/builder.rs @@ -1,11 +1,13 @@ use std::sync::Arc; +use astria_core::generated::sequencerblock::v1alpha1::sequencer_service_client::SequencerServiceClient; use astria_eyre::eyre::{ self, Context as _, }; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; +use tonic::transport::Endpoint; use tracing::info; use super::state::State; @@ -74,6 +76,10 @@ impl Builder { sequencer_client::HttpClient::new(&*sequencer_cometbft_endpoint) .wrap_err("failed constructing cometbft http client")?; + let endpoint = Endpoint::new(sequencer_grpc_endpoint.clone()) + .wrap_err_with(|| format!("invalid grpc endpoint: {sequencer_grpc_endpoint}"))?; + let sequencer_grpc_client = SequencerServiceClient::new(endpoint.connect_lazy()); + let (batches_tx, batches_rx) = tokio::sync::mpsc::channel(BATCH_QUEUE_SIZE); let handle = Handle::new(batches_tx); @@ -84,7 +90,7 @@ impl Builder { state, batches_rx, sequencer_cometbft_client, - sequencer_grpc_endpoint, + sequencer_grpc_client, signer, metrics, }, diff --git a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/mod.rs b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/mod.rs index e1f2e6801a..bb4d62fc27 100644 --- a/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/mod.rs +++ b/crates/astria-bridge-withdrawer/src/bridge_withdrawer/submitter/mod.rs @@ -7,6 +7,7 @@ use astria_core::{ generated::sequencerblock::v1alpha1::{ sequencer_service_client::{ self, + SequencerServiceClient, }, GetPendingNonceRequest, }, @@ -64,14 +65,14 @@ pub(super) struct Submitter { state: Arc, batches_rx: mpsc::Receiver, sequencer_cometbft_client: sequencer_client::HttpClient, - sequencer_grpc_endpoint: String, + sequencer_grpc_client: SequencerServiceClient, signer: SequencerKey, metrics: &'static Metrics, } impl Submitter { pub(super) async fn run(mut self) -> eyre::Result<()> { - let (sequencer_chain_id, sequencer_grpc_client) = select! { + let sequencer_chain_id = select! { () = self.shutdown_token.cancelled() => { info!("submitter received shutdown signal while waiting for startup"); return Ok(()); @@ -79,13 +80,7 @@ impl Submitter { startup_info = self.startup_handle.get_info() => { let startup::Info { chain_id, .. } = startup_info.wrap_err("submitter failed to get startup info")?; - - let sequencer_grpc_client = sequencer_service_client::SequencerServiceClient::connect( - format!("http://{}", self.sequencer_grpc_endpoint), - ).await.wrap_err("failed to connect to sequencer gRPC endpoint")?; - - self.state.set_submitter_ready(); - (chain_id, sequencer_grpc_client) + chain_id } }; self.state.set_submitter_ready(); @@ -101,13 +96,13 @@ impl Submitter { batch = self.batches_rx.recv() => { let Some(Batch { actions, rollup_height }) = batch else { + info!("received None from batch channel, shutting down"); break Err(eyre!("batch channel closed")); }; - // if batch submission fails, halt the submitter if let Err(e) = process_batch( self.sequencer_cometbft_client.clone(), - sequencer_grpc_client.clone(), + self.sequencer_grpc_client.clone(), &self.signer, self.state.clone(), &sequencer_chain_id, @@ -208,7 +203,7 @@ async fn process_batch( sequencer.block = rsp.height.value(), sequencer.tx_hash = %rsp.hash, rollup.height = rollup_height, - "batch successfully executed." + "withdraw batch successfully executed." ); state.set_last_rollup_height_submitted(rollup_height); state.set_last_sequencer_height(rsp.height.value()); diff --git a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/ethereum.rs b/crates/astria-bridge-withdrawer/tests/blackbox/helpers/ethereum.rs deleted file mode 100644 index 71a881d325..0000000000 --- a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/ethereum.rs +++ /dev/null @@ -1,416 +0,0 @@ -use std::{ - sync::Arc, - time::Duration, -}; - -use astria_bridge_contracts::{ - astria_bridgeable_erc20::{ - AstriaBridgeableERC20, - ASTRIABRIDGEABLEERC20_ABI, - ASTRIABRIDGEABLEERC20_BYTECODE, - }, - astria_withdrawer::{ - AstriaWithdrawer, - ASTRIAWITHDRAWER_ABI, - ASTRIAWITHDRAWER_BYTECODE, - }, -}; -use astria_core::primitive::v1::Address as AstriaAddress; -use ethers::{ - abi::Tokenizable, - core::utils::Anvil, - prelude::*, - signers::Signer, - utils::AnvilInstance, -}; -use tracing::debug; - -use super::test_bridge_withdrawer::{ - astria_address, - default_bridge_address, - default_native_asset, -}; - -// allow: want the name to reflect this is a test config. -#[allow(clippy::module_name_repetitions)] -pub struct TestEthereum { - contract_address: ethers::types::Address, - provider: Arc>, - wallet: LocalWallet, - anvil: AnvilInstance, -} - -impl TestEthereum { - pub fn wallet_addr(&self) -> ethers::types::Address { - self.wallet.address() - } - - pub fn contract_address(&self) -> String { - hex::encode(self.contract_address) - } - - pub fn ws_endpoint(&self) -> String { - self.anvil.ws_endpoint() - } - - pub async fn send_sequencer_withdraw_transaction( - &self, - value: U256, - recipient: AstriaAddress, - ) -> TransactionReceipt { - let signer = Arc::new(SignerMiddleware::new( - self.provider.clone(), - self.wallet.clone(), - )); - let contract = AstriaWithdrawer::new(self.contract_address, signer.clone()); - let tx = contract - .withdraw_to_sequencer(recipient.to_string()) - .value(value); - let receipt = tx - .send() - .await - .expect("failed to submit transaction") - .await - .expect("failed to await pending transaction") - .expect("no receipt found"); - - debug!(transaction.hash = %receipt.transaction_hash, "sequencer withdraw tranasaction successfully submitted"); - - assert!( - receipt.status == Some(ethers::types::U64::from(1)), - "`withdraw` transaction failed: {receipt:?}", - ); - - receipt - } - - pub async fn send_ics20_withdraw_transaction( - &self, - value: U256, - recipient: String, - ) -> TransactionReceipt { - let signer = Arc::new(SignerMiddleware::new( - self.provider.clone(), - self.wallet.clone(), - )); - let contract = AstriaWithdrawer::new(self.contract_address, signer.clone()); - let tx = contract - .withdraw_to_ibc_chain(recipient, "nootwashere".to_string()) - .value(value); - let receipt = tx - .send() - .await - .expect("failed to submit transaction") - .await - .expect("failed to await pending transaction") - .expect("no receipt found"); - - assert!( - receipt.status == Some(ethers::types::U64::from(1)), - "`withdraw` transaction failed: {receipt:?}", - ); - - receipt - } - - pub async fn mint_tokens( - &self, - amount: U256, - recipient: ethers::types::Address, - ) -> TransactionReceipt { - let signer = Arc::new(SignerMiddleware::new( - self.provider.clone(), - self.wallet.clone(), - )); - let contract = AstriaBridgeableERC20::new(self.contract_address, signer.clone()); - let mint_tx = contract.mint(recipient, amount); - let receipt = mint_tx - .send() - .await - .expect("failed to submit mint transaction") - .await - .expect("failed to await pending mint transaction") - .expect("no mint receipt found"); - - assert!( - receipt.status == Some(ethers::types::U64::from(1)), - "`mint` transaction failed: {receipt:?}", - ); - - receipt - } - - pub async fn send_sequencer_withdraw_transaction_erc20( - &self, - value: U256, - recipient: AstriaAddress, - ) -> TransactionReceipt { - let signer = Arc::new(SignerMiddleware::new( - self.provider.clone(), - self.wallet.clone(), - )); - let contract = AstriaBridgeableERC20::new(self.contract_address, signer.clone()); - let tx = contract.withdraw_to_sequencer(value, recipient.to_string()); - - let receipt = tx - .send() - .await - .expect("failed to submit transaction") - .await - .expect("failed to await pending transaction") - .expect("no receipt found"); - - assert!( - receipt.status == Some(ethers::types::U64::from(1)), - "`withdraw` transaction failed: {receipt:?}", - ); - - receipt - } - - pub async fn send_ics20_withdraw_transaction_astria_bridgeable_erc20( - &self, - value: U256, - recipient: String, - ) -> TransactionReceipt { - let signer = Arc::new(SignerMiddleware::new( - self.provider.clone(), - self.wallet.clone(), - )); - let contract = AstriaBridgeableERC20::new(self.contract_address, signer.clone()); - let tx = contract.withdraw_to_ibc_chain(value, recipient, "nootwashere".to_string()); - let receipt = tx - .send() - .await - .expect("failed to submit transaction") - .await - .expect("failed to await pending transaction") - .expect("no receipt found"); - - assert!( - receipt.status == Some(ethers::types::U64::from(1)), - "`withdraw` transaction failed: {receipt:?}", - ); - - receipt - } -} - -pub enum TestEthereumConfig { - AstriaWithdrawer(AstriaWithdrawerDeployerConfig), - AstriaBridgeableERC20(AstriaBridgeableERC20DeployerConfig), -} - -impl TestEthereumConfig { - pub(crate) async fn spawn(self) -> TestEthereum { - match self { - Self::AstriaWithdrawer(config) => { - let (contract_address, provider, wallet, anvil) = config.deploy().await; - TestEthereum { - contract_address, - provider, - wallet, - anvil, - } - } - Self::AstriaBridgeableERC20(config) => { - let (contract_address, provider, wallet, anvil) = config.deploy().await; - TestEthereum { - contract_address, - provider, - wallet, - anvil, - } - } - } - } -} - -#[allow(clippy::struct_field_names)] -pub struct AstriaWithdrawerDeployerConfig { - pub base_chain_asset_precision: u32, - pub base_chain_bridge_address: astria_core::primitive::v1::Address, - pub base_chain_asset_denomination: String, -} - -impl Default for AstriaWithdrawerDeployerConfig { - fn default() -> Self { - Self { - base_chain_asset_precision: 18, - base_chain_bridge_address: default_bridge_address(), - base_chain_asset_denomination: default_native_asset().to_string(), - } - } -} - -impl AstriaWithdrawerDeployerConfig { - pub async fn deploy(self) -> (Address, Arc>, LocalWallet, AnvilInstance) { - let Self { - base_chain_asset_precision, - base_chain_bridge_address, - base_chain_asset_denomination, - } = self; - - deploy_astria_withdrawer( - base_chain_asset_precision.into(), - base_chain_bridge_address, - base_chain_asset_denomination, - ) - .await - } -} - -/// Starts a local anvil instance and deploys the `AstriaWithdrawer` contract to it. -/// -/// Returns the contract address, provider, wallet, and anvil instance. -/// -/// # Panics -/// -/// - if the provider fails to connect to the anvil instance -/// - if the contract fails to deploy -pub(crate) async fn deploy_astria_withdrawer( - base_chain_asset_precision: U256, - base_chain_bridge_address: astria_core::primitive::v1::Address, - base_chain_asset_denomination: String, -) -> (Address, Arc>, LocalWallet, AnvilInstance) { - // setup anvil and signing wallet - let anvil = Anvil::new().block_time(1u64).spawn(); - let wallet: LocalWallet = anvil.keys()[0].clone().into(); - let provider = Arc::new( - Provider::::connect(anvil.ws_endpoint()) - .await - .unwrap() - .interval(Duration::from_millis(10u64)), - ); - let signer = SignerMiddleware::new( - provider.clone(), - wallet.clone().with_chain_id(anvil.chain_id()), - ); - - let abi = ASTRIAWITHDRAWER_ABI.clone(); - let bytecode = ASTRIAWITHDRAWER_BYTECODE.to_vec(); - - let args = vec![ - base_chain_asset_precision.into_token(), - base_chain_bridge_address.to_string().into_token(), - base_chain_asset_denomination.into_token(), - ]; - - let factory = ContractFactory::new(abi.clone(), bytecode.into(), signer.into()); - let contract = factory.deploy_tokens(args).unwrap().send().await.unwrap(); - let contract_address = contract.address(); - - ( - contract_address, - provider, - wallet.with_chain_id(anvil.chain_id()), - anvil, - ) -} - -pub struct AstriaBridgeableERC20DeployerConfig { - pub bridge_address: Address, - pub base_chain_asset_precision: u32, - pub base_chain_bridge_address: astria_core::primitive::v1::Address, - pub base_chain_asset_denomination: String, - pub name: String, - pub symbol: String, -} - -impl Default for AstriaBridgeableERC20DeployerConfig { - fn default() -> Self { - Self { - bridge_address: Address::zero(), - base_chain_asset_precision: 18, - base_chain_bridge_address: astria_address([0u8; 20]), - base_chain_asset_denomination: "testdenom".to_string(), - name: "test-token".to_string(), - symbol: "TT".to_string(), - } - } -} - -impl AstriaBridgeableERC20DeployerConfig { - pub(crate) async fn deploy(self) -> (Address, Arc>, LocalWallet, AnvilInstance) { - let Self { - bridge_address, - base_chain_asset_precision, - base_chain_bridge_address, - base_chain_asset_denomination, - name, - symbol, - } = self; - - deploy_astria_bridgeable_erc20( - bridge_address, - base_chain_asset_precision.into(), - base_chain_bridge_address, - base_chain_asset_denomination, - name, - symbol, - ) - .await - } -} - -/// Starts a local anvil instance and deploys the `AstriaBridgeableERC20` contract to it. -/// -/// Returns the contract address, provider, wallet, and anvil instance. -/// -/// # Panics -/// -/// - if the provider fails to connect to the anvil instance -/// - if the contract fails to deploy -pub(crate) async fn deploy_astria_bridgeable_erc20( - mut bridge_address: Address, - base_chain_asset_precision: ethers::abi::Uint, - base_chain_bridge_address: astria_core::primitive::v1::Address, - base_chain_asset_denomination: String, - name: String, - symbol: String, -) -> (Address, Arc>, LocalWallet, AnvilInstance) { - // setup anvil and signing wallet - let anvil = Anvil::new().spawn(); - let wallet: LocalWallet = anvil.keys()[0].clone().into(); - let provider = Arc::new( - Provider::::connect(anvil.ws_endpoint()) - .await - .unwrap() - .interval(Duration::from_millis(10u64)), - ); - let signer = SignerMiddleware::new( - provider.clone(), - wallet.clone().with_chain_id(anvil.chain_id()), - ); - - let abi = ASTRIABRIDGEABLEERC20_ABI.clone(); - let bytecode = ASTRIABRIDGEABLEERC20_BYTECODE.to_vec(); - - let factory = ContractFactory::new(abi.clone(), bytecode.into(), signer.into()); - - if bridge_address == Address::zero() { - bridge_address = wallet.address(); - } - let args = vec![ - bridge_address.into_token(), - base_chain_asset_precision.into_token(), - base_chain_bridge_address.to_string().into_token(), - base_chain_asset_denomination.into_token(), - name.into_token(), - symbol.into_token(), - ]; - let contract = factory.deploy_tokens(args).unwrap().send().await.unwrap(); - let contract_address = contract.address(); - - ( - contract_address, - provider, - wallet.with_chain_id(anvil.chain_id()), - anvil, - ) -} - -#[must_use] -pub fn default_rollup_address() -> ethers::types::Address { - Address::random() -} diff --git a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mock_cometbft.rs b/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mock_cometbft.rs deleted file mode 100644 index 29b17f1c34..0000000000 --- a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mock_cometbft.rs +++ /dev/null @@ -1,357 +0,0 @@ -use std::time::Duration; - -use astria_core::{ - primitive::v1::asset, - protocol::bridge::v1alpha1::BridgeAccountLastTxHashResponse, -}; -use prost::Message as _; -use sequencer_client::{ - NonceResponse, - SignedTransaction, -}; -use tendermint::{ - abci::{ - response::CheckTx, - types::ExecTxResult, - }, - block::Height, - chain, -}; -use tendermint_rpc::{ - endpoint::{ - broadcast::{ - tx_commit, - tx_sync, - }, - tx, - }, - response, -}; -use tracing::debug; -use wiremock::{ - matchers::{ - body_partial_json, - body_string_contains, - }, - Mock, - MockGuard, - MockServer, - ResponseTemplate, -}; - -use super::test_bridge_withdrawer::{ - default_native_asset, - DEFAULT_IBC_DENOM, - SEQUENCER_CHAIN_ID, -}; - -#[must_use] -pub fn make_tx_commit_success_response() -> tx_commit::Response { - tx_commit::Response { - check_tx: CheckTx::default(), - tx_result: ExecTxResult::default(), - hash: vec![0u8; 32].try_into().unwrap(), - height: Height::default(), - } -} - -#[must_use] -pub fn make_tx_commit_check_tx_failure_response() -> tx_commit::Response { - tx_commit::Response { - check_tx: CheckTx { - code: 1.into(), - ..CheckTx::default() - }, - tx_result: ExecTxResult::default(), - hash: vec![0u8; 32].try_into().unwrap(), - height: Height::default(), - } -} - -#[must_use] -pub fn make_tx_commit_deliver_tx_failure_response() -> tx_commit::Response { - tx_commit::Response { - check_tx: CheckTx::default(), - tx_result: ExecTxResult { - code: 1.into(), - ..ExecTxResult::default() - }, - hash: vec![0u8; 32].try_into().unwrap(), - height: Height::default(), - } -} - -pub async fn mount_default_chain_id(cometbft_mock: &MockServer) { - mount_genesis_chain_id_response(SEQUENCER_CHAIN_ID, cometbft_mock).await; -} - -pub async fn mount_default_chain_id_guard_as_scoped(cometbft_mock: &MockServer) -> MockGuard { - mount_genesis_chain_id_response_as_scoped(SEQUENCER_CHAIN_ID, cometbft_mock).await -} - -pub async fn mount_native_fee_asset(cometbft_mock: &MockServer) { - let fee_assets = vec![default_native_asset()]; - mount_allowed_fee_assets_response(fee_assets, cometbft_mock).await; -} - -pub async fn mount_native_fee_asset_as_scoped(cometbft_mock: &MockServer) -> MockGuard { - let fee_assets = vec![DEFAULT_IBC_DENOM.parse().unwrap()]; - mount_allowed_fee_assets_response_as_scoped(fee_assets, cometbft_mock).await -} - -pub async fn mount_ibc_fee_asset(cometbft_mock: &MockServer) { - let fee_assets = vec![DEFAULT_IBC_DENOM.parse().unwrap()]; - mount_allowed_fee_assets_response(fee_assets, cometbft_mock).await; -} - -pub async fn mount_ibc_fee_asset_as_scoped(cometbft_mock: &MockServer) -> MockGuard { - let fee_assets = vec![default_native_asset()]; - mount_allowed_fee_assets_response_as_scoped(fee_assets, cometbft_mock).await -} - -pub async fn mount_genesis_chain_id_response(chain_id: &str, server: &MockServer) { - prepare_genesis_chain_id_response(chain_id) - .mount(server) - .await; -} - -pub async fn mount_genesis_chain_id_response_as_scoped( - chain_id: &str, - server: &MockServer, -) -> MockGuard { - prepare_genesis_chain_id_response(chain_id) - .mount_as_scoped(server) - .await -} - -fn prepare_genesis_chain_id_response(chain_id: &str) -> Mock { - use tendermint::{ - consensus::{ - params::{ - AbciParams, - ValidatorParams, - }, - Params, - }, - genesis::Genesis, - time::Time, - }; - let response = tendermint_rpc::endpoint::genesis::Response:: { - genesis: Genesis { - genesis_time: Time::from_unix_timestamp(1, 1).unwrap(), - chain_id: chain::Id::try_from(chain_id).unwrap(), - initial_height: 1, - consensus_params: Params { - block: tendermint::block::Size { - max_bytes: 1024, - max_gas: 1024, - time_iota_ms: 1000, - }, - evidence: tendermint::evidence::Params { - max_age_num_blocks: 1000, - max_age_duration: tendermint::evidence::Duration(Duration::from_secs(3600)), - max_bytes: 1_048_576, - }, - validator: ValidatorParams { - pub_key_types: vec![tendermint::public_key::Algorithm::Ed25519], - }, - version: None, - abci: AbciParams::default(), - }, - validators: vec![], - app_hash: tendermint::hash::AppHash::default(), - app_state: serde_json::Value::Null, - }, - }; - let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); - - Mock::given(body_partial_json(serde_json::json!({"method": "genesis"}))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(wrapper) - .append_header("Content-Type", "application/json"), - ) - .up_to_n_times(1) - .expect(1) -} - -pub async fn mount_allowed_fee_assets_response( - fee_assets: Vec, - cometbft_mock: &MockServer, -) { - prepare_allowed_fee_assets_response(fee_assets) - .mount(cometbft_mock) - .await; -} - -pub async fn mount_allowed_fee_assets_response_as_scoped( - fee_assets: Vec, - cometbft_mock: &MockServer, -) -> MockGuard { - prepare_allowed_fee_assets_response(fee_assets) - .mount_as_scoped(cometbft_mock) - .await -} - -fn prepare_allowed_fee_assets_response(fee_assets: Vec) -> Mock { - let response = tendermint_rpc::endpoint::abci_query::Response { - response: tendermint_rpc::endpoint::abci_query::AbciQuery { - value: astria_core::protocol::asset::v1alpha1::AllowedFeeAssetsResponse { - fee_assets, - height: 1, - } - .into_raw() - .encode_to_vec(), - ..Default::default() - }, - }; - let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); - Mock::given(body_partial_json( - serde_json::json!({"method": "abci_query"}), - )) - .and(body_string_contains("asset/allowed_fee_assets")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(wrapper) - .append_header("Content-Type", "application/json"), - ) - .expect(1) -} - -pub async fn mount_last_bridge_tx_hash_response( - server: &MockServer, - response: BridgeAccountLastTxHashResponse, -) { - prepare_last_bridge_tx_hash_response(response) - .mount(server) - .await; -} - -pub async fn mount_last_bridge_tx_hash_response_as_scoped( - server: &MockServer, - response: BridgeAccountLastTxHashResponse, -) -> MockGuard { - prepare_last_bridge_tx_hash_response(response) - .mount_as_scoped(server) - .await -} - -fn prepare_last_bridge_tx_hash_response(response: BridgeAccountLastTxHashResponse) -> Mock { - let response = tendermint_rpc::endpoint::abci_query::Response { - response: tendermint_rpc::endpoint::abci_query::AbciQuery { - value: response.into_raw().encode_to_vec(), - ..Default::default() - }, - }; - let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); - Mock::given(body_partial_json( - serde_json::json!({"method": "abci_query"}), - )) - .and(body_string_contains("bridge/account_last_tx_hash")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(wrapper) - .append_header("Content-Type", "application/json"), - ) - .expect(1) -} - -pub async fn mount_get_nonce_response(server: &MockServer, response: NonceResponse) { - prepare_get_nonce_response(response).mount(server).await; -} - -pub async fn mount_get_nonce_response_as_scoped( - server: &MockServer, - response: NonceResponse, -) -> MockGuard { - prepare_get_nonce_response(response) - .mount_as_scoped(server) - .await -} - -fn prepare_get_nonce_response(response: NonceResponse) -> Mock { - let response = tendermint_rpc::endpoint::abci_query::Response { - response: tendermint_rpc::endpoint::abci_query::AbciQuery { - value: response.into_raw().encode_to_vec(), - ..Default::default() - }, - }; - let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); - Mock::given(body_partial_json( - serde_json::json!({"method": "abci_query"}), - )) - .and(body_string_contains("accounts/nonce")) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(wrapper) - .append_header("Content-Type", "application/json"), - ) - .expect(1) -} - -pub async fn mount_tx_response(server: &MockServer, response: tx::Response) { - prepare_tx_response(response).mount(server).await; -} - -pub async fn mount_tx_response_as_scoped(server: &MockServer, response: tx::Response) -> MockGuard { - prepare_tx_response(response).mount_as_scoped(server).await -} - -fn prepare_tx_response(response: tx::Response) -> Mock { - let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); - Mock::given(body_partial_json(serde_json::json!({"method": "tx"}))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(&wrapper) - .append_header("Content-Type", "application/json"), - ) - .expect(1) -} - -pub async fn mount_broadcast_tx_commit_response( - server: &MockServer, - response: tx_commit::Response, -) { - prepare_broadcast_tx_commit_response(response) - .mount(server) - .await; -} - -pub async fn mount_broadcast_tx_commit_response_as_scoped( - server: &MockServer, - response: tx_commit::Response, -) -> MockGuard { - prepare_broadcast_tx_commit_response(response) - .mount_as_scoped(server) - .await -} - -fn prepare_broadcast_tx_commit_response(response: tx_commit::Response) -> Mock { - let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); - Mock::given(body_partial_json(serde_json::json!({ - "method": "broadcast_tx_commit" - }))) - .respond_with( - ResponseTemplate::new(200) - .set_body_json(&wrapper) - .append_header("Content-Type", "application/json"), - ) - .expect(1) -} - -/// Convert a `Request` object to a `SignedTransaction` -pub fn signed_tx_from_request(request: &wiremock::Request) -> SignedTransaction { - use astria_core::generated::protocol::transaction::v1alpha1::SignedTransaction as RawSignedTransaction; - use prost::Message as _; - - let wrapped_tx_sync_req: tendermint_rpc::request::Wrapper = - serde_json::from_slice(&request.body) - .expect("deserialize to JSONRPC wrapped tx_sync::Request"); - let raw_signed_tx = RawSignedTransaction::decode(&*wrapped_tx_sync_req.params().tx) - .expect("can't deserialize signed sequencer tx from broadcast jsonrpc request"); - let signed_tx = SignedTransaction::try_from_raw(raw_signed_tx) - .expect("can't convert raw signed tx to checked signed tx"); - debug!(?signed_tx, "sequencer mock received signed transaction"); - - signed_tx -} diff --git a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mock_sequencer.rs b/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mock_sequencer.rs deleted file mode 100644 index ed37d660da..0000000000 --- a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mock_sequencer.rs +++ /dev/null @@ -1,121 +0,0 @@ -use std::{ - net::SocketAddr, - sync::Arc, -}; - -use astria_core::{ - self, - generated::sequencerblock::v1alpha1::{ - sequencer_service_server::{ - SequencerService, - SequencerServiceServer, - }, - FilteredSequencerBlock as RawFilteredSequencerBlock, - GetFilteredSequencerBlockRequest, - GetPendingNonceRequest, - GetPendingNonceResponse, - GetSequencerBlockRequest, - SequencerBlock as RawSequencerBlock, - }, -}; -use astria_eyre::eyre::{ - self, - WrapErr as _, -}; -use astria_grpc_mock::{ - matcher::message_type, - response::constant_response, - Mock, - MockServer, -}; -use tokio::task::JoinHandle; -use tonic::{ - transport::Server, - Request, - Response, - Status, -}; - -const GET_PENDING_NONCE_GRPC_NAME: &str = "get_pending_nonce"; - -#[allow(clippy::module_name_repetitions)] -pub struct MockSequencerServer { - _server: JoinHandle>, - pub(crate) mock_server: MockServer, - pub(crate) local_addr: SocketAddr, -} - -impl MockSequencerServer { - pub(crate) async fn spawn() -> Self { - use tokio_stream::wrappers::TcpListenerStream; - - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let local_addr = listener.local_addr().unwrap(); - - let mock_server = MockServer::new(); - - let server = { - let sequencer_service = SequencerServiceImpl(mock_server.clone()); - tokio::spawn(async move { - Server::builder() - .add_service(SequencerServiceServer::new(sequencer_service)) - .serve_with_incoming(TcpListenerStream::new(listener)) - .await - .wrap_err("gRPC sequencer server failed") - }) - }; - Self { - _server: server, - mock_server, - local_addr, - } - } - - pub(crate) async fn mount_pending_nonce_response( - &self, - nonce_to_mount: u32, - debug_name: impl Into, - ) { - let resp = GetPendingNonceResponse { - inner: nonce_to_mount, - }; - Mock::for_rpc_given( - GET_PENDING_NONCE_GRPC_NAME, - message_type::(), - ) - .respond_with(constant_response(resp)) - .up_to_n_times(1) - .expect(1) - .with_name(debug_name) - .mount(&self.mock_server) - .await; - } -} - -struct SequencerServiceImpl(MockServer); - -#[tonic::async_trait] -impl SequencerService for SequencerServiceImpl { - async fn get_sequencer_block( - self: Arc, - _request: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn get_filtered_sequencer_block( - self: Arc, - _request: Request, - ) -> Result, Status> { - unimplemented!() - } - - async fn get_pending_nonce( - self: Arc, - request: Request, - ) -> Result, Status> { - self.0 - .handle_request(GET_PENDING_NONCE_GRPC_NAME, request) - .await - } -} diff --git a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mod.rs b/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mod.rs deleted file mode 100644 index 20bd8da804..0000000000 --- a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/mod.rs +++ /dev/null @@ -1,20 +0,0 @@ -// These are tests; failing with panics is ok. -#![allow(clippy::missing_panics_doc)] - -mod ethereum; -mod mock_cometbft; -mod mock_sequencer; -mod test_bridge_withdrawer; - -pub use self::{ - ethereum::default_rollup_address, - mock_cometbft::*, - mock_sequencer::MockSequencerServer, - test_bridge_withdrawer::{ - assert_actions_eq, - default_sequencer_address, - make_bridge_unlock_action, - make_ics20_withdrawal_action, - TestBridgeWithdrawerConfig, - }, -}; diff --git a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/test_bridge_withdrawer.rs b/crates/astria-bridge-withdrawer/tests/blackbox/helpers/test_bridge_withdrawer.rs deleted file mode 100644 index 47230be2f9..0000000000 --- a/crates/astria-bridge-withdrawer/tests/blackbox/helpers/test_bridge_withdrawer.rs +++ /dev/null @@ -1,509 +0,0 @@ -use std::{ - io::Write as _, - mem, - net::SocketAddr, - time::Duration, -}; - -use astria_bridge_withdrawer::{ - bridge_withdrawer::ShutdownHandle, - BridgeWithdrawer, - Config, -}; -use astria_core::{ - primitive::v1::asset::{ - self, - Denom, - }, - protocol::{ - bridge::v1alpha1::BridgeAccountLastTxHashResponse, - memos::v1alpha1::{ - BridgeUnlock, - Ics20WithdrawalFromRollup, - }, - transaction::v1alpha1::{ - action::{ - BridgeUnlockAction, - Ics20Withdrawal, - }, - Action, - }, - }, -}; -use ethers::types::TransactionReceipt; -use futures::Future; -use ibc_types::core::{ - channel::ChannelId, - client::Height as IbcHeight, -}; -use once_cell::sync::Lazy; -use sequencer_client::{ - Address, - NonceResponse, -}; -use tempfile::NamedTempFile; -use tokio::task::JoinHandle; -use tracing::{ - debug, - error, - info, -}; - -use super::{ - ethereum::AstriaBridgeableERC20DeployerConfig, - make_tx_commit_success_response, - mock_cometbft::{ - mount_default_chain_id, - mount_get_nonce_response, - mount_native_fee_asset, - }, - mount_broadcast_tx_commit_response_as_scoped, - mount_ibc_fee_asset, - mount_last_bridge_tx_hash_response, - MockSequencerServer, -}; -use crate::helpers::ethereum::{ - AstriaWithdrawerDeployerConfig, - TestEthereum, - TestEthereumConfig, -}; - -pub(crate) const DEFAULT_IBC_DENOM: &str = "transfer/channel-0/utia"; -pub(crate) const SEQUENCER_CHAIN_ID: &str = "test-sequencer"; -const ASTRIA_ADDRESS_PREFIX: &str = "astria"; - -static TELEMETRY: Lazy<()> = Lazy::new(|| { - if std::env::var_os("TEST_LOG").is_some() { - let filter_directives = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()); - telemetry::configure() - .no_otel() - .stdout_writer(std::io::stdout) - .set_pretty_print(true) - .filter_directives(&filter_directives) - .try_init() - .unwrap(); - } else { - telemetry::configure() - .no_otel() - .stdout_writer(std::io::sink) - .try_init() - .unwrap(); - } -}); - -pub struct TestBridgeWithdrawer { - /// The address of the public API server (health, ready). - pub api_address: SocketAddr, - - /// The mock cometbft server. - pub cometbft_mock: wiremock::MockServer, - - /// The mock sequencer server. - pub sequencer_mock: MockSequencerServer, - - /// The rollup-side ethereum smart contract - pub ethereum: TestEthereum, - - /// A handle to issue a shutdown to the bridge withdrawer. - bridge_withdrawer_shutdown_handle: Option, - - /// The bridge withdrawer task. - bridge_withdrawer: JoinHandle<()>, - - /// The config used to initialize the bridge withdrawer. - pub config: Config, -} - -impl Drop for TestBridgeWithdrawer { - fn drop(&mut self) { - debug!("dropping TestBridgeWithdrawer"); - - // Drop the shutdown handle to cause the bridge withdrawer to shutdown. - let _ = self.bridge_withdrawer_shutdown_handle.take(); - - let bridge_withdrawer = mem::replace(&mut self.bridge_withdrawer, tokio::spawn(async {})); - let _ = futures::executor::block_on(async move { - tokio::time::timeout(Duration::from_secs(2), bridge_withdrawer) - .await - .unwrap_or_else(|_| { - error!("timeout out waiting for bridge withdrawer to shut down"); - Ok(()) - }) - }); - } -} - -impl TestBridgeWithdrawer { - #[must_use] - pub fn asset_denom(&self) -> Denom { - Denom::from(self.config.rollup_asset_denomination.clone()) - } - - #[must_use] - pub fn rollup_wallet_addr(&self) -> ethers::types::Address { - self.ethereum.wallet_addr() - } - - pub async fn mount_startup_responses(&mut self) { - self.mount_sequencer_config_responses().await; - self.mount_wait_for_mempool_response().await; - self.mount_last_bridge_tx_responses().await; - } - - async fn mount_sequencer_config_responses(&mut self) { - mount_default_chain_id(&self.cometbft_mock).await; - if self.asset_denom() == default_native_asset() { - mount_native_fee_asset(&self.cometbft_mock).await; - } else { - mount_ibc_fee_asset(&self.cometbft_mock).await; - } - } - - async fn mount_wait_for_mempool_response(&mut self) { - // TODO: add config to allow testing for non-empty mempool - let empty_mempool_response = NonceResponse { - height: 0, - nonce: 1, - }; - mount_get_nonce_response(&self.cometbft_mock, empty_mempool_response).await; - - self.sequencer_mock - .mount_pending_nonce_response(1, "startup::wait_for_mempool()") - .await; - } - - async fn mount_last_bridge_tx_responses(&mut self) { - // TODO: add config to allow testing sync - mount_last_bridge_tx_hash_response( - &self.cometbft_mock, - BridgeAccountLastTxHashResponse { - height: 0, - tx_hash: None, - }, - ) - .await; - } - - /// Executes `future` within the specified duration, returning its result. - /// - /// If execution takes more than 80% of the allowed time, an error is logged before returning. - /// - /// # Panics - /// - /// Panics if execution takes longer than the specified duration. - pub async fn timeout_ms( - &self, - num_milliseconds: u64, - context: &str, - future: F, - ) -> F::Output { - let start = std::time::Instant::now(); - let within = Duration::from_millis(num_milliseconds); - if let Ok(value) = tokio::time::timeout(within, future).await { - let elapsed = start.elapsed(); - if elapsed.checked_mul(5).unwrap() > within.checked_mul(4).unwrap() { - error!(%context, - "elapsed time ({} seconds) was over 80% of the specified timeout ({} \ - seconds) - consider increasing the timeout", - elapsed.as_secs_f32(), - within.as_secs_f32() - ); - } - debug!(context, "future executed without timeout"); - value - } else { - // TODO: add handing of failed future using the api server like in sequencer-relayer - panic!("{context} timed out after {num_milliseconds} milliseconds"); - } - } - - pub async fn mount_pending_nonce_response(&self, nonce: u32, debug_name: &str) { - self.sequencer_mock - .mount_pending_nonce_response(nonce, debug_name) - .await; - } - - pub async fn mount_broadcast_tx_commit_success_response_as_scoped( - &self, - ) -> wiremock::MockGuard { - mount_broadcast_tx_commit_response_as_scoped( - &self.cometbft_mock, - make_tx_commit_success_response(), - ) - .await - } -} - -#[allow(clippy::module_name_repetitions)] -pub struct TestBridgeWithdrawerConfig { - /// Configures the rollup's withdrawal smart contract to either native or ERC20. - pub ethereum_config: TestEthereumConfig, - /// The denomination of the asset - pub asset_denom: Denom, -} - -impl TestBridgeWithdrawerConfig { - pub async fn spawn(self) -> TestBridgeWithdrawer { - let Self { - ethereum_config, - asset_denom, - } = self; - Lazy::force(&TELEMETRY); - - // sequencer signer key - let keyfile = NamedTempFile::new().unwrap(); - (&keyfile) - .write_all( - "2bd806c97f0e00af1a1fc3328fa763a9269723c8db8fac4f93af71db186d6e90".as_bytes(), - ) - .unwrap(); - let sequencer_key_path = keyfile.path().to_str().unwrap().to_string(); - - let ethereum = ethereum_config.spawn().await; - - let cometbft_mock = wiremock::MockServer::start().await; - - let sequencer_mock = MockSequencerServer::spawn().await; - let sequencer_grpc_endpoint = sequencer_mock.local_addr.to_string(); - - let config = Config { - sequencer_cometbft_endpoint: cometbft_mock.uri(), - sequencer_grpc_endpoint, - sequencer_chain_id: SEQUENCER_CHAIN_ID.into(), - sequencer_key_path, - fee_asset_denomination: asset_denom.clone(), - rollup_asset_denomination: asset_denom.as_trace_prefixed().unwrap().clone(), - sequencer_bridge_address: default_bridge_address().to_string(), - ethereum_contract_address: ethereum.contract_address(), - ethereum_rpc_endpoint: ethereum.ws_endpoint(), - sequencer_address_prefix: ASTRIA_ADDRESS_PREFIX.into(), - api_addr: "0.0.0.0:0".into(), - log: String::new(), - force_stdout: false, - no_otel: false, - no_metrics: false, - metrics_http_listener_addr: String::new(), - pretty_print: true, - }; - - info!(config = serde_json::to_string(&config).unwrap()); - let (bridge_withdrawer, bridge_withdrawer_shutdown_handle) = - BridgeWithdrawer::new(config.clone()).unwrap(); - let api_address = bridge_withdrawer.local_addr(); - let bridge_withdrawer = tokio::task::spawn(bridge_withdrawer.run()); - - let mut test_bridge_withdrawer = TestBridgeWithdrawer { - api_address, - ethereum, - cometbft_mock, - sequencer_mock, - bridge_withdrawer_shutdown_handle: Some(bridge_withdrawer_shutdown_handle), - bridge_withdrawer, - config, - }; - - test_bridge_withdrawer.mount_startup_responses().await; - - test_bridge_withdrawer - } - - #[must_use] - pub fn native_ics20_config() -> Self { - Self { - ethereum_config: TestEthereumConfig::AstriaWithdrawer(AstriaWithdrawerDeployerConfig { - base_chain_asset_denomination: DEFAULT_IBC_DENOM.to_string(), - ..Default::default() - }), - asset_denom: DEFAULT_IBC_DENOM.parse().unwrap(), - } - } - - #[must_use] - pub fn erc20_sequencer_withdraw_config() -> Self { - Self { - ethereum_config: TestEthereumConfig::AstriaBridgeableERC20( - AstriaBridgeableERC20DeployerConfig { - base_chain_asset_precision: 18, - ..Default::default() - }, - ), - asset_denom: default_native_asset(), - } - } - - #[must_use] - pub fn erc20_ics20_config() -> Self { - Self { - ethereum_config: TestEthereumConfig::AstriaBridgeableERC20( - AstriaBridgeableERC20DeployerConfig { - base_chain_asset_precision: 18, - ..Default::default() - }, - ), - asset_denom: DEFAULT_IBC_DENOM.parse().unwrap(), - } - } -} - -impl Default for TestBridgeWithdrawerConfig { - fn default() -> Self { - Self { - ethereum_config: TestEthereumConfig::AstriaWithdrawer( - AstriaWithdrawerDeployerConfig::default(), - ), - asset_denom: default_native_asset(), - } - } -} - -#[track_caller] -pub fn assert_actions_eq(expected: &Action, actual: &Action) { - match (expected.clone(), actual.clone()) { - (Action::BridgeUnlock(expected), Action::BridgeUnlock(actual)) => { - assert_eq!(expected, actual, "BridgeUnlock actions do not match"); - } - (Action::Ics20Withdrawal(expected), Action::Ics20Withdrawal(actual)) => { - assert_eq!( - SubsetOfIcs20Withdrawal::from(expected), - SubsetOfIcs20Withdrawal::from(actual), - "Ics20Withdrawal actions do not match" - ); - } - _ => { - panic!("actions have a differing variants:\nexpected: {expected:?}\nactual: {actual:?}") - } - } -} - -/// A test wrapper around the `BridgeWithdrawer` for comparing the type without taking into account -/// the timout timestamp (which is based on the current `tendermint::Time::now()` in the -/// implementation) -#[derive(Debug, PartialEq)] -struct SubsetOfIcs20Withdrawal { - amount: u128, - denom: Denom, - destination_chain_address: String, - return_address: Address, - timeout_height: IbcHeight, - source_channel: ChannelId, - fee_asset: asset::Denom, - memo: String, - bridge_address: Option
, -} - -impl From for SubsetOfIcs20Withdrawal { - fn from(value: Ics20Withdrawal) -> Self { - let Ics20Withdrawal { - amount, - denom, - destination_chain_address, - return_address, - timeout_height, - timeout_time: _timeout_time, - source_channel, - fee_asset, - memo, - bridge_address, - } = value; - Self { - amount, - denom, - destination_chain_address, - return_address, - timeout_height, - source_channel, - fee_asset, - memo, - bridge_address, - } - } -} - -#[must_use] -pub fn make_bridge_unlock_action(receipt: &TransactionReceipt) -> Action { - let denom = default_native_asset(); - let inner = BridgeUnlockAction { - to: default_sequencer_address(), - amount: 1_000_000u128, - memo: serde_json::to_string(&BridgeUnlock { - rollup_block_number: receipt.block_number.unwrap().as_u64(), - rollup_transaction_hash: receipt.transaction_hash.to_string(), - }) - .unwrap(), - fee_asset: denom, - bridge_address: Some(default_bridge_address()), - }; - Action::BridgeUnlock(inner) -} - -#[must_use] -pub fn make_ics20_withdrawal_action(receipt: &TransactionReceipt) -> Action { - let timeout_height = IbcHeight::new(u64::MAX, u64::MAX).unwrap(); - let timeout_time = make_ibc_timeout_time(); - let denom = default_ibc_asset(); - let inner = Ics20Withdrawal { - denom: denom.clone(), - destination_chain_address: default_sequencer_address().to_string(), - return_address: default_bridge_address(), - amount: 1_000_000u128, - memo: serde_json::to_string(&Ics20WithdrawalFromRollup { - memo: "nootwashere".to_string(), - rollup_return_address: receipt.from.to_string(), - rollup_block_number: receipt.block_number.unwrap().as_u64(), - rollup_transaction_hash: receipt.transaction_hash.to_string(), - }) - .unwrap(), - fee_asset: denom, - timeout_height, - timeout_time, - source_channel: "channel-0".parse().unwrap(), - bridge_address: Some(default_bridge_address()), - }; - - Action::Ics20Withdrawal(inner) -} - -#[must_use] -fn make_ibc_timeout_time() -> u64 { - // this is copied from `bridge_withdrawer::ethereum::convert` - const ICS20_WITHDRAWAL_TIMEOUT: Duration = Duration::from_secs(300); - - tendermint::Time::now() - .checked_add(ICS20_WITHDRAWAL_TIMEOUT) - .unwrap() - .unix_timestamp_nanos() - .try_into() - .unwrap() -} - -#[must_use] -pub(crate) fn default_native_asset() -> asset::Denom { - "nria".parse().unwrap() -} - -#[must_use] -fn default_ibc_asset() -> asset::Denom { - DEFAULT_IBC_DENOM.parse::().unwrap() -} - -#[must_use] -pub(crate) fn default_bridge_address() -> Address { - astria_address([1u8; 20]) -} - -#[must_use] -pub fn default_sequencer_address() -> Address { - astria_address([2u8; 20]) -} - -/// Constructs an [`Address`] prefixed by `"astria"`. -#[must_use] -pub(crate) fn astria_address( - array: [u8; astria_core::primitive::v1::ADDRESS_LEN], -) -> astria_core::primitive::v1::Address { - astria_core::primitive::v1::Address::builder() - .array(array) - .prefix(ASTRIA_ADDRESS_PREFIX) - .try_build() - .unwrap() -} diff --git a/crates/astria-bridge-withdrawer/tests/blackbox/main.rs b/crates/astria-bridge-withdrawer/tests/blackbox/main.rs deleted file mode 100644 index 00aa141f39..0000000000 --- a/crates/astria-bridge-withdrawer/tests/blackbox/main.rs +++ /dev/null @@ -1,206 +0,0 @@ -use astria_core::protocol::transaction::v1alpha1::Action; -use helpers::{ - assert_actions_eq, - default_sequencer_address, - make_bridge_unlock_action, - make_ics20_withdrawal_action, - signed_tx_from_request, - TestBridgeWithdrawerConfig, -}; - -pub mod helpers; - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[ignore = "needs anvil to be present in $PATH; see github.com/foundry-rs/foundry for how to \ - install"] -async fn native_sequencer_withdraw_success() { - let test_env = TestBridgeWithdrawerConfig::default().spawn().await; - - test_env - .mount_pending_nonce_response(1, "process batch 1") - .await; - let broadcast_guard = test_env - .mount_broadcast_tx_commit_success_response_as_scoped() - .await; - - // send a native sequencer withdrawal tx to the rollup - let value = 1_000_000.into(); - let recipient = default_sequencer_address(); - let receipt = test_env - .ethereum - .send_sequencer_withdraw_transaction(value, recipient) - .await; - - test_env - .timeout_ms( - 2_000, - "batch 1 execution", - broadcast_guard.wait_until_satisfied(), - ) - .await; - - assert_contract_receipt_action_matches_broadcast_action::( - &broadcast_guard.received_requests().await, - &receipt, - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[ignore = "needs anvil to be present in $PATH; see github.com/foundry-rs/foundry for how to \ - install"] -async fn native_ics20_withdraw_success() { - let test_env = TestBridgeWithdrawerConfig::native_ics20_config() - .spawn() - .await; - - test_env - .mount_pending_nonce_response(1, "process batch 1") - .await; - let broadcast_guard = test_env - .mount_broadcast_tx_commit_success_response_as_scoped() - .await; - - // send an ics20 withdrawal tx to the rollup - let value = 1_000_000.into(); - let recipient = default_sequencer_address(); - let receipt = test_env - .ethereum - .send_ics20_withdraw_transaction(value, recipient.to_string()) - .await; - - test_env - .timeout_ms( - 2_000, - "batch 1 execution", - broadcast_guard.wait_until_satisfied(), - ) - .await; - - assert_contract_receipt_action_matches_broadcast_action::( - &broadcast_guard.received_requests().await, - &receipt, - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[ignore = "needs anvil to be present in $PATH; see gith&ub.com/foundry-rs/foundry for how to \ - install"] -async fn erc20_sequencer_withdraw_success() { - let test_env = TestBridgeWithdrawerConfig::erc20_sequencer_withdraw_config() - .spawn() - .await; - - test_env - .mount_pending_nonce_response(1, "process batch 1") - .await; - let broadcast_guard = test_env - .mount_broadcast_tx_commit_success_response_as_scoped() - .await; - - // mint some erc20 tokens to the rollup wallet - let _mint_receipt = test_env - .ethereum - .mint_tokens(2_000_000_000.into(), test_env.rollup_wallet_addr()) - .await; - - // send an ics20 withdrawal tx to the rollup - let value = 1_000_000.into(); - let recipient = default_sequencer_address(); - let receipt = test_env - .ethereum - .send_sequencer_withdraw_transaction_erc20(value, recipient) - .await; - - test_env - .timeout_ms( - 2_000, - "batch 1 execution", - broadcast_guard.wait_until_satisfied(), - ) - .await; - - assert_contract_receipt_action_matches_broadcast_action::( - &broadcast_guard.received_requests().await, - &receipt, - ); -} - -#[tokio::test(flavor = "multi_thread", worker_threads = 1)] -#[ignore = "needs anvil to be present in $PATH; see github.com/foundry-rs/foundry for how to \ - install"] -async fn erc20_ics20_withdraw_success() { - let test_env = TestBridgeWithdrawerConfig::erc20_ics20_config() - .spawn() - .await; - - test_env - .mount_pending_nonce_response(1, "process batch 1") - .await; - let broadcast_guard = test_env - .mount_broadcast_tx_commit_success_response_as_scoped() - .await; - - // mint some erc20 tokens to the rollup wallet - let _mint_receipt = test_env - .ethereum - .mint_tokens(2_000_000_000.into(), test_env.rollup_wallet_addr()) - .await; - - // send an ics20 withdrawal tx to the rollup - let value = 1_000_000.into(); - let recipient = default_sequencer_address(); - let receipt = test_env - .ethereum - .send_ics20_withdraw_transaction_astria_bridgeable_erc20(value, recipient.to_string()) - .await; - - test_env - .timeout_ms( - 2_000, - "batch 1 execution", - broadcast_guard.wait_until_satisfied(), - ) - .await; - - assert_contract_receipt_action_matches_broadcast_action::( - &broadcast_guard.received_requests().await, - &receipt, - ); -} - -trait ActionFromReceipt { - fn action_from_receipt(receipt: ðers::types::TransactionReceipt) -> Action; -} - -struct BridgeUnlock; -impl ActionFromReceipt for BridgeUnlock { - #[track_caller] - fn action_from_receipt(receipt: ðers::types::TransactionReceipt) -> Action { - make_bridge_unlock_action(receipt) - } -} - -struct Ics20; -impl ActionFromReceipt for Ics20 { - #[track_caller] - fn action_from_receipt(receipt: ðers::types::TransactionReceipt) -> Action { - make_ics20_withdrawal_action(receipt) - } -} - -#[track_caller] -fn assert_contract_receipt_action_matches_broadcast_action( - received_broadcasts: &[wiremock::Request], - receipt: ðers::types::TransactionReceipt, -) { - let tx = signed_tx_from_request(received_broadcasts.first().expect( - "at least one request should have been received if the broadcast guard is satisfied", - )); - let actual = tx - .actions() - .first() - .expect("the signed transaction should contain at least one action"); - - let expected = T::action_from_receipt(receipt); - assert_actions_eq(&expected, actual); -}