Skip to content

fix(wallet): estimate gas with value and confirm failed sends on chain - #42

Merged
gacevicljubisa merged 5 commits into
mainfrom
fix/gas-estimate-and-send-lookup
Oct 15, 2026
Merged

gacevicljubisa merged 5 commits into
mainfrom
fix/gas-estimate-and-send-lookup

Conversation

@gacevicljubisa

@gacevicljubisa gacevicljubisa commented Oct 15, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR fixes the gas estimate and makes a failed send safe.

Gas estimate

  • The gas estimate request now includes the transfer value. Without the value, Sepolia returns 15000 gas. The transaction then fails with "intrinsic gas too low".
  • The 30% gas boost now applies. Before, integer division set the boost to zero.

Failed send

  • The wallet signs each transaction one time. A retry sends the same transaction with the same nonce. Thus, only one attempt can be mined.
  • After a failed send, the wallet asks the chain for the transaction for 15 seconds. Some nodes reject a transaction but keep it in the pool.
  • The wallet does not retry a transaction that the node rejected, for example "insufficient funds".
  • After "nonce too low", the wallet gets the pending nonce from the node. The nonce can only increase.
  • If you cancel the context, the lookup stops after the first request.

Options and CLI

  • New wallet options: WithRetry (attempts and delay) and WithLookup (timeout and interval).
  • New funder option: WithWalletOptions. It sends options to the wallet that the funder makes.
  • New CLI flags: --retryAttempts and --retryDelay.
  • --log-verbosity now applies. Before, the CLI made the logger before it read the flags.

API change

  • wallet.BackendClient has a new method, TransactionByHash. ethclient.Client already has it.

Tests

  • go test -race ./... passes.

gacevicljubisa and others added 4 commits July 31, 2026 17:01
…ncel

- Do not retry a transaction the node rejected; ask the chain once.
- On "nonce too low", move the next nonce up to the pending nonce.
- Stop the lookup when the context is cancelled, after the first request.
- Match "already known" without case, to include Nethermind.
- Replace funder WithRetryOption with WithWalletOptions.
- Shorten comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Make the logger after the flags are parsed, so --log-verbosity applies.
- Pass the logger to Fund and Stake.
- Add --retryAttempts and --retryDelay flags.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@aloknerurkar aloknerurkar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work. Signing once and re-sending the same transaction, the nonce that never moves back, and the on-chain lookup before giving up together remove the double-funding risk the old "replacement transaction underpriced" loop had. The gas fixes are clearly right: on main, gas *= 1 + (30/100) was gas *= 1, and the estimate had no value. Tests cover the cases well. Build, vet and go test -race -count=3 ./... pass locally.

One issue I'd like to discuss before merge, plus a couple of smaller points inline.

A rejected send leaves a nonce gap that blocks later transfers. The nonce is reserved before the send and never handed back. When the node rejects the transaction outright, nothing is ever broadcast on that nonce. The next transfer from the same wallet gets nonce+1, which the chain holds as a future transaction until the gap is filled, so it isn't mined. I checked it with the scripted client: "insufficient funds…" on the first transfer, then the second one sends with nonce 8 while the node's pending nonce stays 7. The same happens with a generic send error (RPC timeout, connection reset) where the node never got the transaction.

Main had the same gap, so this isn't a regression. But the PR now classifies rejectedMsgs as final, so at that point the wallet knows the nonce wasn't used. In a funder run the wallets are funded concurrently, so one "insufficient funds" failure (funding wallet running low) can leave the rest of the run's transactions accepted by the node but stuck in its future queue, while the run reports them as sent.

Re-fetching PendingNonceAt isn't a safe fix, because with other sends in flight it would hand out a nonce that's already used. Options I can see:

  • On a final rejection, give the nonce back only if it's still the latest one reserved (nonce == nonceNext-1). This covers the sequential case cheaply.
  • Otherwise, fill the hole with a 0-value self-transfer on that nonce, or at least log the gap at error level so the operator knows later transfers won't mine until it's filled.

Happy to hear if the funder is only ever run with enough balance and this was a conscious trade-off.

s.resyncNonce(ctx, fromAddr)
}

if rejected || attempt == attempts {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where a final rejection returns while its nonce stays reserved (see the review body). For the sequential case, something like if rejected && !nonceTooLow { s.releaseNonce(nonce) }, where release moves nonceNext back only when nonce == nonceNext-1, would avoid stalling the next transfer.

// The nonce is already used on chain.
nonceTooLowMsgs = []string{"nonce too low"}
// The node rejected the transaction itself, so re-sending it cannot help.
rejectedMsgs = []string{"nonce too low", "insufficient funds", "intrinsic gas too low", "exceeds block gas limit"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matching node errors by substring is the pragmatic choice here. One consequence worth knowing: "replacement transaction underpriced", which main used as its retry trigger, is now treated as "maybe kept" and polled for the full lookup window on each attempt. With the CLI default of one attempt that's 15s per failed transfer. That's fine, and WithLookup's doc already mentions the Nethermind case.

Comment thread cmd/main.go Outdated
fundCmd.PersistentFlags().StringVar(&cfg.WalletKey, "walletKey", "", "wallet key")
fundCmd.PersistentFlags().Float64Var(&cfg.MinAmounts.NativeCoin, "minNative", 0, "specifies min amount of chain native coins (DAI) nodes should have")
fundCmd.PersistentFlags().Float64Var(&cfg.MinAmounts.SwarmToken, "minSwarm", 0, "specifies min amount of swarm tokens (BZZ) nodes should have")
fundCmd.PersistentFlags().IntVar(&retryAttempts, "retryAttempts", 1, "number of attempts to send a transaction whose send failed")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: --retryAttempts defaults to 1, so the CLI never retries unless asked. That's the safe default, but the PR summary reads as if retries are on. Maybe say so in the README entry.

@gacevicljubisa
gacevicljubisa merged commit 59b6e6a into main Oct 15, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants