use crate::error::ChainIoError;
use std::time::Duration;
use tokio_util::sync::CancellationToken;
use tracing::{error, info, warn};
#[async_trait::async_trait]
pub trait NonceCanceller: Sync {
async fn broadcast_cancel(
&self,
nonce: u64,
prev_max_fee: u128,
prev_priority_fee: u128,
bump_percent: u32,
) -> Result<CancelBroadcast, ChainIoError>;
async fn await_receipt(&self, tx_hash: alloy::primitives::B256, timeout: Duration) -> Result<(), ChainIoError>;
async fn latest_mined_nonce(&self) -> Result<u64, ChainIoError>;
async fn resync_allocator(&self);
}
#[derive(Debug, Clone, Copy)]
pub struct CancelBroadcast {
pub tx_hash: alloy::primitives::B256,
pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CancelOutcome {
Resolved,
Shutdown,
}
#[derive(Debug, Clone, Copy)]
pub struct CancelParams {
pub bump_percent: u32,
pub confirm_timeout: Duration,
pub retry_backoff: Duration,
}
impl Default for CancelParams {
fn default() -> Self {
Self {
bump_percent: 20,
confirm_timeout: Duration::from_secs(60),
retry_backoff: Duration::from_secs(2),
}
}
}
pub async fn cancel_until_resolved<C: NonceCanceller + ?Sized>(
canceller: &C,
chain_id: u64,
nonce: u64,
seed_max_fee: u128,
seed_priority: u128,
params: CancelParams,
cancel_token: Option<&CancellationToken>,
) -> CancelOutcome {
let is_cancelled = || cancel_token.is_some_and(|t| t.is_cancelled());
let mut prev_max_fee = seed_max_fee;
let mut prev_priority = seed_priority;
let broadcast = loop {
if is_cancelled() {
return CancelOutcome::Shutdown;
}
match canceller
.broadcast_cancel(nonce, prev_max_fee, prev_priority, params.bump_percent)
.await
{
Ok(b) => break b,
Err(e) if e.is_nonce_consumed() => {
info!(
nonce,
"cancel rejected nonce-too-low — slot already consumed on-chain, resolved"
);
return CancelOutcome::Resolved;
}
Err(e) => {
newton_metric::inc_batch_nonce_cancel_failures(chain_id);
error!(
error = %e,
nonce,
"NONCE CANCEL BROADCAST FAILED — signer may be out of funds; retrying until it clears (alert on nonce_cancel_failures_total)"
);
if !e.is_insufficient_funds() {
prev_max_fee = bump(prev_max_fee);
prev_priority = bump(prev_priority);
}
sleep_or_cancel(params.retry_backoff, cancel_token).await;
}
}
};
newton_metric::inc_batch_nonce_cancels(chain_id);
let mut tx_hash = broadcast.tx_hash;
prev_max_fee = broadcast.max_fee_per_gas;
prev_priority = broadcast.max_priority_fee_per_gas;
let mut unaffordable = false;
loop {
if is_cancelled() {
return CancelOutcome::Shutdown;
}
let poll = match cancel_token {
Some(token) => tokio::select! {
biased;
_ = token.cancelled() => return CancelOutcome::Shutdown,
r = canceller.await_receipt(tx_hash, params.confirm_timeout) => r,
},
None => canceller.await_receipt(tx_hash, params.confirm_timeout).await,
};
match poll {
Ok(()) => return CancelOutcome::Resolved, Err(e) => {
let was_timeout = matches!(e, ChainIoError::TransactionTimeout { .. });
if !was_timeout {
sleep_or_cancel(params.retry_backoff, cancel_token).await;
} else {
if let Ok(latest) = canceller.latest_mined_nonce().await {
let gap = nonce.saturating_sub(latest);
newton_metric::set_batch_signer_nonce_gap(chain_id, gap);
if gap > 0 {
warn!(
nonce,
latest, gap, "cancel stalled with chain blocked from below; resyncing allocator"
);
canceller.resync_allocator().await;
}
}
}
if !unaffordable {
prev_max_fee = bump(prev_max_fee);
prev_priority = bump(prev_priority);
}
match canceller
.broadcast_cancel(nonce, prev_max_fee, prev_priority, params.bump_percent)
.await
{
Ok(b) => {
tx_hash = b.tx_hash;
prev_max_fee = b.max_fee_per_gas;
prev_priority = b.max_priority_fee_per_gas;
unaffordable = false;
}
Err(e) if e.is_nonce_consumed() => {
info!(
nonce,
"cancel re-broadcast rejected nonce-too-low — slot consumed on-chain, freed"
);
return CancelOutcome::Resolved;
}
Err(e) => {
newton_metric::inc_batch_nonce_cancel_failures(chain_id);
unaffordable = e.is_insufficient_funds();
error!(
error = %e,
nonce,
unaffordable,
"cancel re-broadcast failed while awaiting confirmation; retrying (alert on nonce_cancel_failures_total)"
);
}
}
}
}
}
}
fn bump(fee: u128) -> u128 {
fee.saturating_mul(110) / 100
}
async fn sleep_or_cancel(dur: Duration, cancel_token: Option<&CancellationToken>) {
match cancel_token {
Some(token) => {
tokio::select! {
biased;
_ = token.cancelled() => {}
_ = tokio::time::sleep(dur) => {}
}
}
None => tokio::time::sleep(dur).await,
}
}