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}