Skip to main content

strike_sdk/
nonce.rs

1//! Shared nonce manager for sequential transaction sends.
2//!
3//! Enabled by the `nonce-manager` feature (on by default). All transaction sends
4//! go through [`NonceSender`] to avoid nonce collisions when multiple operations
5//! are in flight.
6
7use alloy::network::Ethereum;
8use alloy::primitives::Address;
9use alloy::providers::{DynProvider, PendingTransactionBuilder, Provider};
10use alloy::rpc::types::TransactionRequest;
11use eyre::{Result, WrapErr};
12use tracing::{info, warn};
13
14/// BSC RPC providers enforce a minimum gas price of 0.05 gwei.
15const BSC_MIN_GAS_PRICE: u128 = 50_000_000; // 0.05 gwei in wei
16
17/// Concrete pending tx type.
18pub type PendingTx = PendingTransactionBuilder<Ethereum>;
19
20/// Shared nonce manager — all tx sends go through this to avoid nonce collisions.
21///
22/// Wraps a type-erased provider ([`DynProvider`]) so it's a plain concrete type.
23/// Callers should wrap this in `Arc<Mutex<NonceSender>>` and lock before each send.
24///
25/// # Auto-recovery
26///
27/// On nonce-related errors, the sender automatically syncs the nonce from chain
28/// and retries once before returning an error.
29pub struct NonceSender {
30    provider: DynProvider,
31    signer_addr: Address,
32    nonce: u64,
33}
34
35impl NonceSender {
36    /// Create a new NonceSender, fetching the current nonce from chain.
37    pub async fn new(provider: DynProvider, signer_addr: Address) -> Result<Self> {
38        let nonce = provider
39            .get_transaction_count(signer_addr)
40            .await
41            .wrap_err("failed to get initial nonce")?;
42        info!(nonce, "NonceSender initialized");
43        Ok(Self {
44            provider,
45            signer_addr,
46            nonce,
47        })
48    }
49
50    /// Re-fetch nonce from chain (use after errors).
51    pub async fn sync(&mut self) -> Result<()> {
52        let n = self
53            .provider
54            .get_transaction_count(self.signer_addr)
55            .await
56            .wrap_err("failed to sync nonce")?;
57        info!(
58            old_nonce = self.nonce,
59            new_nonce = n,
60            "nonce synced from chain"
61        );
62        self.nonce = n;
63        Ok(())
64    }
65
66    /// Current local nonce value.
67    pub fn current_nonce(&self) -> u64 {
68        self.nonce
69    }
70
71    /// Enforce BSC minimum gas price on a transaction request.
72    ///
73    /// BSC uses legacy (non-EIP-1559) transactions. When no gas fields are set,
74    /// alloy auto-fills at the provider level — which can result in values below
75    /// the RPC's minimum. We force legacy `gas_price` to at least 0.05 gwei and
76    /// clear EIP-1559 fields to prevent alloy from choosing type-2 transactions.
77    fn apply_gas_floor(tx: TransactionRequest) -> TransactionRequest {
78        let mut tx = tx;
79        // Force legacy gas price — BSC doesn't use EIP-1559
80        let gp = tx.gas_price.unwrap_or(BSC_MIN_GAS_PRICE);
81        tx.gas_price = Some(if gp < BSC_MIN_GAS_PRICE { BSC_MIN_GAS_PRICE } else { gp });
82        // Clear EIP-1559 fields so alloy sends a type-0 (legacy) tx
83        tx.max_fee_per_gas = None;
84        tx.max_priority_fee_per_gas = None;
85        tx
86    }
87
88    /// Send a transaction, stamping it with the next nonce.
89    ///
90    /// On nonce-related errors: syncs from chain and retries once.
91    /// The returned [`PendingTx`] can be `.await`ed for the receipt
92    /// **after** releasing the Mutex lock.
93    pub async fn send(&mut self, tx: TransactionRequest) -> Result<PendingTx> {
94        let tx = Self::apply_gas_floor(tx);
95        let attempt = tx.clone().nonce(self.nonce);
96        match self.provider.send_transaction(attempt).await {
97            Ok(pending) => {
98                self.nonce += 1;
99                Ok(pending)
100            }
101            Err(e) => {
102                let err_str = e.to_string();
103                if err_str.contains("nonce")
104                    || err_str.contains("replacement")
105                    || err_str.contains("already known")
106                {
107                    warn!(nonce = self.nonce, err = %e, "nonce error — syncing and retrying");
108                    self.sync().await?;
109                    let retry = tx.nonce(self.nonce);
110                    let pending = self
111                        .provider
112                        .send_transaction(retry)
113                        .await
114                        .wrap_err("retry after nonce sync failed")?;
115                    self.nonce += 1;
116                    Ok(pending)
117                } else {
118                    Err(e.into())
119                }
120            }
121        }
122    }
123}