use serde::Serialize;
use serde_json::Value;
use crate::error::TransportError;
use crate::gas::{compute_gas_recommendation, GasSpeed};
use crate::request::JsonRpcRequest;
use crate::transport::RpcTransport;
use crate::tx::{TrackedTx, TxStatus, TxTracker};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum BumpStrategy {
Percentage(u32),
SpeedTier(GasSpeed),
Fixed { max_fee: u64, max_priority_fee: u64 },
Double,
Cancel,
}
impl Default for BumpStrategy {
fn default() -> Self {
Self::Percentage(1200) }
}
#[derive(Debug, Clone)]
pub struct BumpConfig {
pub min_bump_bps: u32,
pub max_gas_price: u64,
pub max_bumps: u32,
}
impl Default for BumpConfig {
fn default() -> Self {
Self {
min_bump_bps: 1000, max_gas_price: 500_000_000_000, max_bumps: 5,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct BumpResult {
pub original_hash: String,
pub new_max_fee: u64,
pub new_max_priority_fee: u64,
pub new_gas_price: Option<u64>,
pub bump_count: u32,
pub strategy_used: String,
}
pub fn compute_bump(
tx: &TrackedTx,
strategy: BumpStrategy,
config: &BumpConfig,
bump_count: u32,
current_base_fee: Option<u128>,
priority_fee_samples: &[u128],
) -> Result<BumpResult, TransportError> {
if tx.status != TxStatus::Pending {
return Err(TransportError::Other(
"can only bump pending transactions".into(),
));
}
if bump_count >= config.max_bumps {
return Err(TransportError::Other(format!(
"max bumps ({}) exceeded",
config.max_bumps
)));
}
let current_max_fee = tx.max_fee.unwrap_or(tx.gas_price.unwrap_or(0));
let current_priority = tx.max_priority_fee.unwrap_or(0);
let (new_max_fee, new_priority_fee) = match strategy {
BumpStrategy::Percentage(bps) => {
let effective_bps = bps.max(config.min_bump_bps);
let bump_multiplier = 10000 + effective_bps as u64;
(
current_max_fee * bump_multiplier / 10000,
current_priority * bump_multiplier / 10000,
)
}
BumpStrategy::Double => (current_max_fee * 2, current_priority * 2),
BumpStrategy::Fixed {
max_fee,
max_priority_fee,
} => {
let min_max_fee = current_max_fee * (10000 + config.min_bump_bps as u64) / 10000;
let min_priority = current_priority * (10000 + config.min_bump_bps as u64) / 10000;
(max_fee.max(min_max_fee), max_priority_fee.max(min_priority))
}
BumpStrategy::SpeedTier(speed) => {
if let Some(base_fee) = current_base_fee {
let rec = compute_gas_recommendation(base_fee, priority_fee_samples, 0);
let estimate = match speed {
GasSpeed::Slow => &rec.slow,
GasSpeed::Standard => &rec.standard,
GasSpeed::Fast => &rec.fast,
GasSpeed::Urgent => &rec.urgent,
};
let new_fee = estimate.max_fee_per_gas as u64;
let new_tip = estimate.max_priority_fee_per_gas as u64;
let min_fee = current_max_fee * (10000 + config.min_bump_bps as u64) / 10000;
let min_tip = current_priority * (10000 + config.min_bump_bps as u64) / 10000;
(new_fee.max(min_fee), new_tip.max(min_tip))
} else {
let bps = config.min_bump_bps;
let mult = 10000 + bps as u64;
(
current_max_fee * mult / 10000,
current_priority * mult / 10000,
)
}
}
BumpStrategy::Cancel => {
let mult = 10000 + config.min_bump_bps as u64;
(
current_max_fee * mult / 10000,
current_priority * mult / 10000,
)
}
};
let new_priority_fee = if new_priority_fee == 0 {
1_000_000_000
} else {
new_priority_fee
};
if new_max_fee > config.max_gas_price {
return Err(TransportError::Other(format!(
"bumped gas {} exceeds cap {}",
new_max_fee, config.max_gas_price
)));
}
Ok(BumpResult {
original_hash: tx.tx_hash.clone(),
new_max_fee,
new_max_priority_fee: new_priority_fee,
new_gas_price: if tx.gas_price.is_some() {
Some(new_max_fee)
} else {
None
},
bump_count: bump_count + 1,
strategy_used: format!("{:?}", strategy),
})
}
#[allow(clippy::too_many_arguments)]
pub async fn bump_and_send<F>(
transport: &dyn RpcTransport,
tracker: &TxTracker,
tx_hash: &str,
strategy: BumpStrategy,
config: &BumpConfig,
bump_count: u32,
current_base_fee: Option<u128>,
priority_fee_samples: &[u128],
raw_tx_builder: F,
) -> Result<BumpResult, TransportError>
where
F: FnOnce(u64, u64, u64) -> String, {
let tx = tracker
.get(tx_hash)
.ok_or_else(|| TransportError::Other(format!("transaction {tx_hash} not tracked")))?;
let bump = compute_bump(
&tx,
strategy,
config,
bump_count,
current_base_fee,
priority_fee_samples,
)?;
let raw_tx = raw_tx_builder(tx.nonce, bump.new_max_fee, bump.new_max_priority_fee);
let req = JsonRpcRequest::auto("eth_sendRawTransaction", vec![Value::String(raw_tx)]);
let resp = transport.send(req).await?;
let result = resp.into_result().map_err(TransportError::Rpc)?;
let new_hash = result
.as_str()
.ok_or_else(|| {
TransportError::Other("eth_sendRawTransaction did not return a hash".into())
})?
.to_string();
tracker.update_status(
tx_hash,
TxStatus::Replaced {
replacement_hash: new_hash.clone(),
},
);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
tracker.track(TrackedTx {
tx_hash: new_hash,
from: tx.from.clone(),
nonce: tx.nonce,
submitted_at: now,
status: TxStatus::Pending,
gas_price: bump.new_gas_price,
max_fee: Some(bump.new_max_fee),
max_priority_fee: Some(bump.new_max_priority_fee),
last_checked: now,
});
Ok(bump)
}
pub fn compute_cancel(
tx: &TrackedTx,
config: &BumpConfig,
bump_count: u32,
) -> Result<BumpResult, TransportError> {
compute_bump(tx, BumpStrategy::Cancel, config, bump_count, None, &[])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::request::{JsonRpcResponse, RpcId};
use crate::tx::TxTrackerConfig;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::Mutex;
fn pending_eip1559_tx(hash: &str, max_fee: u64, priority_fee: u64) -> TrackedTx {
TrackedTx {
tx_hash: hash.to_string(),
from: "0xAlice".to_string(),
nonce: 7,
submitted_at: 1000,
status: TxStatus::Pending,
gas_price: None,
max_fee: Some(max_fee),
max_priority_fee: Some(priority_fee),
last_checked: 1000,
}
}
fn pending_legacy_tx(hash: &str, gas_price: u64) -> TrackedTx {
TrackedTx {
tx_hash: hash.to_string(),
from: "0xBob".to_string(),
nonce: 3,
submitted_at: 1000,
status: TxStatus::Pending,
gas_price: Some(gas_price),
max_fee: None,
max_priority_fee: None,
last_checked: 1000,
}
}
struct MockTransport {
responses: Mutex<HashMap<String, Value>>,
}
impl MockTransport {
fn new() -> Self {
Self {
responses: Mutex::new(HashMap::new()),
}
}
fn set_response(&self, method: &str, value: Value) {
let mut map = self.responses.lock().unwrap();
map.insert(method.to_string(), value);
}
}
#[async_trait]
impl RpcTransport for MockTransport {
async fn send(&self, req: JsonRpcRequest) -> Result<JsonRpcResponse, TransportError> {
let map = self.responses.lock().unwrap();
let result = map.get(&req.method).cloned().unwrap_or(Value::Null);
Ok(JsonRpcResponse {
jsonrpc: "2.0".into(),
id: RpcId::Number(1),
result: Some(result),
error: None,
})
}
fn url(&self) -> &str {
"mock://gas_bumper"
}
}
#[test]
fn bump_percentage_default() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig::default();
let result = compute_bump(&tx, BumpStrategy::default(), &config, 0, None, &[])
.expect("should succeed");
assert_eq!(result.new_max_fee, 112_000_000_000);
assert_eq!(result.new_max_priority_fee, 2_240_000_000);
assert_eq!(result.bump_count, 1);
assert_eq!(result.original_hash, "0xabc");
assert!(result.new_gas_price.is_none()); }
#[test]
fn bump_double() {
let tx = pending_eip1559_tx("0xabc", 50_000_000_000, 1_000_000_000);
let config = BumpConfig::default();
let result =
compute_bump(&tx, BumpStrategy::Double, &config, 0, None, &[]).expect("should succeed");
assert_eq!(result.new_max_fee, 100_000_000_000);
assert_eq!(result.new_max_priority_fee, 2_000_000_000);
assert!(result.strategy_used.contains("Double"));
}
#[test]
fn bump_fixed_enforces_minimum() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig::default();
let result = compute_bump(
&tx,
BumpStrategy::Fixed {
max_fee: 105_000_000_000, max_priority_fee: 2_100_000_000, },
&config,
0,
None,
&[],
)
.expect("should succeed");
assert_eq!(result.new_max_fee, 110_000_000_000);
assert_eq!(result.new_max_priority_fee, 2_200_000_000);
}
#[test]
fn bump_fixed_uses_higher_value_when_above_minimum() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig::default();
let result = compute_bump(
&tx,
BumpStrategy::Fixed {
max_fee: 200_000_000_000, max_priority_fee: 5_000_000_000, },
&config,
0,
None,
&[],
)
.expect("should succeed");
assert_eq!(result.new_max_fee, 200_000_000_000);
assert_eq!(result.new_max_priority_fee, 5_000_000_000);
}
#[test]
fn bump_cancel_minimum() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig::default();
let result =
compute_bump(&tx, BumpStrategy::Cancel, &config, 0, None, &[]).expect("should succeed");
assert_eq!(result.new_max_fee, 110_000_000_000);
assert_eq!(result.new_max_priority_fee, 2_200_000_000);
assert!(result.strategy_used.contains("Cancel"));
}
#[test]
fn bump_exceeds_cap() {
let tx = pending_eip1559_tx("0xabc", 400_000_000_000, 2_000_000_000);
let config = BumpConfig {
max_gas_price: 500_000_000_000, ..Default::default()
};
let err =
compute_bump(&tx, BumpStrategy::Percentage(3000), &config, 0, None, &[]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("exceeds cap"));
}
#[test]
fn bump_max_bumps_exceeded() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig {
max_bumps: 3,
..Default::default()
};
let err = compute_bump(&tx, BumpStrategy::default(), &config, 3, None, &[]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("max bumps"));
}
#[test]
fn bump_non_pending_fails() {
let mut tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
tx.status = TxStatus::Included {
block_number: 42,
block_hash: "0xblock".to_string(),
};
let config = BumpConfig::default();
let err = compute_bump(&tx, BumpStrategy::default(), &config, 0, None, &[]).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("pending"));
}
#[tokio::test]
async fn bump_and_send_updates_tracker() {
let transport = MockTransport::new();
transport.set_response("eth_sendRawTransaction", Value::String("0xnew_hash".into()));
let tracker = TxTracker::new(TxTrackerConfig::default());
let tx = pending_eip1559_tx("0xoriginal", 100_000_000_000, 2_000_000_000);
tracker.track(tx);
let result = bump_and_send(
&transport,
&tracker,
"0xoriginal",
BumpStrategy::default(),
&BumpConfig::default(),
0,
None,
&[],
|_nonce, _max_fee, _priority_fee| "0xsigned_raw_tx".to_string(),
)
.await
.expect("bump_and_send should succeed");
assert_eq!(result.original_hash, "0xoriginal");
assert_eq!(result.bump_count, 1);
let original = tracker.get("0xoriginal").expect("original should exist");
assert_eq!(
original.status,
TxStatus::Replaced {
replacement_hash: "0xnew_hash".to_string()
}
);
let new_tx = tracker.get("0xnew_hash").expect("new tx should be tracked");
assert_eq!(new_tx.status, TxStatus::Pending);
assert_eq!(new_tx.nonce, 7); assert_eq!(new_tx.max_fee, Some(result.new_max_fee));
assert_eq!(new_tx.max_priority_fee, Some(result.new_max_priority_fee));
}
#[test]
fn bump_speed_tier_uses_recommendation() {
let tx = pending_eip1559_tx("0xabc", 10_000_000_000, 1_000_000_000);
let config = BumpConfig::default();
let base_fee: u128 = 30_000_000_000; let mut samples: Vec<u128> = (1..=100)
.map(|i| i * 100_000_000) .collect();
samples.sort();
let result = compute_bump(
&tx,
BumpStrategy::SpeedTier(GasSpeed::Urgent),
&config,
0,
Some(base_fee),
&samples,
)
.expect("should succeed");
assert!(result.new_max_fee > 10_000_000_000);
assert!(result.new_max_priority_fee > 0);
assert!(result.strategy_used.contains("SpeedTier"));
}
#[test]
fn bump_speed_tier_fallback_without_base_fee() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig::default();
let result = compute_bump(
&tx,
BumpStrategy::SpeedTier(GasSpeed::Fast),
&config,
0,
None, &[],
)
.expect("should succeed");
assert_eq!(result.new_max_fee, 110_000_000_000);
assert_eq!(result.new_max_priority_fee, 2_200_000_000);
}
#[test]
fn compute_cancel_works() {
let tx = pending_eip1559_tx("0xabc", 100_000_000_000, 2_000_000_000);
let config = BumpConfig::default();
let result = compute_cancel(&tx, &config, 0).expect("should succeed");
assert_eq!(result.new_max_fee, 110_000_000_000);
assert_eq!(result.new_max_priority_fee, 2_200_000_000);
assert!(result.strategy_used.contains("Cancel"));
assert_eq!(result.bump_count, 1);
}
#[test]
fn bump_legacy_tx_sets_gas_price() {
let tx = pending_legacy_tx("0xlegacy", 20_000_000_000);
let config = BumpConfig::default();
let result = compute_bump(&tx, BumpStrategy::default(), &config, 0, None, &[])
.expect("should succeed");
assert!(result.new_gas_price.is_some());
assert_eq!(result.new_gas_price.unwrap(), result.new_max_fee);
assert_eq!(result.new_max_fee, 22_400_000_000);
}
#[test]
fn bump_zero_priority_gets_minimum() {
let mut tx = pending_eip1559_tx("0xabc", 100_000_000_000, 0);
tx.max_priority_fee = Some(0);
let config = BumpConfig::default();
let result = compute_bump(&tx, BumpStrategy::default(), &config, 0, None, &[])
.expect("should succeed");
assert_eq!(result.new_max_priority_fee, 1_000_000_000);
}
#[tokio::test]
async fn bump_and_send_untracked_tx_fails() {
let transport = MockTransport::new();
let tracker = TxTracker::new(TxTrackerConfig::default());
let err = bump_and_send(
&transport,
&tracker,
"0xunknown",
BumpStrategy::default(),
&BumpConfig::default(),
0,
None,
&[],
|_nonce, _max_fee, _priority_fee| "0xraw".to_string(),
)
.await
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("not tracked"));
}
}