Skip to content

Commit f4b5c98

Browse files
committed
fix(signer): price L1 transactions with a floor and escalate stalled ones
A transaction sent while the network is cheap can be priced below a later base fee and become permanently unmineable, and because nonces are consumed in order it then blocks every subsequent transaction from the same account. Nothing in the signer revisited such a transaction: on timeout it returned an error and left it in the mempool, so the queue only grew. We hit this on a production proposer: 20 checkpointBlockHash transactions accumulated behind a head priced at maxFeePerGas 0.3228 gwei while base fee had risen to 1.25 gwei. 17 of the 20 were priced perfectly adequately and were held up purely by nonce ordering. Recovery required replacing the transactions by hand. Note this failed at LOW gas, which is the opposite of the usual intuition about congestion: automatic estimation derives maxFeePerGas from the current base fee, so the cheaper the network the smaller the buffer, and base fee needs only a handful of 12.5% steps to overtake it. Two changes, both inside send_transaction_request_with_timeout so all three signer kinds and every call site benefit: - maxFeePerGas floor, default 3 gwei (L1_GAS_FEE_FLOOR_GWEI) - gas price buffer, default 4x (L1_GAS_BASE_FEE_MULTIPLIER) - on timeout, re-send at the SAME nonce with +25% on both fee fields, up to 3 times (L1_TX_MAX_BUMPS) The transactions this signer sends are small -- a checkpoint is ~46k gas -- so a 3 gwei floor costs on the order of 0.00014 ETH per transaction, which is negligible against a stalled proposer. All three are env-overridable so a live incident can be handled by configuration rather than a deploy. Three details carry the correctness: - The nonce is pinned before the first send, so an escalation replaces the transaction instead of queueing behind it. SignerLock already serialises sends, so nothing else claims the nonce meanwhile. - An escalation can lose the race: the transaction it replaces may be mined just as the replacement goes out, and the node then rejects the replacement. That is success, not failure. Every broadcast hash is recorded before waiting for confirmation, and every error return is preceded by a receipt lookup over all of them. Reporting a landed transaction as failed would cause the caller to send a duplicate. - Callers that price their own transaction keep those fees; both fee fields must be set for that to apply, so a half-specified request is not paired with a computed value. When the attempts are exhausted the error distinguishes "still pending" from "the nonce was consumed by a transaction we cannot identify", decided from the on-chain nonce rather than by matching client-specific error text, since the operator's next step differs. Tests cover the floor, the buffer (asserted to survive >=12 consecutive blocks of maximum base-fee climb), the EIP-1559 +10% replacement threshold across values including zero, monotonic escalation, caller-fee precedence, and the worst-case timing helper. Verified end to end against anvil with block production withheld: the first attempt times out and the signer re-sends at the same nonce, maxFeePerGas 15 -> 18 gwei, which then confirms. Adds a tracing dependency to the signer crate so the escalation is visible; previously nothing in it logged.
1 parent e0f4902 commit f4b5c98

3 files changed

Lines changed: 411 additions & 7 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

utils/signer/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ alloy-transport-http.workspace = true
2020

2121
# general
2222
anyhow.workspace = true
23+
tracing.workspace = true
2324
tokio.workspace = true
2425
rustls = { version = "0.23", default-features = false, features = [
2526
"std",

0 commit comments

Comments
 (0)