diff --git a/crates/astria-bridge-withdrawer/src/withdrawer/ethereum/watcher.rs b/crates/astria-bridge-withdrawer/src/withdrawer/ethereum/watcher.rs index 1e384a08f9..d8160ffeaa 100644 --- a/crates/astria-bridge-withdrawer/src/withdrawer/ethereum/watcher.rs +++ b/crates/astria-bridge-withdrawer/src/withdrawer/ethereum/watcher.rs @@ -111,7 +111,8 @@ pub(crate) struct Watcher { impl Watcher { pub(crate) async fn run(mut self) -> Result<()> { - let (provider, contract, fee_asset_id, asset_withdrawal_divisor) = self.startup().await?; + let (provider, contract, fee_asset_id, asset_withdrawal_divisor, next_rollup_block_height) = + self.startup().await?; let Self { contract_address: _contract_address, @@ -138,15 +139,16 @@ impl Watcher { tokio::task::spawn(batcher.run()); - // start from block 1 right now - // TODO: determine the last block we've seen based on the sequencer data - let sequencer_withdrawal_event_handler = tokio::task::spawn( - watch_for_sequencer_withdrawal_events(contract.clone(), event_tx.clone(), 1), - ); + let sequencer_withdrawal_event_handler = + tokio::task::spawn(watch_for_sequencer_withdrawal_events( + contract.clone(), + event_tx.clone(), + next_rollup_block_height, + )); let ics20_withdrawal_event_handler = tokio::task::spawn(watch_for_ics20_withdrawal_events( contract, event_tx.clone(), - 1, + next_rollup_block_height, )); state.set_watcher_ready(); @@ -169,8 +171,8 @@ impl Watcher { /// Gets the startup data from the submitter and connects to the Ethereum node. /// - /// Returns the contract handle, the asset ID of the fee asset, and the divisor for the asset - /// withdrawal amount. + /// Returns the contract handle, the asset ID of the fee asset, the divisor for the asset + /// withdrawal amount, and the rollup block height to watch from. /// /// # Errors /// - If the fee asset ID provided in the config is not a valid fee asset on the sequencer. @@ -183,10 +185,12 @@ impl Watcher { IAstriaWithdrawer>, asset::Id, u128, + u64, )> { // wait for submitter to be ready let SequencerStartupInfo { fee_asset_id, + next_batch_rollup_height, } = self.submitter_handle.recv_startup_info().await?; // connect to eth node @@ -242,6 +246,7 @@ impl Watcher { contract, fee_asset_id, asset_withdrawal_divisor, + next_batch_rollup_height, )) } } @@ -556,6 +561,7 @@ mod tests { startup_tx .send(SequencerStartupInfo { fee_asset_id: denom.id(), + next_batch_rollup_height: 0, }) .unwrap(); @@ -647,6 +653,7 @@ mod tests { startup_tx .send(SequencerStartupInfo { fee_asset_id: denom.id(), + next_batch_rollup_height: 0, }) .unwrap(); @@ -768,6 +775,7 @@ mod tests { startup_tx .send(SequencerStartupInfo { fee_asset_id: denom.id(), + next_batch_rollup_height: 0, }) .unwrap(); @@ -869,6 +877,7 @@ mod tests { startup_tx .send(SequencerStartupInfo { fee_asset_id: asset::Id::from_denom("transfer/channel-0/utia"), + next_batch_rollup_height: 0, }) .unwrap(); diff --git a/crates/astria-bridge-withdrawer/src/withdrawer/mod.rs b/crates/astria-bridge-withdrawer/src/withdrawer/mod.rs index 1d6edfb130..1c6b119038 100644 --- a/crates/astria-bridge-withdrawer/src/withdrawer/mod.rs +++ b/crates/astria-bridge-withdrawer/src/withdrawer/mod.rs @@ -188,6 +188,7 @@ impl Service { #[derive(Debug)] pub struct SequencerStartupInfo { pub fee_asset_id: asset::Id, + pub next_batch_rollup_height: u64, } /// A handle for instructing the [`Service`] to shut down. diff --git a/crates/astria-bridge-withdrawer/src/withdrawer/submitter/mod.rs b/crates/astria-bridge-withdrawer/src/withdrawer/submitter/mod.rs index 218a57186a..55c2522e5e 100644 --- a/crates/astria-bridge-withdrawer/src/withdrawer/submitter/mod.rs +++ b/crates/astria-bridge-withdrawer/src/withdrawer/submitter/mod.rs @@ -4,9 +4,11 @@ use std::{ }; use astria_core::{ + bridge::Ics20WithdrawalFromRollupMemo, primitive::v1::asset, protocol::{ asset::v1alpha1::AllowedFeeAssetIdsResponse, + bridge::v1alpha1::BridgeAccountLastTxHashResponse, transaction::v1alpha1::{ Action, TransactionParams, @@ -23,17 +25,23 @@ use astria_eyre::eyre::{ }; pub(crate) use builder::Builder; pub(super) use builder::Handle; +use prost::Message as _; use sequencer_client::{ tendermint_rpc::{ self, endpoint::broadcast::tx_commit, }, Address, + BalanceResponse, SequencerClientExt, SignedTransaction, }; use signer::SequencerKey; use state::State; +use tendermint_rpc::{ + endpoint::tx, + Client, +}; use tokio::{ select, sync::{ @@ -61,6 +69,7 @@ use super::{ state, SequencerStartupInfo, }; +use crate::withdrawer::ethereum::convert::BridgeUnlockMemo; mod builder; mod signer; @@ -101,12 +110,15 @@ impl Submitter { 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(), &self.signer, self.state.clone(), &self.sequencer_chain_id, - actions, rollup_height).await { + actions, + rollup_height, + ).await { break Err(e); } } @@ -129,8 +141,25 @@ impl Submitter { Ok(()) } - /// Confirms the config values used for initialization against the sequencer node's cometbft - /// instance and set the submitter state to ready. + /// Confirms configuration values against the sequencer node and then syncs the next sequencer + /// nonce and rollup block according to the latest on-chain state. + /// + /// Configuration values checked: + /// - `self.chain_id` matches the value returned from the sequencer node's genesis + /// - `self.fee_asset_id` is a valid fee asset on the sequencer node + /// - `self.sequencer_key.address` has a sufficient balance of `self.fee_asset_id` + /// + /// Sync process: + /// - Fetch the last transaction hash by the bridge account from the sequencer + /// - Fetch the corresponding transaction + /// - Extract the last nonce used from the transaction + /// - Extract the rollup block height from the memo of one of the withdraw actions in the + /// transaction + /// + /// # Returns + /// A struct with the information collected and validated during startup: + /// - `fee_asset_id` + /// - `next_batch_rollup_height` /// /// # Errors /// @@ -158,10 +187,12 @@ impl Submitter { ); // confirm that the sequencer key has a sufficient balance of the fee asset - let fee_asset_balances = self - .sequencer_cometbft_client - .get_latest_balance(self.signer.address) - .await?; + let fee_asset_balances = get_latest_balance( + self.sequencer_cometbft_client.clone(), + self.state.clone(), + self.signer.address, + ) + .await?; let fee_asset_balance = fee_asset_balances .balances .into_iter() @@ -173,19 +204,105 @@ impl Submitter { "sequencer key does not have a sufficient balance of the fee asset" ); + // sync to latest on-chain state + let next_batch_rollup_height = self.get_next_rollup_height().await?; + self.state.set_submitter_ready(); // send startup info to watcher let startup = SequencerStartupInfo { fee_asset_id: self.expected_fee_asset_id, + next_batch_rollup_height, }; Ok(startup) } + + /// Gets the data necessary for syncing to the latest on-chain state from the sequencer. Since + /// we batch all events from a given rollup block into a single sequencer transaction, we + /// get the last tx finalized by the bridge account on the sequencer and extract the rollup + /// height from it. + /// + /// The rollup height is extracted from the block height value in the memo of one of the actions + /// in the batch. + /// + /// # Returns + /// The next batch rollup height to process. + /// + /// # Errors + /// + /// 1. Failing to get and deserialize a valid last transaction by the bridge account from the + /// sequencer. + /// 2. The last transaction by the bridge account failed to execute (this should not happen in + /// the sequencer logic) + /// 3. The last transaction by the bridge account did not contain a withdrawal action + /// 4. The memo of the last transaction by the bridge account could not be parsed + async fn get_next_rollup_height(&mut self) -> eyre::Result { + let signed_transaction = self.get_last_transaction().await?; + let next_batch_rollup_height = if let Some(signed_transaction) = signed_transaction { + rollup_height_from_signed_transaction(&signed_transaction).wrap_err( + "failed to extract rollup height from last transaction by the bridge account", + )? + } else { + 1 + }; + Ok(next_batch_rollup_height) + } + + async fn get_last_transaction(&self) -> eyre::Result> { + // get last transaction hash by the bridge account, if it exists + let last_transaction_hash_resp = get_bridge_account_last_transaction_hash( + self.sequencer_cometbft_client.clone(), + self.state.clone(), + self.signer.address, + ) + .await + .wrap_err("failed to fetch last transaction hash by the bridge account")?; + + let Some(tx_hash) = last_transaction_hash_resp.tx_hash else { + return Ok(None); + }; + + let tx_hash = tendermint::Hash::try_from(tx_hash.to_vec()) + .wrap_err("failed to convert last transaction hash to tendermint hash")?; + + // get the corresponding transaction + let last_transaction = get_tx( + self.sequencer_cometbft_client.clone(), + self.state.clone(), + tx_hash, + ) + .await + .wrap_err("failed to fetch last transaction by the bridge account")?; + + // check that the transaction actually executed + ensure!( + last_transaction.tx_result.code == tendermint::abci::Code::Ok, + "last transaction by the bridge account failed to execute. this should not happen in \ + the sequencer logic." + ); + + let proto_tx = + astria_core::generated::protocol::transaction::v1alpha1::SignedTransaction::decode( + &*last_transaction.tx, + ) + .wrap_err("failed to convert transaction data from CometBFT to proto")?; + + let tx = SignedTransaction::try_from_raw(proto_tx) + .wrap_err("failed to convert transaction data from proto to SignedTransaction")?; + + info!( + last_bridge_account_tx.hash = %telemetry::display::hex(&tx_hash), + last_bridge_account_tx.height = i64::from(last_transaction.height), + "fetched last transaction by the bridge account" + ); + + Ok(Some(tx)) + } } async fn process_batch( sequencer_cometbft_client: sequencer_client::HttpClient, - sequnecer_key: &SequencerKey, + sequencer_key: &SequencerKey, state: Arc, sequencer_chain_id: &str, actions: Vec, @@ -194,7 +311,7 @@ async fn process_batch( // get nonce and make unsigned transaction let nonce = get_latest_nonce( sequencer_cometbft_client.clone(), - sequnecer_key.address, + sequencer_key.address, state.clone(), ) .await?; @@ -213,7 +330,7 @@ async fn process_batch( }; // sign transaction - let signed = unsigned.into_signed(&sequnecer_key.signing_key); + let signed = unsigned.into_signed(&sequencer_key.signing_key); debug!(tx_hash = %telemetry::display::hex(&signed.sha256_of_proto_encoding()), "signed transaction"); // submit transaction and handle response @@ -255,8 +372,6 @@ async fn process_batch( } } -/// Queries the sequencer for the latest nonce with an exponential backoff -#[instrument(skip_all, fields(%address))] async fn get_latest_nonce( client: sequencer_client::HttpClient, address: Address, @@ -444,3 +559,156 @@ async fn get_allowed_fee_asset_ids( res } + +#[instrument(skip_all)] +async fn get_latest_balance( + client: sequencer_client::HttpClient, + state: Arc, + address: Address, +) -> eyre::Result { + let retry_config = tryhard::RetryFutureConfig::new(u32::MAX) + .exponential_backoff(Duration::from_millis(100)) + .max_delay(Duration::from_secs(20)) + .on_retry( + |attempt: u32, + next_delay: Option, + error: &sequencer_client::extension_trait::Error| { + let state = Arc::clone(&state); + state.set_sequencer_connected(false); + + let wait_duration = next_delay + .map(humantime::format_duration) + .map(tracing::field::display); + warn!( + attempt, + wait_duration, + error = error as &dyn std::error::Error, + "attempt to get latest balance; retrying after backoff", + ); + futures::future::ready(()) + }, + ); + + let res = tryhard::retry_fn(|| client.get_latest_balance(address)) + .with_config(retry_config) + .await + .wrap_err("failed to get latest balance from Sequencer after a lot of attempts"); + + state.set_sequencer_connected(res.is_ok()); + + res +} + +#[instrument(skip_all)] +async fn get_bridge_account_last_transaction_hash( + client: sequencer_client::HttpClient, + state: Arc, + address: Address, +) -> eyre::Result { + let retry_config = tryhard::RetryFutureConfig::new(u32::MAX) + .exponential_backoff(Duration::from_millis(100)) + .max_delay(Duration::from_secs(20)) + .on_retry( + |attempt: u32, + next_delay: Option, + error: &sequencer_client::extension_trait::Error| { + let state = Arc::clone(&state); + state.set_sequencer_connected(false); + + let wait_duration = next_delay + .map(humantime::format_duration) + .map(tracing::field::display); + warn!( + attempt, + wait_duration, + error = error as &dyn std::error::Error, + "attempt to fetch last bridge account's transaction hash; retrying after \ + backoff", + ); + futures::future::ready(()) + }, + ); + + let res = tryhard::retry_fn(|| client.get_bridge_account_last_transaction_hash(address)) + .with_config(retry_config) + .await + .wrap_err( + "failed to fetch last bridge account's transaction hash from Sequencer after a lot of \ + attempts", + ); + + state.set_sequencer_connected(res.is_ok()); + + res +} + +#[instrument(skip_all)] +async fn get_tx( + client: sequencer_client::HttpClient, + state: Arc, + tx_hash: tendermint::Hash, +) -> eyre::Result { + let retry_config = tryhard::RetryFutureConfig::new(u32::MAX) + .exponential_backoff(Duration::from_millis(100)) + .max_delay(Duration::from_secs(20)) + .on_retry( + |attempt: u32, next_delay: Option, error: &tendermint_rpc::Error| { + let state = Arc::clone(&state); + state.set_sequencer_connected(false); + + let wait_duration = next_delay + .map(humantime::format_duration) + .map(tracing::field::display); + warn!( + attempt, + wait_duration, + error = error as &dyn std::error::Error, + "attempt to get transaction from Sequencer; retrying after backoff", + ); + futures::future::ready(()) + }, + ); + + let res = tryhard::retry_fn(|| client.tx(tx_hash, false)) + .with_config(retry_config) + .await + .wrap_err("failed to get transaction from Sequencer after a lot of attempts"); + + state.set_sequencer_connected(res.is_ok()); + + res +} + +fn rollup_height_from_signed_transaction( + signed_transaction: &SignedTransaction, +) -> eyre::Result { + // find the last batch's rollup block height + let withdrawal_action = signed_transaction + .actions() + .iter() + .find(|action| matches!(action, Action::BridgeUnlock(_) | Action::Ics20Withdrawal(_))) + .ok_or_eyre("last transaction by the bridge account did not contain a withdrawal action")?; + + let last_batch_rollup_height = match withdrawal_action { + Action::BridgeUnlock(action) => { + let memo: BridgeUnlockMemo = serde_json::from_slice(&action.memo) + .wrap_err("failed to parse memo from last transaction by the bridge account")?; + Some(memo.block_number.as_u64()) + } + Action::Ics20Withdrawal(action) => { + let memo: Ics20WithdrawalFromRollupMemo = serde_json::from_str(&action.memo) + .wrap_err("failed to parse memo from last transaction by the bridge account")?; + Some(memo.block_number) + } + _ => None, + } + .expect("action is already checked to be either BridgeUnlock or Ics20Withdrawal"); + + info!( + last_batch.tx_hash = %telemetry::display::hex(&signed_transaction.sha256_of_proto_encoding()), + last_batch.rollup_height = last_batch_rollup_height, + "extracted rollup height from last batch of withdrawals", + ); + + Ok(last_batch_rollup_height) +} diff --git a/crates/astria-bridge-withdrawer/src/withdrawer/submitter/tests.rs b/crates/astria-bridge-withdrawer/src/withdrawer/submitter/tests.rs index c8cda58ab3..074e8d78dd 100644 --- a/crates/astria-bridge-withdrawer/src/withdrawer/submitter/tests.rs +++ b/crates/astria-bridge-withdrawer/src/withdrawer/submitter/tests.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, io::Write as _, sync::Arc, time::Duration, @@ -7,6 +8,7 @@ use std::{ use astria_core::{ bridge::Ics20WithdrawalFromRollupMemo, + crypto::SigningKey, generated::protocol::account::v1alpha1::NonceResponse, primitive::v1::{ asset::{ @@ -18,16 +20,22 @@ use astria_core::{ }, protocol::{ account::v1alpha1::AssetBalance, + bridge::v1alpha1::BridgeAccountLastTxHashResponse, transaction::v1alpha1::{ action::{ BridgeUnlockAction, Ics20Withdrawal, }, Action, + TransactionParams, + UnsignedTransaction, }, }, }; -use astria_eyre::eyre; +use astria_eyre::eyre::{ + self, + Context, +}; use ibc_types::core::client::Height as IbcHeight; use once_cell::sync::Lazy; use prost::Message as _; @@ -42,6 +50,7 @@ use serde_json::json; use tempfile::NamedTempFile; use tendermint::{ abci::{ + self, response::CheckTx, types::ExecTxResult, }, @@ -49,7 +58,10 @@ use tendermint::{ chain, }; use tendermint_rpc::{ - endpoint::broadcast::tx_sync, + endpoint::{ + broadcast::tx_sync, + tx, + }, request, }; use tokio::task::JoinHandle; @@ -76,6 +88,11 @@ use crate::withdrawer::{ }; const SEQUENCER_CHAIN_ID: &str = "test_sequencer-1000"; +const DEFAULT_LAST_ROLLUP_HEIGHT: u64 = 1; +const DEFAULT_LAST_SEQUENCER_HEIGHT: u64 = 0; +const DEFAULT_SEQUENCER_NONCE: u32 = 0; +const DEFAULT_NATIVE_DEMON: &str = "nria"; +const DEFAULT_IBC_DENOM: &str = "transfer/channel-0/utia"; static TELEMETRY: Lazy<()> = Lazy::new(|| { if std::env::var_os("TEST_LOG").is_some() { @@ -134,7 +151,7 @@ impl TestSubmitter { sequencer_chain_id: SEQUENCER_CHAIN_ID.to_string(), sequencer_cometbft_endpoint, state, - expected_fee_asset_id: Denom::from("nria".to_string()).id(), + expected_fee_asset_id: Denom::from(DEFAULT_NATIVE_DEMON.to_string()).id(), min_expected_fee_asset_balance: 1_000_000, } .build() @@ -148,7 +165,7 @@ impl TestSubmitter { } } - async fn startup_and_spawn_with_guards(&mut self, startup_guards: Vec) { + async fn startup_and_spawn_with_guards(&mut self, startup_guards: HashMap) { let submitter = self.submitter.take().unwrap(); let mut state = submitter.state.subscribe(); @@ -156,9 +173,10 @@ impl TestSubmitter { self.submitter_task_handle = Some(tokio::spawn(submitter.run())); // wait for all startup guards to be satisfied - for guard in startup_guards { + for (name, guard) in startup_guards { tokio::time::timeout(Duration::from_millis(100), guard.wait_until_satisfied()) .await + .wrap_err(format!("{name} guard not satisfied in time.")) .unwrap(); } @@ -174,7 +192,14 @@ impl TestSubmitter { async fn startup_and_spawn(&mut self) { let startup_guards = register_startup_guards(&self.cometbft_mock).await; - self.startup_and_spawn_with_guards(startup_guards).await; + let sync_guards = register_sync_guards(&self.cometbft_mock).await; + self.startup_and_spawn_with_guards( + startup_guards + .into_iter() + .chain(sync_guards.into_iter()) + .collect(), + ) + .await; } async fn spawn() -> Self { @@ -189,7 +214,7 @@ async fn register_default_chain_id_guard(cometbft_mock: &MockServer) -> MockGuar } async fn register_default_fee_asset_ids_guard(cometbft_mock: &MockServer) -> MockGuard { - let fee_asset_ids = vec![Denom::from("nria".to_string()).id()]; + let fee_asset_ids = vec![Denom::from(DEFAULT_NATIVE_DEMON.to_string()).id()]; register_allowed_fee_asset_ids_response(fee_asset_ids, cometbft_mock).await } @@ -198,7 +223,7 @@ async fn register_default_min_expected_fee_asset_balance_guard( ) -> MockGuard { register_get_latest_balance( vec![AssetBalance { - denom: Denom::from("nria".to_string()), + denom: Denom::from(DEFAULT_NATIVE_DEMON.to_string()), balance: 1_000_000u128, }], cometbft_mock, @@ -206,16 +231,46 @@ async fn register_default_min_expected_fee_asset_balance_guard( .await } -async fn register_startup_guards(cometbft_mock: &MockServer) -> Vec { - vec![ - register_default_chain_id_guard(cometbft_mock).await, - register_default_fee_asset_ids_guard(cometbft_mock).await, - register_default_min_expected_fee_asset_balance_guard(cometbft_mock).await, - ] +async fn register_default_last_bridge_tx_hash_guard(cometbft_mock: &MockServer) -> MockGuard { + register_last_bridge_tx_hash_guard(cometbft_mock, make_last_bridge_tx_hash_response()).await +} + +async fn register_default_last_bridge_tx_guard(cometbft_mock: &MockServer) -> MockGuard { + register_tx_guard(cometbft_mock, make_tx_response()).await +} + +async fn register_startup_guards(cometbft_mock: &MockServer) -> HashMap { + HashMap::from([ + ( + "chain_id".to_string(), + register_default_chain_id_guard(cometbft_mock).await, + ), + ( + "fee_asset_ids".to_string(), + register_default_fee_asset_ids_guard(cometbft_mock).await, + ), + ( + "min_expected_fee_asset_balance".to_string(), + register_default_min_expected_fee_asset_balance_guard(cometbft_mock).await, + ), + ]) +} + +async fn register_sync_guards(cometbft_mock: &MockServer) -> HashMap { + HashMap::from([ + ( + "tx_hash".to_string(), + register_default_last_bridge_tx_hash_guard(cometbft_mock).await, + ), + ( + "last_bridge_tx".to_string(), + register_default_last_bridge_tx_guard(cometbft_mock).await, + ), + ]) } fn make_ics20_withdrawal_action() -> Action { - let denom = Denom::from("transfer/channel-0/utia".to_string()); + let denom = Denom::from(DEFAULT_IBC_DENOM.to_string()); let destination_chain_address = "address".to_string(); let inner = Ics20Withdrawal { denom: denom.clone(), @@ -229,7 +284,7 @@ fn make_ics20_withdrawal_action() -> Action { memo: serde_json::to_string(&Ics20WithdrawalFromRollupMemo { memo: "hello".to_string(), bridge_address: crate::astria_address([0u8; 20]), - block_number: 1u64, + block_number: DEFAULT_LAST_ROLLUP_HEIGHT, transaction_hash: [2u8; 32], }) .unwrap(), @@ -244,7 +299,7 @@ fn make_ics20_withdrawal_action() -> Action { } fn make_bridge_unlock_action() -> Action { - let denom = Denom::from("nria".to_string()); + let denom = Denom::from(DEFAULT_NATIVE_DEMON.to_string()); let inner = BridgeUnlockAction { to: Address::builder() .array([0u8; 20]) @@ -253,8 +308,8 @@ fn make_bridge_unlock_action() -> Action { .unwrap(), amount: 99, memo: serde_json::to_vec(&BridgeUnlockMemo { - block_number: 1.into(), - transaction_hash: [2u8; 32].into(), + block_number: DEFAULT_LAST_ROLLUP_HEIGHT.into(), + transaction_hash: [1u8; 32].into(), }) .unwrap(), fee_asset_id: denom.id(), @@ -303,6 +358,48 @@ fn make_tx_commit_deliver_tx_failure_response() -> tx_commit::Response { } } +fn make_last_bridge_tx_hash_response() -> BridgeAccountLastTxHashResponse { + BridgeAccountLastTxHashResponse { + height: DEFAULT_LAST_ROLLUP_HEIGHT, + tx_hash: Some([0u8; 32]), + } +} + +fn make_signed_bridge_transaction() -> SignedTransaction { + let alice_secret_bytes: [u8; 32] = + hex::decode("2bd806c97f0e00af1a1fc3328fa763a9269723c8db8fac4f93af71db186d6e90") + .unwrap() + .try_into() + .unwrap(); + let alice_key = SigningKey::from(alice_secret_bytes); + + let actions = vec![make_bridge_unlock_action(), make_ics20_withdrawal_action()]; + UnsignedTransaction { + params: TransactionParams::builder() + .nonce(DEFAULT_SEQUENCER_NONCE) + .chain_id(SEQUENCER_CHAIN_ID) + .try_build() + .unwrap(), + actions, + } + .into_signed(&alice_key) +} + +fn make_tx_response() -> tx::Response { + let tx = make_signed_bridge_transaction(); + tx::Response { + hash: tx.sha256_of_proto_encoding().to_vec().try_into().unwrap(), + height: DEFAULT_LAST_SEQUENCER_HEIGHT.try_into().unwrap(), + index: 0, + tx_result: ExecTxResult { + code: abci::Code::Ok, + ..ExecTxResult::default() + }, + tx: tx.into_raw().encode_to_vec(), + proof: None, + } +} + /// Convert a `Request` object to a `SignedTransaction` fn signed_tx_from_request(request: &Request) -> SignedTransaction { use astria_core::generated::protocol::transaction::v1alpha1::SignedTransaction as RawSignedTransaction; @@ -430,6 +527,29 @@ async fn register_get_latest_balance( .await } +async fn register_last_bridge_tx_hash_guard( + server: &MockServer, + response: BridgeAccountLastTxHashResponse, +) -> MockGuard { + 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(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) + .mount_as_scoped(server) + .await +} + async fn register_get_nonce_response(server: &MockServer, response: NonceResponse) -> MockGuard { let response = tendermint_rpc::endpoint::abci_query::Response { response: tendermint_rpc::endpoint::abci_query::AbciQuery { @@ -450,6 +570,19 @@ async fn register_get_nonce_response(server: &MockServer, response: NonceRespons .await } +async fn register_tx_guard(server: &MockServer, response: tx::Response) -> MockGuard { + let wrapper = response::Wrapper::new_with_id(tendermint_rpc::Id::Num(1), Some(response), None); + Mock::given(body_partial_json(json!({"method": "tx"}))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(&wrapper) + .append_header("Content-Type", "application/json"), + ) + .expect(1) + .mount_as_scoped(server) + .await +} + async fn register_broadcast_tx_commit_response( server: &MockServer, response: tx_commit::Response, @@ -505,6 +638,7 @@ async fn submitter_submit_success() { }, ) .await; + let broadcast_guard = register_broadcast_tx_commit_response(&cometbft_mock, make_tx_commit_success_response()) .await; @@ -513,7 +647,7 @@ async fn submitter_submit_success() { let batch = make_batch_with_bridge_unlock_and_ics20_withdrawal(); submitter_handle.send_batch(batch).await.unwrap(); - // wait for the nonce and broadcast guards to be satisfied + // wait for nonce and broadcast guards to be satisfied tokio::time::timeout( Duration::from_millis(100), nonce_guard.wait_until_satisfied(), @@ -562,6 +696,7 @@ async fn submitter_submit_check_tx_failure() { }, ) .await; + let broadcast_guard = register_broadcast_tx_commit_response( &cometbft_mock, make_tx_commit_check_tx_failure_response(), @@ -617,6 +752,7 @@ async fn submitter_submit_deliver_tx_failure() { }, ) .await; + let broadcast_guard = register_broadcast_tx_commit_response( &cometbft_mock, make_tx_commit_deliver_tx_failure_response(), diff --git a/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs b/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs index 7afb5e86b5..4b75c9d8ef 100644 --- a/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs +++ b/crates/astria-core/src/generated/astria.protocol.bridge.v1alpha1.rs @@ -1,11 +1,12 @@ -/// A response containing the last tx hash given some bridge address. +/// A response containing the last tx hash given some bridge address, +/// if it exists. #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct BridgeAccountLastTxHashResponse { #[prost(uint64, tag = "2")] pub height: u64, - #[prost(bytes = "vec", tag = "3")] - pub tx_hash: ::prost::alloc::vec::Vec, + #[prost(bytes = "vec", optional, tag = "3")] + pub tx_hash: ::core::option::Option<::prost::alloc::vec::Vec>, } impl ::prost::Name for BridgeAccountLastTxHashResponse { const NAME: &'static str = "BridgeAccountLastTxHashResponse"; diff --git a/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs b/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs index 1e97689622..6e09384c54 100644 --- a/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs +++ b/crates/astria-core/src/protocol/bridge/v1alpha1/mod.rs @@ -3,7 +3,7 @@ use super::raw; #[derive(Debug, Clone, PartialEq, Eq)] pub struct BridgeAccountLastTxHashResponse { pub height: u64, - pub tx_hash: [u8; 32], + pub tx_hash: Option<[u8; 32]>, } impl BridgeAccountLastTxHashResponse { @@ -18,9 +18,13 @@ impl BridgeAccountLastTxHashResponse { ) -> Result { Ok(Self { height: raw.height, - tx_hash: raw.tx_hash.try_into().map_err(|bytes: Vec| { - BridgeAccountLastTxHashResponseError::invalid_tx_hash(bytes.len()) - })?, + tx_hash: raw + .tx_hash + .map(TryInto::<[u8; 32]>::try_into) + .transpose() + .map_err(|bytes: Vec| { + BridgeAccountLastTxHashResponseError::invalid_tx_hash(bytes.len()) + })?, }) } @@ -28,7 +32,7 @@ impl BridgeAccountLastTxHashResponse { pub fn into_raw(self) -> raw::BridgeAccountLastTxHashResponse { raw::BridgeAccountLastTxHashResponse { height: self.height, - tx_hash: self.tx_hash.to_vec(), + tx_hash: self.tx_hash.map(Into::into), } } } diff --git a/crates/astria-sequencer-client/src/tests/http.rs b/crates/astria-sequencer-client/src/tests/http.rs index f4bbca6ebd..9e47dedab6 100644 --- a/crates/astria-sequencer-client/src/tests/http.rs +++ b/crates/astria-sequencer-client/src/tests/http.rs @@ -262,7 +262,7 @@ async fn get_bridge_account_last_transaction_hash() { let expected_response = BridgeAccountLastTxHashResponse { height: 10, - tx_hash: [0; 32].to_vec(), + tx_hash: Some([0; 32].to_vec()), }; let _guard = register_abci_query_response( diff --git a/crates/astria-sequencer/src/bridge/query.rs b/crates/astria-sequencer/src/bridge/query.rs index 850e2f23ab..65a50303e5 100644 --- a/crates/astria-sequencer/src/bridge/query.rs +++ b/crates/astria-sequencer/src/bridge/query.rs @@ -41,19 +41,18 @@ pub(crate) async fn bridge_account_last_tx_hash_request( } }; - let tx_hash = match snapshot + let resp = match snapshot .get_last_transaction_hash_for_bridge_account(&address) .await { - Ok(Some(tx_hash)) => tx_hash, - Ok(None) => { - return response::Query { - code: AbciErrorCode::VALUE_NOT_FOUND.into(), - info: AbciErrorCode::VALUE_NOT_FOUND.to_string(), - log: "no transaction hash found for provided address".into(), - ..response::Query::default() - }; - } + Ok(Some(tx_hash)) => BridgeAccountLastTxHashResponse { + height, + tx_hash: Some(tx_hash), + }, + Ok(None) => BridgeAccountLastTxHashResponse { + height, + tx_hash: None, + }, Err(err) => { return response::Query { code: AbciErrorCode::INTERNAL_ERROR.into(), @@ -63,13 +62,7 @@ pub(crate) async fn bridge_account_last_tx_hash_request( }; } }; - let payload = BridgeAccountLastTxHashResponse { - height, - tx_hash, - } - .into_raw() - .encode_to_vec() - .into(); + let payload = resp.into_raw().encode_to_vec().into(); let height = tendermint::block::Height::try_from(height).expect("height must fit into an i64"); response::Query { diff --git a/proto/protocolapis/astria/protocol/bridge/v1alpha1/types.proto b/proto/protocolapis/astria/protocol/bridge/v1alpha1/types.proto index c4a850ebc4..392502e5d4 100644 --- a/proto/protocolapis/astria/protocol/bridge/v1alpha1/types.proto +++ b/proto/protocolapis/astria/protocol/bridge/v1alpha1/types.proto @@ -2,8 +2,9 @@ syntax = "proto3"; package astria.protocol.bridge.v1alpha1; -// A response containing the last tx hash given some bridge address. +// A response containing the last tx hash given some bridge address, +// if it exists. message BridgeAccountLastTxHashResponse { uint64 height = 2; - bytes tx_hash = 3; + optional bytes tx_hash = 3; }