Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cast): implement auto gas price adjustment for stuck transactions in cast send #9147

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c3f1206
feat(cast): implement auto gas price adjustment for stuck transactions
leovct Oct 19, 2024
640042d
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 19, 2024
1525724
fix: test json mode
leovct Oct 20, 2024
1454cc6
chore: move `cast send` tests
leovct Oct 20, 2024
650a857
chore: rename methods
leovct Oct 20, 2024
62f07a4
fix: rustfmt
leovct Oct 20, 2024
2649c83
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 21, 2024
17152de
chore: address comments
leovct Oct 22, 2024
fe75a0b
chore: fetch initial base fee from provider and update bump gas limit…
leovct Oct 22, 2024
56d9be0
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 22, 2024
e01ecf8
chore: ensure there are pending txs before attempting to bump the gas…
leovct Oct 22, 2024
b124420
docs: nit
leovct Oct 22, 2024
c345567
chore: nit
leovct Oct 22, 2024
96bbea7
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 23, 2024
71f4987
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 23, 2024
bcdb04c
fix: tests
leovct Oct 23, 2024
4564536
chore: nit
leovct Oct 23, 2024
418db33
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 23, 2024
d04b2dd
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 24, 2024
363af11
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 24, 2024
798f768
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 25, 2024
5baabae
chore: use `provider.get_fee_history()` to retrieve the base fee
leovct Oct 25, 2024
942727c
chore: set `tx.nonce`
leovct Oct 25, 2024
6029c14
chore: nit
leovct Oct 25, 2024
ec2c43a
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Oct 25, 2024
5ff1422
Merge branch 'master' into feat/cast-send-bump-gas-price-stuck-txs
leovct Nov 4, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
292 changes: 217 additions & 75 deletions crates/cast/bin/cmd/send.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
use crate::tx::{self, CastTxBuilder};
use crate::tx::{self, CastTxBuilder, SenderKind};
use alloy_network::{AnyNetwork, EthereumWallet};
use alloy_primitives::U256;
use alloy_provider::{Provider, ProviderBuilder};
use alloy_rpc_types::TransactionRequest;
use alloy_rpc_types::{BlockId, TransactionRequest};
use alloy_serde::WithOtherFields;
use alloy_signer::Signer;
use alloy_transport::Transport;
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -69,9 +70,12 @@ pub struct SendTxArgs {
help_heading = "Transaction options"
)]
path: Option<PathBuf>,

#[command(flatten)]
bump_gas_price: BumpGasPriceArgs,
}

#[derive(Debug, Parser)]
#[derive(Clone, Debug, Parser)]
pub enum SendTxSubcommands {
/// Use to deploy raw contract bytecode.
#[command(name = "--create")]
Expand All @@ -87,103 +91,241 @@ 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/stuck transaction.
#[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 allowed gas price during retries, in wei.
#[arg(long, default_value = "3000000000")]
gas_price_bump_limit: u64,

leovct marked this conversation as resolved.
Show resolved Hide resolved
/// 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 {
leovct marked this conversation as resolved.
Show resolved Hide resolved
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(&eth);
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 {
sh_warn!("Switching to chain {}", config_chain)?;
provider
.raw_request(
"wallet_switchEthereumChain".into(),
[serde_json::json!({
"chainId": format!("0x{:x}", config_chain_id),
})],
)
.await?;
}
// Ensure there are pending transactions before attempting to bump the gas price.
if bump_gas_price.auto_bump_gas_price {
let sender = SenderKind::from_wallet_opts(eth.wallet.clone()).await?;
let from = sender.address();
let nonce = provider.get_transaction_count(from).await.unwrap();
let pending_nonce =
provider.get_transaction_count(from).block_id(BlockId::pending()).await.unwrap();
if nonce == pending_nonce {
return Err(eyre::eyre!("No pending transactions to replace."));
}
leovct marked this conversation as resolved.
Show resolved Hide resolved
}

let (tx, _) = builder.build(config.sender).await?;
let eip1559_est = provider.estimate_eip1559_fees(None).await.unwrap();
let base_fee = eip1559_est.max_fee_per_gas - eip1559_est.max_priority_fee_per_gas;
let initial_gas_price = tx.gas_price.unwrap_or(U256::from(base_fee));

leovct marked this conversation as resolved.
Show resolved Hide resolved
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 = U256::from(bump_gas_price.gas_price_bump_limit);

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);

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure what's the best strategy here, but wondering if we should also bump the priority fee somehow?

let Err(err) = prepare_and_send_transaction(
eth.clone(),
to.clone(),
sig.clone(),
args.clone(),
cast_async,
confirmations,
to_json,
command.clone(),
unlocked,
timeout,
new_tx,
path.clone(),
)
.await
else {
return Ok(())
};

let is_underpriced = err.to_string().contains("replacement transaction underpriced");
let is_already_imported = err.to_string().contains("transaction already imported");

if !(is_underpriced || is_already_imported) {
return Err(err);
}

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();
if bump_gas_price.auto_bump_gas_price {
if !to_json {
if is_underpriced {
println!("Error: transaction underpriced.");
} else if is_already_imported {
println!("Error: transaction already imported.");
}
}

tx::validate_from_address(eth.wallet.from, from)?;
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 (tx, _) = builder.build(&signer).await?;
let old_gas_price = current_gas_price;
current_gas_price = initial_gas_price + (bump_amount * U256::from(retry_count));

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");
}

let wallet = EthereumWallet::from(signer);
let provider = ProviderBuilder::<_, _, AnyNetwork>::default()
.wallet(wallet)
.on_provider(&provider);
if current_gas_price >= gas_price_limit {
return Err(eyre::eyre!(
"Unable to bump more the gas price. Hit the bump gas limit of {} wei.",
gas_price_limit
));
}

cast_send(provider, tx, cast_async, confirmations, timeout, to_json).await
continue;
}

return Err(err);
}
}
}

#[allow(clippy::too_many_arguments, dependency_on_unit_never_type_fallback)]
async fn prepare_and_send_transaction(
eth: EthereumOpts,
to: Option<NameOrAddress>,
mut sig: Option<String>,
mut args: Vec<String>,
cast_async: bool,
confirmations: u64,
to_json: bool,
command: Option<SendTxSubcommands>,
unlocked: bool,
timeout: Option<u64>,
tx: TransactionOpts,
path: Option<PathBuf>,
) -> 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(&eth);
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 {
sh_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?;

send_and_monitor_transaction(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)?;

let (tx, _) = builder.build(&signer).await?;

let wallet = EthereumWallet::from(signer);
let provider =
ProviderBuilder::<_, _, AnyNetwork>::default().wallet(wallet).on_provider(&provider);

send_and_monitor_transaction(provider, tx, cast_async, confirmations, timeout, to_json)
.await
}
}

async fn cast_send<P: Provider<T, AnyNetwork>, T: Transport + Clone>(
async fn send_and_monitor_transaction<P: Provider<T, AnyNetwork>, T: Transport + Clone>(
provider: P,
tx: WithOtherFields<TransactionRequest>,
cast_async: bool,
Expand Down
2 changes: 1 addition & 1 deletion crates/cast/bin/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,7 @@ where
P: Provider<T, AnyNetwork>,
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,
Expand Down
Loading
Loading