diff --git a/crates/builder/src/bundle_proposer.rs b/crates/builder/src/bundle_proposer.rs index 49375116b..d006a395f 100644 --- a/crates/builder/src/bundle_proposer.rs +++ b/crates/builder/src/bundle_proposer.rs @@ -96,11 +96,21 @@ impl Bundle { pub(crate) trait BundleProposer: Send + Sync + 'static { type UO: UserOperation; + /// Constructs the next bundle + /// + /// If `min_fees` is `Some`, the proposer will ensure the bundle has + /// at least `min_fees`. async fn make_bundle( &self, - required_fees: Option, + min_fees: Option, is_replacement: bool, ) -> anyhow::Result>; + + /// Gets the current gas fees + /// + /// If `min_fees` is `Some`, the proposer will ensure the gas fees returned are at least `min_fees`. + async fn estimate_gas_fees(&self, min_fees: Option) + -> anyhow::Result<(GasFees, U256)>; } #[derive(Debug)] @@ -138,6 +148,13 @@ where { type UO = UO; + async fn estimate_gas_fees( + &self, + required_fees: Option, + ) -> anyhow::Result<(GasFees, U256)> { + self.fee_estimator.required_bundle_fees(required_fees).await + } + async fn make_bundle( &self, required_fees: Option, @@ -148,7 +165,7 @@ where self.provider .get_latest_block_hash_and_number() .map_err(anyhow::Error::from), - self.fee_estimator.required_bundle_fees(required_fees) + self.estimate_gas_fees(required_fees) )?; if ops.is_empty() { return Ok(Bundle::default()); diff --git a/crates/builder/src/bundle_sender.rs b/crates/builder/src/bundle_sender.rs index cae080929..67375f0c5 100644 --- a/crates/builder/src/bundle_sender.rs +++ b/crates/builder/src/bundle_sender.rs @@ -132,6 +132,12 @@ where // Waiting for a bundle to be mined // (wait_until_block, fee_increase_count) Pending(u64, u64), + // Cancelling the last transaction + // (fee_increase_count) + Cancelling(u64), + // Waiting for a cancellation transaction to be mined + // (wait_until_block, fee_increase_count) + CancelPending(u64, u64), } // initial state @@ -181,39 +187,40 @@ where debug!("No operations in bundle"); if fee_increase_count > 0 { - // TODO(danc): when abandoning we tend to get "stuck" where we have a pending bundle - // transaction that isn't landing. We should move to a new state where we track the - // pending transaction and "cancel" it if it doesn't land after a certain number of blocks. - - warn!("Abandoning bundle after fee increases {fee_increase_count}, no operations available, waiting for next trigger"); + warn!("Abandoning bundle after fee increases {fee_increase_count}, no operations available"); BuilderMetrics::increment_bundle_txns_abandoned( self.builder_index, self.entry_point.address(), ); - self.transaction_tracker.reset().await; send_bundle_result = Some(SendBundleResult::NoOperationsAfterFeeIncreases { attempt_number: fee_increase_count, - }) + }); + + // abandon the bundle by resetting the tracker and starting a new bundle process + // If the node we are using still has the transaction in the mempool, its + // possible we will get a `ReplacementUnderpriced` on the next iteration + // and will start a cancellation. + self.transaction_tracker.reset().await; + state = State::Building(true, 0); } else { debug!("No operations available, waiting for next trigger"); send_bundle_result = Some(SendBundleResult::NoOperationsInitially); + state = State::Building(true, 0); } - - state = State::Building(true, 0); } Ok(SendBundleAttemptResult::NonceTooLow) => { // reset the transaction tracker and try again + info!("Nonce too low, starting new bundle attempt"); self.transaction_tracker.reset().await; state = State::Building(true, 0); } Ok(SendBundleAttemptResult::ReplacementUnderpriced) => { - // TODO(danc): handle replacement underpriced - // move to the cancellation state - - // for now: reset the transaction tracker and try again + info!( + "Replacement transaction underpriced, entering cancellation loop" + ); self.transaction_tracker.reset().await; - state = State::Building(true, 0); + state = State::Cancelling(0); } Err(error) => { error!("Bundle send error {error:?}"); @@ -236,11 +243,26 @@ where TrackerUpdate::Mined { block_number, attempt_number, + gas_limit, + gas_used, tx_hash, + nonce, .. } => { // mined! info!("Bundle transaction mined"); + BuilderMetrics::process_bundle_txn_success( + self.builder_index, + self.entry_point.address(), + gas_limit, + gas_used, + ); + self.emit(BuilderEvent::transaction_mined( + self.builder_index, + tx_hash, + nonce.low_u64(), + block_number, + )); send_bundle_result = Some(SendBundleResult::Success { block_number, attempt_number, @@ -248,46 +270,156 @@ where }); state = State::Building(true, 0); } - TrackerUpdate::LatestTxDropped { .. } => { + TrackerUpdate::LatestTxDropped { nonce } => { // try again, don't wait for trigger, re-estimate fees info!("Latest transaction dropped, starting new bundle attempt"); + self.emit(BuilderEvent::latest_transaction_dropped( + self.builder_index, + nonce.low_u64(), + )); + BuilderMetrics::increment_bundle_txns_dropped( + self.builder_index, + self.entry_point.address(), + ); // force reset the transaction tracker self.transaction_tracker.reset().await; - state = State::Building(true, 0); } - TrackerUpdate::NonceUsedForOtherTx { .. } => { + TrackerUpdate::NonceUsedForOtherTx { nonce } => { // try again, don't wait for trigger, re-estimate fees info!("Nonce used externally, starting new bundle attempt"); + self.emit(BuilderEvent::nonce_used_for_other_transaction( + self.builder_index, + nonce.low_u64(), + )); + BuilderMetrics::increment_bundle_txns_nonce_used( + self.builder_index, + self.entry_point.address(), + ); state = State::Building(true, 0); } } } else if sender_trigger.last_block().block_number >= until { - if fee_increase_count >= self.settings.max_fee_increases { - // TODO(danc): same "stuck" issue here on abandonment - // this abandon is likely to lead to "transaction underpriced" errors - // Instead, move to cancellation state. + // start replacement, don't wait for trigger. Continue + // to attempt until there are no longer any UOs priced high enough + // to bundle. + info!( + "Not mined after {} blocks, increasing fees, attempt: {}", + self.settings.max_blocks_to_wait_for_mine, + fee_increase_count + 1 + ); + BuilderMetrics::increment_bundle_txn_fee_increases( + self.builder_index, + self.entry_point.address(), + ); + state = State::Building(false, fee_increase_count + 1); + } + } + State::Cancelling(fee_increase_count) => { + // cancel the transaction + info!("Cancelling last transaction"); + + let (estimated_fees, _) = self + .proposer + .estimate_gas_fees(None) + .await + .unwrap_or_default(); + + let cancel_res = self + .transaction_tracker + .cancel_transaction(self.entry_point.address(), estimated_fees) + .await; + + match cancel_res { + Ok(Some(_)) => { + info!("Cancellation transaction sent, waiting for confirmation"); + BuilderMetrics::increment_cancellation_txns_sent( + self.builder_index, + self.entry_point.address(), + ); + + state = State::CancelPending( + sender_trigger.last_block.block_number + + self.settings.max_blocks_to_wait_for_mine, + fee_increase_count, + ); + } + Ok(None) => { + info!("Soft cancellation or no transaction to cancel, starting new bundle attempt"); + BuilderMetrics::increment_soft_cancellations( + self.builder_index, + self.entry_point.address(), + ); - warn!("Abandoning bundle after max fee increases {fee_increase_count}"); - BuilderMetrics::increment_bundle_txns_abandoned( + state = State::Building(true, 0); + } + Err(TransactionTrackerError::ReplacementUnderpriced) => { + info!("Replacement transaction underpriced during cancellation, trying again"); + state = State::Cancelling(fee_increase_count + 1); + } + Err(TransactionTrackerError::NonceTooLow) => { + // reset the transaction tracker and try again + info!("Nonce too low during cancellation, starting new bundle attempt"); + self.transaction_tracker.reset().await; + state = State::Building(true, 0); + } + Err(e) => { + error!("Failed to cancel transaction, moving back to building state: {e:#?}"); + BuilderMetrics::increment_cancellation_txns_failed( self.builder_index, self.entry_point.address(), ); + state = State::Building(true, 0); + } + } + } + State::CancelPending(until, fee_increase_count) => { + sender_trigger.wait_for_block().await?; + + // check for transaction update + if let Some(update) = self.check_for_transaction_update().await { + match update { + TrackerUpdate::Mined { .. } => { + // mined + info!("Cancellation transaction mined"); + BuilderMetrics::increment_cancellation_txns_mined( + self.builder_index, + self.entry_point.address(), + ); + } + TrackerUpdate::LatestTxDropped { .. } => { + // If a cancellation gets dropped, move to bundling state as there is no + // longer a pending transaction + info!( + "Cancellation transaction dropped, starting new bundle attempt" + ); + // force reset the transaction tracker + self.transaction_tracker.reset().await; + } + TrackerUpdate::NonceUsedForOtherTx { .. } => { + // If a nonce is used externally, move to bundling state as there is no longer + // a pending transaction + info!("Nonce used externally while cancelling, starting new bundle attempt"); + } + } + + state = State::Building(true, 0); + } else if sender_trigger.last_block().block_number >= until { + if fee_increase_count >= self.settings.max_fee_increases { + // abandon the cancellation + warn!("Abandoning cancellation after max fee increases {fee_increase_count}, starting new bundle attempt"); + // force reset the transaction tracker self.transaction_tracker.reset().await; state = State::Building(true, 0); } else { // start replacement, don't wait for trigger info!( - "Not mined after {} blocks, increasing fees, attempt: {}", + "Cancellation not mined after {} blocks, increasing fees, attempt: {}", self.settings.max_blocks_to_wait_for_mine, fee_increase_count + 1 ); - BuilderMetrics::increment_bundle_txn_fee_increases( - self.builder_index, - self.entry_point.address(), - ); - state = State::Building(false, fee_increase_count + 1); + state = State::Cancelling(fee_increase_count + 1); } } } @@ -351,60 +483,25 @@ where } }; - // process update before returning match update { TrackerUpdate::Mined { tx_hash, block_number, attempt_number, - gas_limit, - gas_used, nonce, .. } => { - BuilderMetrics::increment_bundle_txns_success( - self.builder_index, - self.entry_point.address(), - ); - BuilderMetrics::set_bundle_gas_stats( - gas_limit, - gas_used, - self.builder_index, - self.entry_point.address(), - ); if attempt_number == 0 { - info!("Bundle with hash {tx_hash:?} landed in block {block_number}"); + info!("Transaction with hash {tx_hash:?}, nonce {nonce:?}, landed in block {block_number}"); } else { - info!("Bundle with hash {tx_hash:?} landed in block {block_number} after increasing gas fees {attempt_number} time(s)"); + info!("Transaction with hash {tx_hash:?}, nonce {nonce:?}, landed in block {block_number} after increasing gas fees {attempt_number} time(s)"); } - self.emit(BuilderEvent::transaction_mined( - self.builder_index, - tx_hash, - nonce.low_u64(), - block_number, - )); } TrackerUpdate::LatestTxDropped { nonce } => { - self.emit(BuilderEvent::latest_transaction_dropped( - self.builder_index, - nonce.low_u64(), - )); - BuilderMetrics::increment_bundle_txns_dropped( - self.builder_index, - self.entry_point.address(), - ); - info!("Previous transaction dropped by sender"); + info!("Previous transaction dropped by sender. Nonce: {nonce:?}"); } TrackerUpdate::NonceUsedForOtherTx { nonce } => { - self.emit(BuilderEvent::nonce_used_for_other_transaction( - self.builder_index, - nonce.low_u64(), - )); - BuilderMetrics::increment_bundle_txns_nonce_used( - self.builder_index, - self.entry_point.address(), - ); - info!("Nonce used by external transaction"); + info!("Nonce used by external transaction. Nonce: {nonce:?}"); } }; @@ -697,7 +794,8 @@ impl BundleSenderTrigger { } async fn wait_for_block(&mut self) -> anyhow::Result { - self.block_rx + self.last_block = self + .block_rx .recv() .await .ok_or_else(|| anyhow::anyhow!("Block stream closed"))?; @@ -736,8 +834,20 @@ impl BuilderMetrics { .increment(1); } - fn increment_bundle_txns_success(builder_index: u64, entry_point: Address) { + fn process_bundle_txn_success( + builder_index: u64, + entry_point: Address, + gas_limit: Option, + gas_used: Option, + ) { metrics::counter!("builder_bundle_txns_success", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(1); + + if let Some(limit) = gas_limit { + metrics::counter!("builder_bundle_gas_limit", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(limit.as_u64()); + } + if let Some(used) = gas_used { + metrics::counter!("builder_bundle_gas_used", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(used.as_u64()); + } } fn increment_bundle_txns_dropped(builder_index: u64, entry_point: Address) { @@ -766,17 +876,19 @@ impl BuilderMetrics { metrics::counter!("builder_bundle_replacement_underpriced", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(1); } - fn set_bundle_gas_stats( - gas_limit: Option, - gas_used: Option, - builder_index: u64, - entry_point: Address, - ) { - if let Some(limit) = gas_limit { - metrics::counter!("builder_bundle_gas_limit", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(limit.as_u64()); - } - if let Some(used) = gas_used { - metrics::counter!("builder_bundle_gas_used", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(used.as_u64()); - } + fn increment_cancellation_txns_sent(builder_index: u64, entry_point: Address) { + metrics::counter!("builder_cancellation_txns_sent", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(1); + } + + fn increment_cancellation_txns_mined(builder_index: u64, entry_point: Address) { + metrics::counter!("builder_cancellation_txns_mined", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(1); + } + + fn increment_soft_cancellations(builder_index: u64, entry_point: Address) { + metrics::counter!("builder_soft_cancellations", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(1); + } + + fn increment_cancellation_txns_failed(builder_index: u64, entry_point: Address) { + metrics::counter!("builder_cancellation_txns_failed", "entry_point" => entry_point.to_string(), "builder_index" => builder_index.to_string()).increment(1); } } diff --git a/crates/builder/src/sender/bloxroute.rs b/crates/builder/src/sender/bloxroute.rs index b23ea7a92..66a8807b3 100644 --- a/crates/builder/src/sender/bloxroute.rs +++ b/crates/builder/src/sender/bloxroute.rs @@ -19,6 +19,7 @@ use ethers::{ providers::{JsonRpcClient, Middleware, Provider}, types::{ transaction::eip2718::TypedTransaction, Address, Bytes, TransactionReceipt, TxHash, H256, + U256, }, utils::hex, }; @@ -28,12 +29,16 @@ use jsonrpsee::{ http_client::{transport::HttpBackend, HeaderMap, HeaderValue, HttpClient, HttpClientBuilder}, }; use rundler_sim::ExpectedStorage; +use rundler_types::GasFees; use serde::{Deserialize, Serialize}; use serde_json::value::RawValue; use tokio::time; use tonic::async_trait; -use super::{fill_and_sign, Result, SentTxInfo, TransactionSender, TxStatus}; +use super::{ + create_hard_cancel_tx, fill_and_sign, CancelTxInfo, Result, SentTxInfo, TransactionSender, + TxStatus, +}; pub(crate) struct PolygonBloxrouteTransactionSender where @@ -62,6 +67,29 @@ where Ok(SentTxInfo { nonce, tx_hash }) } + async fn cancel_transaction( + &self, + _tx_hash: H256, + nonce: U256, + to: Address, + gas_fees: GasFees, + ) -> Result { + let tx = create_hard_cancel_tx(self.provider.address(), to, nonce, gas_fees); + + let (raw_tx, _) = fill_and_sign(&self.provider, tx).await?; + + let tx_hash = self + .provider + .provider() + .request("eth_sendRawTransaction", (raw_tx,)) + .await?; + + Ok(CancelTxInfo { + tx_hash, + soft_cancelled: false, + }) + } + async fn get_transaction_status(&self, tx_hash: H256) -> Result { let tx = self .provider diff --git a/crates/builder/src/sender/conditional.rs b/crates/builder/src/sender/conditional.rs index e02f78a68..5e1ca3e35 100644 --- a/crates/builder/src/sender/conditional.rs +++ b/crates/builder/src/sender/conditional.rs @@ -17,14 +17,18 @@ use anyhow::Context; use ethers::{ middleware::SignerMiddleware, providers::{JsonRpcClient, Middleware, PendingTransaction, Provider}, - types::{transaction::eip2718::TypedTransaction, Address, TransactionReceipt, H256}, + types::{transaction::eip2718::TypedTransaction, Address, TransactionReceipt, H256, U256}, }; use ethers_signers::Signer; use rundler_sim::ExpectedStorage; +use rundler_types::GasFees; use serde_json::json; use tonic::async_trait; -use super::{fill_and_sign, Result, SentTxInfo, TransactionSender, TxStatus}; +use super::{ + create_hard_cancel_tx, fill_and_sign, CancelTxInfo, Result, SentTxInfo, TransactionSender, + TxStatus, +}; pub(crate) struct ConditionalTransactionSender where @@ -62,6 +66,29 @@ where Ok(SentTxInfo { nonce, tx_hash }) } + async fn cancel_transaction( + &self, + _tx_hash: H256, + nonce: U256, + to: Address, + gas_fees: GasFees, + ) -> Result { + let tx = create_hard_cancel_tx(self.provider.address(), to, nonce, gas_fees); + + let (raw_tx, _) = fill_and_sign(&self.provider, tx).await?; + + let tx_hash = self + .provider + .provider() + .request("eth_sendRawTransaction", (raw_tx,)) + .await?; + + Ok(CancelTxInfo { + tx_hash, + soft_cancelled: false, + }) + } + async fn get_transaction_status(&self, tx_hash: H256) -> Result { let tx = self .provider diff --git a/crates/builder/src/sender/flashbots.rs b/crates/builder/src/sender/flashbots.rs index bad8c6bf3..eecaf1144 100644 --- a/crates/builder/src/sender/flashbots.rs +++ b/crates/builder/src/sender/flashbots.rs @@ -26,8 +26,7 @@ use ethers::{ middleware::SignerMiddleware, providers::{interval, JsonRpcClient, Middleware, Provider}, types::{ - transaction::eip2718::TypedTransaction, Address, Bytes, TransactionReceipt, TxHash, H256, - U256, U64, + transaction::eip2718::TypedTransaction, Address, Bytes, TransactionReceipt, H256, U256, U64, }, utils, }; @@ -37,8 +36,9 @@ use futures_util::{Stream, StreamExt, TryFutureExt}; use pin_project::pin_project; use reqwest::{ header::{HeaderMap, HeaderValue, CONTENT_TYPE}, - Client, + Client, Response, }; +use rundler_types::GasFees; use serde::{de, Deserialize, Serialize}; use serde_json::{json, Value}; use tonic::async_trait; @@ -46,6 +46,7 @@ use tonic::async_trait; use super::{ fill_and_sign, ExpectedStorage, Result, SentTxInfo, TransactionSender, TxSenderError, TxStatus, }; +use crate::sender::CancelTxInfo; #[derive(Debug)] pub(crate) struct FlashbotsTransactionSender { @@ -75,6 +76,28 @@ where Ok(SentTxInfo { nonce, tx_hash }) } + async fn cancel_transaction( + &self, + tx_hash: H256, + _nonce: U256, + _to: Address, + _gas_fees: GasFees, + ) -> Result { + let success = self + .flashbots_client + .cancel_private_transaction(tx_hash) + .await?; + + if !success { + return Err(TxSenderError::SoftCancelFailed); + } + + Ok(CancelTxInfo { + tx_hash: H256::zero(), + soft_cancelled: true, + }) + } + async fn get_transaction_status(&self, tx_hash: H256) -> Result { let status = self.flashbots_client.status(tx_hash).await?; Ok(match status.status { @@ -181,13 +204,31 @@ struct Refund { #[derive(Serialize, Debug)] #[serde(rename_all = "camelCase")] -struct FlashbotsPrivateTransaction { +struct FlashbotsSendPrivateTransactionRequest { tx: Bytes, #[serde(skip_serializing_if = "Option::is_none")] max_block_number: Option, preferences: Preferences, } +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct FlashbotsSendPrivateTransactionResponse { + result: H256, +} + +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +struct FlashbotsCancelPrivateTransactionRequest { + tx_hash: H256, +} + +#[derive(Deserialize, Debug)] +#[serde(rename_all = "camelCase")] +struct FlashbotsCancelPrivateTransactionResponse { + result: bool, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] #[allow(dead_code)] @@ -277,7 +318,7 @@ where "jsonrpc": "2.0", "method": "eth_sendPrivateTransaction", "params": [ - FlashbotsPrivateTransaction { + FlashbotsSendPrivateTransactionRequest { tx: raw_tx, max_block_number: None, preferences, @@ -285,6 +326,37 @@ where "id": 1 }); + let response = self.sign_send_request(body).await?; + + let parsed_response = response + .json::() + .await + .map_err(|e| anyhow!("failed to deserialize Flashbots response: {:?}", e))?; + + Ok(parsed_response.result) + } + + async fn cancel_private_transaction(&self, tx_hash: H256) -> anyhow::Result { + let body = json!({ + "jsonrpc": "2.0", + "method": "eth_cancelPrivateTransaction", + "params": [ + FlashbotsCancelPrivateTransactionRequest { tx_hash } + ], + "id": 1 + }); + + let response = self.sign_send_request(body).await?; + + let parsed_response = response + .json::() + .await + .map_err(|e| anyhow!("failed to deserialize Flashbots response: {:?}", e))?; + + Ok(parsed_response.result) + } + + async fn sign_send_request(&self, body: Value) -> anyhow::Result { let signature = self .signer .sign_message(format!( @@ -302,29 +374,16 @@ where headers.insert("x-flashbots-signature", header_val); // Send the request - let response = self - .http_client + self.http_client .post(&self.relay_url) .headers(headers) .body(body.to_string()) .send() .await - .map_err(|e| anyhow!("failed to send transaction to Flashbots: {:?}", e))?; - - let parsed_response = response - .json::() - .await - .map_err(|e| anyhow!("failed to deserialize Flashbots response: {:?}", e))?; - - Ok(parsed_response.result) + .map_err(|e| anyhow!("failed to send request to Flashbots: {:?}", e)) } } -#[derive(Deserialize, Debug)] -struct FlashbotsResponse { - result: TxHash, -} - type PinBoxFut<'a, T> = Pin> + Send + 'a>>; enum PendingFlashbotsTxState<'a> { diff --git a/crates/builder/src/sender/mod.rs b/crates/builder/src/sender/mod.rs index a83d0a296..df4214a9a 100644 --- a/crates/builder/src/sender/mod.rs +++ b/crates/builder/src/sender/mod.rs @@ -26,7 +26,8 @@ use ethers::{ prelude::SignerMiddleware, providers::{JsonRpcClient, Middleware, Provider, ProviderError}, types::{ - transaction::eip2718::TypedTransaction, Address, Bytes, TransactionReceipt, H256, U256, + transaction::eip2718::TypedTransaction, Address, Bytes, Eip1559TransactionRequest, + TransactionReceipt, H256, U256, }, }; use ethers_signers::{LocalWallet, Signer}; @@ -35,12 +36,22 @@ pub(crate) use flashbots::FlashbotsTransactionSender; use mockall::automock; pub(crate) use raw::RawTransactionSender; use rundler_sim::ExpectedStorage; +use rundler_types::GasFees; + #[derive(Debug)] pub(crate) struct SentTxInfo { pub(crate) nonce: U256, pub(crate) tx_hash: H256, } +#[derive(Debug)] +pub(crate) struct CancelTxInfo { + pub(crate) tx_hash: H256, + // True if the transaction was soft-cancelled. Soft-cancellation is when the RPC endpoint + // accepts the cancel without an onchain transaction. + pub(crate) soft_cancelled: bool, +} + #[derive(Debug)] pub(crate) enum TxStatus { Pending, @@ -57,6 +68,9 @@ pub(crate) enum TxSenderError { /// Nonce too low #[error("nonce too low")] NonceTooLow, + /// Soft cancellation failed + #[error("soft cancel failed")] + SoftCancelFailed, /// All other errors #[error(transparent)] Other(#[from] anyhow::Error), @@ -74,6 +88,14 @@ pub(crate) trait TransactionSender: Send + Sync + 'static { expected_storage: &ExpectedStorage, ) -> Result; + async fn cancel_transaction( + &self, + tx_hash: H256, + nonce: U256, + to: Address, + gas_fees: GasFees, + ) -> Result; + async fn get_transaction_status(&self, tx_hash: H256) -> Result; async fn wait_until_mined(&self, tx_hash: H256) -> Result>; @@ -213,6 +235,22 @@ where Ok((tx.rlp_signed(&signature), nonce)) } +fn create_hard_cancel_tx( + from: Address, + to: Address, + nonce: U256, + gas_fees: GasFees, +) -> TypedTransaction { + Eip1559TransactionRequest::new() + .from(from) + .to(to) + .nonce(nonce) + .max_fee_per_gas(gas_fees.max_fee_per_gas) + .max_priority_fee_per_gas(gas_fees.max_priority_fee_per_gas) + .data(Bytes::new()) + .into() +} + impl From for TxSenderError { fn from(value: ProviderError) -> Self { match &value { diff --git a/crates/builder/src/sender/raw.rs b/crates/builder/src/sender/raw.rs index afaacab95..4495606e4 100644 --- a/crates/builder/src/sender/raw.rs +++ b/crates/builder/src/sender/raw.rs @@ -18,13 +18,16 @@ use async_trait::async_trait; use ethers::{ middleware::SignerMiddleware, providers::{JsonRpcClient, Middleware, PendingTransaction, Provider}, - types::{transaction::eip2718::TypedTransaction, Address, TransactionReceipt, H256}, + types::{transaction::eip2718::TypedTransaction, Address, TransactionReceipt, H256, U256}, }; use ethers_signers::Signer; use rundler_sim::ExpectedStorage; +use rundler_types::GasFees; -use super::Result; -use crate::sender::{fill_and_sign, SentTxInfo, TransactionSender, TxStatus}; +use super::{CancelTxInfo, Result}; +use crate::sender::{ + create_hard_cancel_tx, fill_and_sign, SentTxInfo, TransactionSender, TxStatus, +}; #[derive(Debug)] pub(crate) struct RawTransactionSender @@ -59,6 +62,29 @@ where Ok(SentTxInfo { nonce, tx_hash }) } + async fn cancel_transaction( + &self, + _tx_hash: H256, + nonce: U256, + to: Address, + gas_fees: GasFees, + ) -> Result { + let tx = create_hard_cancel_tx(self.provider.address(), to, nonce, gas_fees); + + let (raw_tx, _) = fill_and_sign(&self.provider, tx).await?; + + let tx_hash = self + .provider + .provider() + .request("eth_sendRawTransaction", (raw_tx,)) + .await?; + + Ok(CancelTxInfo { + tx_hash, + soft_cancelled: false, + }) + } + async fn get_transaction_status(&self, tx_hash: H256) -> Result { let tx = self .provider diff --git a/crates/builder/src/transaction_tracker.rs b/crates/builder/src/transaction_tracker.rs index b5a452615..775ec0905 100644 --- a/crates/builder/src/transaction_tracker.rs +++ b/crates/builder/src/transaction_tracker.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use anyhow::{bail, Context}; use async_trait::async_trait; -use ethers::types::{transaction::eip2718::TypedTransaction, H256, U256}; +use ethers::types::{transaction::eip2718::TypedTransaction, Address, H256, U256}; use rundler_provider::Provider; use rundler_sim::ExpectedStorage; use rundler_types::GasFees; @@ -47,6 +47,16 @@ pub(crate) trait TransactionTracker: Send + Sync + 'static { expected_stroage: &ExpectedStorage, ) -> TransactionTrackerResult; + /// Cancel the latest transaction in the tracker. + /// + /// Returns: An option containing the hash of the transaction that was used to cancel. If the option + /// is empty, then either no transaction was cancelled or the cancellation was a "soft-cancel." + async fn cancel_transaction( + &mut self, + to: Address, + estimated_fees: GasFees, + ) -> TransactionTrackerResult>; + /// Checks: /// 1. One of our transactions mines (not necessarily the one just sent). /// 2. All our send transactions have dropped. @@ -260,6 +270,54 @@ where Ok(sent_tx.tx_hash) } + async fn cancel_transaction( + &mut self, + to: Address, + estimated_fees: GasFees, + ) -> TransactionTrackerResult> { + let (tx_hash, gas_fees) = match self.transactions.last() { + Some(tx) => { + let increased_fees = tx + .gas_fees + .increase_by_percent(self.settings.replacement_fee_percent_increase); + let gas_fees = GasFees { + max_fee_per_gas: increased_fees + .max_fee_per_gas + .max(estimated_fees.max_fee_per_gas), + max_priority_fee_per_gas: increased_fees + .max_priority_fee_per_gas + .max(estimated_fees.max_priority_fee_per_gas), + }; + (tx.tx_hash, gas_fees) + } + None => (H256::zero(), estimated_fees), + }; + + let cancel_info = self + .sender + .cancel_transaction(tx_hash, self.nonce, to, gas_fees) + .await?; + + if cancel_info.soft_cancelled { + // If the transaction was soft-cancelled. Reset internal state. + self.reset().await; + return Ok(None); + } + + info!("Sent cancellation tx {:?}", cancel_info.tx_hash); + + self.transactions.push(PendingTransaction { + tx_hash: cancel_info.tx_hash, + gas_fees, + attempt_number: self.attempt_count, + }); + + self.has_dropped = false; + self.attempt_count += 1; + self.update_metrics(); + Ok(Some(cancel_info.tx_hash)) + } + async fn check_for_update(&mut self) -> TransactionTrackerResult> { let external_nonce = self.get_external_nonce().await?; if self.nonce < external_nonce { @@ -345,6 +403,9 @@ impl From for TransactionTrackerError { TxSenderError::ReplacementUnderpriced => { TransactionTrackerError::ReplacementUnderpriced } + TxSenderError::SoftCancelFailed => { + TransactionTrackerError::Other(anyhow::anyhow!("soft cancel failed")) + } TxSenderError::Other(e) => TransactionTrackerError::Other(e), } }