From 8be4c3294233e8be4c1b5abfeb0d317666064efe Mon Sep 17 00:00:00 2001 From: leovct Date: Sun, 20 Oct 2024 01:05:56 +0200 Subject: [PATCH] feat(cast): implement auto gas price adjustment for stuck transactions --- crates/cast/bin/cmd/send.rs | 282 +++++++++++++++++++++++++--------- crates/cast/bin/tx.rs | 2 +- crates/cast/tests/cli/main.rs | 224 +++++++++++++++++++++++++++ 3 files changed, 432 insertions(+), 76 deletions(-) diff --git a/crates/cast/bin/cmd/send.rs b/crates/cast/bin/cmd/send.rs index cf3582fe03a6..f661b8bc3a5d 100644 --- a/crates/cast/bin/cmd/send.rs +++ b/crates/cast/bin/cmd/send.rs @@ -1,5 +1,6 @@ use crate::tx::{self, CastTxBuilder}; use alloy_network::{AnyNetwork, EthereumWallet}; +use alloy_primitives::U256; use alloy_provider::{Provider, ProviderBuilder}; use alloy_rpc_types::TransactionRequest; use alloy_serde::WithOtherFields; @@ -19,6 +20,9 @@ use std::{path::PathBuf, str::FromStr}; /// CLI arguments for `cast send`. #[derive(Debug, Parser)] pub struct SendTxArgs { + #[command(flatten)] + eth: EthereumOpts, + /// The destination of the transaction. /// /// If not provided, you must use cast send --create. @@ -57,9 +61,6 @@ pub struct SendTxArgs { #[command(flatten)] tx: TransactionOpts, - #[command(flatten)] - eth: EthereumOpts, - /// The path of blob data to be sent. #[arg( long, @@ -69,9 +70,12 @@ pub struct SendTxArgs { help_heading = "Transaction options" )] path: Option, + + #[command(flatten)] + bump_gas_price: BumpGasPriceArgs, } -#[derive(Debug, Parser)] +#[derive(Debug, Parser, Clone)] pub enum SendTxSubcommands { /// Use to deploy raw contract bytecode. #[command(name = "--create")] @@ -87,99 +91,227 @@ pub enum SendTxSubcommands { }, } +#[derive(Debug, Parser)] +#[command(next_help_heading = "Bump gas price options")] +struct BumpGasPriceArgs { + /// Enable automatic gas price escalation for transactions. + /// + /// When set to true, automatically increase the gas price of a pending transaction. It can be + /// used to replace transactions that are stuck during busy network times. + #[arg(long, alias = "bump-fee")] + auto_bump_gas_price: bool, + + // The percentage by which to increase the gas price on each retry. + #[arg(long, default_value = "10")] + gas_price_increment_percentage: u64, + + /// The maximum total percentage increase allowed for gas price. + /// + /// This sets an upper limit on the gas price across all retry attempts, expressed as a + /// percentage of the original price. For example, a value of 150 means the gas price will + /// never exceed 150% of the original price (1.5 times the initial price). + #[arg(long, default_value = "150")] + gas_price_bump_limit_percentage: u64, + + /// The maximum number of times to bump the gas price for a transaction. + #[arg(long, default_value = "3")] + max_gas_price_bumps: u64, +} + impl SendTxArgs { #[allow(unknown_lints, dependency_on_unit_never_type_fallback)] pub async fn run(self) -> Result<(), eyre::Report> { let Self { eth, to, - mut sig, + sig, + args, cast_async, - mut args, - tx, confirmations, json: to_json, command, unlocked, - path, timeout, + tx, + path, + bump_gas_price, } = self; - let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None }; - - let code = if let Some(SendTxSubcommands::Create { - code, - sig: constructor_sig, - args: constructor_args, - }) = command - { - sig = constructor_sig; - args = constructor_args; - Some(code) - } else { - None - }; - - let config = Config::from(ð); - let provider = utils::get_provider(&config)?; - - let builder = CastTxBuilder::new(&provider, tx, &config) - .await? - .with_to(to) - .await? - .with_code_sig_and_args(code, sig, args) - .await? - .with_blob_data(blob_data)?; - - let timeout = timeout.unwrap_or(config.transaction_timeout); - - // Case 1: - // Default to sending via eth_sendTransaction if the --unlocked flag is passed. - // This should be the only way this RPC method is used as it requires a local node - // or remote RPC with unlocked accounts. - if unlocked { - // only check current chain id if it was specified in the config - if let Some(config_chain) = config.chain { - let current_chain_id = provider.get_chain_id().await?; - let config_chain_id = config_chain.id(); - // switch chain if current chain id is not the same as the one specified in the - // config - if config_chain_id != current_chain_id { - cli_warn!("Switching to chain {}", config_chain); - provider - .raw_request( - "wallet_switchEthereumChain".into(), - [serde_json::json!({ - "chainId": format!("0x{:x}", config_chain_id), - })], - ) - .await?; + const INITIAL_BASE_FEE: u64 = 1000000000; + let initial_gas_price = tx.gas_price.unwrap_or(U256::from(INITIAL_BASE_FEE)); + + let bump_amount = initial_gas_price + .saturating_mul(U256::from(bump_gas_price.gas_price_increment_percentage)) + .wrapping_div(U256::from(100)); + + let gas_price_limit = initial_gas_price + .saturating_mul(U256::from(bump_gas_price.gas_price_bump_limit_percentage)) + .wrapping_div(U256::from(100)); + + let mut current_gas_price = initial_gas_price; + let mut retry_count = 0; + loop { + let mut new_tx = tx.clone(); + new_tx.gas_price = Some(current_gas_price); + + match cast_send0( + eth.clone(), + to.clone(), + sig.clone(), + args.clone(), + cast_async, + confirmations, + to_json, + command.clone(), + unlocked, + timeout, + new_tx, + path.clone(), + ) + .await + { + Ok(_) => return Ok(()), + Err(err) => { + let is_underpriced = + err.to_string().contains("replacement transaction underpriced"); + let is_already_imported = + err.to_string().contains("transaction already imported"); + + if bump_gas_price.auto_bump_gas_price && (is_underpriced || is_already_imported) + { + if is_underpriced { + println!("Error: transaction underpriced."); + } else if is_already_imported { + println!("Error: transaction already imported."); + } + + retry_count += 1; + if retry_count > bump_gas_price.max_gas_price_bumps { + return Err(eyre::eyre!( + "Max gas price bump attempts reached. Transaction still stuck." + )); + } + + let old_gas_price = current_gas_price; + current_gas_price = + initial_gas_price + (bump_amount * U256::from(retry_count)); + + if current_gas_price >= gas_price_limit { + return Err(eyre::eyre!("Unable to bump more the gas price. Hit the limit of {}% of the original price ({} wei)", + bump_gas_price.gas_price_bump_limit_percentage, + gas_price_limit + )); + } + + if !to_json { + println!(); + println!( + "Retrying with a {}% gas price increase (attempt {}/{}).", + bump_gas_price.gas_price_increment_percentage, + retry_count, + bump_gas_price.max_gas_price_bumps + ); + println!("- Old gas price: {old_gas_price} wei"); + println!("- New gas price: {current_gas_price} wei"); + } + continue; + } + + return Err(err); } } + } + } +} - let (tx, _) = builder.build(config.sender).await?; +#[allow(clippy::too_many_arguments, dependency_on_unit_never_type_fallback)] +async fn cast_send0( + eth: EthereumOpts, + to: Option, + mut sig: Option, + mut args: Vec, + cast_async: bool, + confirmations: u64, + to_json: bool, + command: Option, + unlocked: bool, + timeout: Option, + tx: TransactionOpts, + path: Option, +) -> Result<()> { + let blob_data = if let Some(path) = path { Some(std::fs::read(path)?) } else { None }; + + let code = if let Some(SendTxSubcommands::Create { + code, + sig: constructor_sig, + args: constructor_args, + }) = command + { + sig = constructor_sig; + args = constructor_args; + Some(code) + } else { + None + }; + + let config = Config::from(ð); + let provider = utils::get_provider(&config)?; + + let builder = CastTxBuilder::new(&provider, tx, &config) + .await? + .with_to(to) + .await? + .with_code_sig_and_args(code, sig, args) + .await? + .with_blob_data(blob_data)?; + + let timeout = timeout.unwrap_or(config.transaction_timeout); + + // Case 1: + // Default to sending via eth_sendTransaction if the --unlocked flag is passed. + // This should be the only way this RPC method is used as it requires a local node + // or remote RPC with unlocked accounts. + if unlocked { + // Only check current chain id if it was specified in the config. + if let Some(config_chain) = config.chain { + let current_chain_id = provider.get_chain_id().await?; + let config_chain_id = config_chain.id(); + // Switch chain if current chain id is not the same as the one specified in the + // config. + if config_chain_id != current_chain_id { + cli_warn!("Switching to chain {}", config_chain); + provider + .raw_request( + "wallet_switchEthereumChain".into(), + [serde_json::json!({ + "chainId": format!("0x{:x}", config_chain_id), + })], + ) + .await?; + } + } + + let (tx, _) = builder.build(config.sender).await?; - cast_send(provider, tx, cast_async, confirmations, timeout, to_json).await - // Case 2: - // An option to use a local signer was provided. - // If we cannot successfully instantiate a local signer, then we will assume we don't have - // enough information to sign and we must bail. - } else { - // Retrieve the signer, and bail if it can't be constructed. - let signer = eth.wallet.signer().await?; - let from = signer.address(); + cast_send(provider, tx, cast_async, confirmations, timeout, to_json).await + // Case 2: + // An option to use a local signer was provided. + // If we cannot successfully instantiate a local signer, then we will assume we don't have + // enough information to sign and we must bail. + } else { + // Retrieve the signer, and bail if it can't be constructed. + let signer = eth.wallet.signer().await?; + let from = signer.address(); - tx::validate_from_address(eth.wallet.from, from)?; + tx::validate_from_address(eth.wallet.from, from)?; - let (tx, _) = builder.build(&signer).await?; + let (tx, _) = builder.build(&signer).await?; - let wallet = EthereumWallet::from(signer); - let provider = ProviderBuilder::<_, _, AnyNetwork>::default() - .wallet(wallet) - .on_provider(&provider); + let wallet = EthereumWallet::from(signer); + let provider = + ProviderBuilder::<_, _, AnyNetwork>::default().wallet(wallet).on_provider(&provider); - cast_send(provider, tx, cast_async, confirmations, timeout, to_json).await - } + cast_send(provider, tx, cast_async, confirmations, timeout, to_json).await } } diff --git a/crates/cast/bin/tx.rs b/crates/cast/bin/tx.rs index 88b24a6e33c9..f7b20fedde7c 100644 --- a/crates/cast/bin/tx.rs +++ b/crates/cast/bin/tx.rs @@ -281,7 +281,7 @@ where P: Provider, T: Transport + Clone, { - /// Builds [TransactionRequest] and fiils missing fields. Returns a transaction which is ready + /// Builds [TransactionRequest] and fills missing fields. Returns a transaction which is ready /// to be broadcasted. pub async fn build( self, diff --git a/crates/cast/tests/cli/main.rs b/crates/cast/tests/cli/main.rs index 98fc9825e3be..0f80e5c00b9f 100644 --- a/crates/cast/tests/cli/main.rs +++ b/crates/cast/tests/cli/main.rs @@ -1351,6 +1351,230 @@ casttest!(send_eip7702, async |_prj, cmd| { "#]]); }); +casttest!(send_bump_gas_price, async |_prj, cmd| { + // Create a dummy anvil node that won't mine transaction. + // The goal is to simulate stuck transactions in the pool. + let (_api, handle) = anvil::spawn(NodeConfig::test().with_no_mining(true)).await; + let endpoint = handle.http_endpoint(); + + // Send a tx with a gas price of 1200000000 wei. + cmd.args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--gas-price", + "1200000000", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x4e210ed66dcf63734e7db65c6e250e6cecc7f506d937a194d6973f5a58c0a2d6 + +"#]]); + + // Now try to replace the stuck transaction. + // This will not work since the gas price specified is lower than the original gas price. + cmd.cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--gas-price", + "1100000000", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_failure() + .stderr_eq(str![[r#" +Error: +server returned an error response: error code -32003: replacement transaction underpriced + +"#]]); + + // Replace the stuck transaction by specifying the `--bump-fee` flag. + cmd.cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--bump-fee", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq(str![[r#" +Error: transaction underpriced. + +Retrying with a 10% gas price increase (attempt 1/3). +- Old gas price: 1000000000 wei +- New gas price: 1100000000 wei +Error: transaction underpriced. + +Retrying with a 10% gas price increase (attempt 2/3). +- Old gas price: 1100000000 wei +- New gas price: 1200000000 wei +Error: transaction already imported. + +Retrying with a 10% gas price increase (attempt 3/3). +- Old gas price: 1200000000 wei +- New gas price: 1300000000 wei +0x8da0c415e090f780cff122e9aaa2655dc532daf828da1b617e4841198a74b85b + +"#]]); +}); + +casttest!(send_bump_gas_price_max_attempts, async |_prj, cmd| { + // Create a dummy anvil node that won't mine transaction. + // The goal is to simulate stuck transactions in the pool. + let (_api, handle) = anvil::spawn(NodeConfig::test().with_no_mining(true)).await; + let endpoint = handle.http_endpoint(); + + // Send a tx with a gas price of 2000000000 wei. + cmd.args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--gas-price", + "2000000000", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq(str![[r#" +0xfe1c1e10784315245b7a409fee421a72e07740f7662d0cde2d3bdb79eca5666f + +"#]]); + + // Try to replace the stuck transaction by specifying the `--bump-fee` flag. + // Since it will incrementally bump the gas price by 10% with a maximum of 3 bumps, it won't + // be able to replace the stuck transaction, and it should reach the max bump retry limit. + cmd.cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--bump-fee", + "--gas-price-increment-percentage", + "10", + "--max-gas-price-bumps", + "3", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_failure() + .stdout_eq(str![[r#" +Error: transaction underpriced. + +Retrying with a 10% gas price increase (attempt 1/3). +- Old gas price: 1000000000 wei +- New gas price: 1100000000 wei +Error: transaction underpriced. + +Retrying with a 10% gas price increase (attempt 2/3). +- Old gas price: 1100000000 wei +- New gas price: 1200000000 wei +Error: transaction underpriced. + +Retrying with a 10% gas price increase (attempt 3/3). +- Old gas price: 1200000000 wei +- New gas price: 1300000000 wei +Error: transaction underpriced. + +"#]]) + .stderr_eq(str![[r#" +Error: +Max gas price bump attempts reached. Transaction still stuck. + +"#]]); +}); + +casttest!(send_bump_gas_price_limit, async |_prj, cmd| { + // Create a dummy anvil node that won't mine transaction. + // The goal is to simulate stuck transactions in the pool. + let (_api, handle) = anvil::spawn(NodeConfig::test().with_no_mining(true)).await; + let endpoint = handle.http_endpoint(); + + // Send a tx with a gas price of 2000000000 wei. + cmd.args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--gas-price", + "1200000000", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_success() + .stdout_eq(str![[r#" +0x4e210ed66dcf63734e7db65c6e250e6cecc7f506d937a194d6973f5a58c0a2d6 + +"#]]); + + // Try to replace the stuck transaction by specifying the `--bump-fee` flag. + // The gas price bump limit percentage is set to 120% which means the maximum gas price bump + // allowed is 1440000000 wei = 1200000000 wei * 120%. Since the gas is bumped by 10% each time, + // it should hit the gas price bump limit on the second retry. + cmd.cast_fuse() + .args([ + "send", + "--private-key", + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80", + "--rpc-url", + &endpoint, + "--value", + "0.001ether", + "--bump-fee", + "--gas-price-increment-percentage", + "10", + "--max-gas-price-bumps", + "3", + "--gas-price-bump-limit-percentage", + "120", + "--async", + "0x0000000000000000000000000000000000000000", + ]) + .assert_failure() + .stdout_eq(str![[r#" +Error: transaction underpriced. + +Retrying with a 10% gas price increase (attempt 1/3). +- Old gas price: 1000000000 wei +- New gas price: 1100000000 wei +Error: transaction underpriced. + +"#]]) + .stderr_eq(str![[r#" +Error: +Unable to bump more the gas price. Hit the limit of 120% of the original price (1200000000 wei) + +"#]]); +}); + casttest!(hash_message, |_prj, cmd| { cmd.args(["hash-message", "hello"]).assert_success().stdout_eq(str![[r#" 0x50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750