Skip to content

Commit 6013b51

Browse files
authored
Merge pull request #510 from 0xPolygon/thiago/nonce-gap
add account nonce fix-gap command
2 parents f04282d + db06954 commit 6013b51

File tree

9 files changed

+566
-3
lines changed

9 files changed

+566
-3
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ Note: Do not modify this section! It is auto-generated by `cobra` using `make ge
4949

5050
- [polycli enr](doc/polycli_enr.md) - Convert between ENR and Enode format
5151

52+
- [polycli fix-nonce-gap](doc/polycli_fix-nonce-gap.md) - Send txs to fix the nonce gap for a specific account
53+
5254
- [polycli fork](doc/polycli_fork.md) - Take a forked block and walk up the chain to do analysis.
5355

5456
- [polycli fund](doc/polycli_fund.md) - Bulk fund crypto wallets automatically.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
This command will check the account current nonce against the max nonce found in the pool. In case of a nonce gap is found, txs will be sent to fill those gaps.
2+
3+
To fix a nonce gap, we can use a command like this:
4+
5+
```bash
6+
polycli fix-nonce-gap \
7+
--rpc-url https://sepolia.drpc.org
8+
--private-key 0x32430699cd4f46ab2422f1df4ad6546811be20c9725544e99253a887e971f92b
9+
```
10+
11+
In case the RPC doesn't provide the `txpool_content` endpoint, the flag `--max-nonce` can be set to define the max nonce. The command will generate TXs from the current nonce up to the max nonce set.
12+
13+
```bash
14+
polycli fix-nonce-gap \
15+
--rpc-url https://sepolia.drpc.org
16+
--private-key 0x32430699cd4f46ab2422f1df4ad6546811be20c9725544e99253a887e971f92b
17+
--max-nonce
18+
```
19+
20+
By default, the command will skip TXs found in the pool, for example, let's assume the current nonce is 10, there is a TX with nonce 15 and 20 in the pool. When sending TXs to fill the gaps, the TXs 15 and 20 will be skipped. IN case you want to force these TXs to be replaced, you must provide the flag `--replace`.
21+
22+
```bash
23+
polycli fix-nonce-gap \
24+
--rpc-url https://sepolia.drpc.org
25+
--private-key 0x32430699cd4f46ab2422f1df4ad6546811be20c9725544e99253a887e971f92b
26+
--replace
27+
```

cmd/fixnoncegap/fixnoncegap.go

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
package fixnoncegap
2+
3+
import (
4+
"context"
5+
_ "embed"
6+
"errors"
7+
"fmt"
8+
"math/big"
9+
"strings"
10+
"time"
11+
12+
"github.com/ethereum/go-ethereum"
13+
"github.com/ethereum/go-ethereum/accounts/abi/bind"
14+
"github.com/ethereum/go-ethereum/common"
15+
"github.com/ethereum/go-ethereum/core/types"
16+
"github.com/ethereum/go-ethereum/crypto"
17+
"github.com/ethereum/go-ethereum/ethclient"
18+
"github.com/rs/zerolog/log"
19+
"github.com/spf13/cobra"
20+
)
21+
22+
var FixNonceGapCmd = &cobra.Command{
23+
Use: "fix-nonce-gap",
24+
Short: "Send txs to fix the nonce gap for a specific account",
25+
Long: fixNonceGapUsage,
26+
Args: cobra.NoArgs,
27+
PreRunE: prepareRpcClient,
28+
RunE: fixNonceGap,
29+
SilenceUsage: true,
30+
}
31+
32+
var (
33+
rpcClient *ethclient.Client
34+
)
35+
36+
type fixNonceGapArgs struct {
37+
rpcURL *string
38+
privateKey *string
39+
replace *bool
40+
maxNonce *uint64
41+
}
42+
43+
var inputFixNonceGapArgs = fixNonceGapArgs{}
44+
45+
const (
46+
ArgPrivateKey = "private-key"
47+
ArgRpcURL = "rpc-url"
48+
ArgReplace = "replace"
49+
ArgMaxNonce = "max-nonce"
50+
)
51+
52+
//go:embed FixNonceGapUsage.md
53+
var fixNonceGapUsage string
54+
55+
func prepareRpcClient(cmd *cobra.Command, args []string) error {
56+
var err error
57+
rpcURL := *inputFixNonceGapArgs.rpcURL
58+
59+
rpcClient, err = ethclient.Dial(rpcURL)
60+
if err != nil {
61+
log.Error().Err(err).Msgf("Unable to Dial RPC %s", rpcURL)
62+
return err
63+
}
64+
65+
if _, err = rpcClient.BlockNumber(cmd.Context()); err != nil {
66+
log.Error().Err(err).Msg("Unable to get block number")
67+
return err
68+
}
69+
70+
return nil
71+
}
72+
73+
func fixNonceGap(cmd *cobra.Command, args []string) error {
74+
replace := *inputFixNonceGapArgs.replace
75+
pvtKey := strings.TrimPrefix(*inputFixNonceGapArgs.privateKey, "0x")
76+
pk, err := crypto.HexToECDSA(pvtKey)
77+
if err != nil {
78+
log.Error().Err(err).Msg("Invalid private key")
79+
return err
80+
}
81+
82+
chainID, err := rpcClient.ChainID(cmd.Context())
83+
if err != nil {
84+
log.Error().Err(err).Msg("Cannot get chain ID")
85+
return err
86+
}
87+
88+
opts, err := bind.NewKeyedTransactorWithChainID(pk, chainID)
89+
if err != nil {
90+
log.Error().Err(err).Msg("Cannot generate transactionOpts")
91+
return err
92+
}
93+
94+
addr := opts.From
95+
96+
currentNonce, err := rpcClient.NonceAt(cmd.Context(), addr, nil)
97+
if err != nil {
98+
log.Error().Err(err).Msg("Unable to get current nonce")
99+
return err
100+
}
101+
log.Info().Stringer("addr", addr).Msgf("Current nonce: %d", currentNonce)
102+
103+
var maxNonce uint64
104+
if *inputFixNonceGapArgs.maxNonce != 0 {
105+
maxNonce = *inputFixNonceGapArgs.maxNonce
106+
} else {
107+
maxNonce, err = getMaxNonceFromTxPool(addr)
108+
if err != nil {
109+
if strings.Contains(err.Error(), "the method txpool_content does not exist/is not available") {
110+
log.Error().Err(err).Msg("The RPC doesn't provide access to txpool_content, please check --help for more information about --max-nonce")
111+
return nil
112+
}
113+
log.Error().Err(err).Msg("Unable to get max nonce from txpool")
114+
return err
115+
}
116+
}
117+
118+
// check if there is a nonce gap
119+
if maxNonce == 0 || currentNonce >= maxNonce {
120+
log.Info().Stringer("addr", addr).Msg("There is no nonce gap.")
121+
return nil
122+
}
123+
log.Info().Stringer("addr", addr).Msgf("Nonce gap found. Max nonce: %d", maxNonce)
124+
125+
gasPrice, err := rpcClient.SuggestGasPrice(cmd.Context())
126+
if err != nil {
127+
log.Error().Err(err).Msg("Unable to get suggested gas price")
128+
return err
129+
}
130+
131+
to := &common.Address{}
132+
133+
gas, err := rpcClient.EstimateGas(cmd.Context(), ethereum.CallMsg{
134+
From: addr,
135+
To: to,
136+
GasPrice: gasPrice,
137+
Value: big.NewInt(1),
138+
})
139+
if err != nil {
140+
log.Error().Err(err).Msg("Unable to estimate gas")
141+
return err
142+
}
143+
144+
txTemplate := &types.LegacyTx{
145+
To: to,
146+
Gas: gas,
147+
GasPrice: gasPrice,
148+
Value: big.NewInt(1),
149+
}
150+
151+
var lastTx *types.Transaction
152+
for i := currentNonce; i < maxNonce; i++ {
153+
txTemplate.Nonce = i
154+
tx := types.NewTx(txTemplate)
155+
out:
156+
for {
157+
signedTx, err := opts.Signer(opts.From, tx)
158+
if err != nil {
159+
log.Error().Err(err).Msg("Unable to sign tx")
160+
return err
161+
}
162+
log.Info().Stringer("hash", signedTx.Hash()).Msgf("sending tx with nonce %d", txTemplate.Nonce)
163+
164+
err = rpcClient.SendTransaction(cmd.Context(), signedTx)
165+
if err != nil {
166+
if strings.Contains(err.Error(), "nonce too low") {
167+
log.Info().Stringer("hash", signedTx.Hash()).Msgf("another tx with nonce %d was mined while trying to increase the fee, skipping it", txTemplate.Nonce)
168+
break out
169+
} else if strings.Contains(err.Error(), "already known") {
170+
log.Info().Stringer("hash", signedTx.Hash()).Msgf("same tx with nonce %d already exists, skipping it", txTemplate.Nonce)
171+
break out
172+
} else if strings.Contains(err.Error(), "replacement transaction underpriced") ||
173+
strings.Contains(err.Error(), "INTERNAL_ERROR: could not replace existing tx") {
174+
if replace {
175+
txTemplateCopy := *txTemplate
176+
oldGasPrice := txTemplate.GasPrice
177+
// increase TX gas price by 10% and retry
178+
txTemplateCopy.GasPrice = new(big.Int).Mul(txTemplate.GasPrice, big.NewInt(11))
179+
txTemplateCopy.GasPrice = new(big.Int).Div(txTemplateCopy.GasPrice, big.NewInt(10))
180+
tx = types.NewTx(&txTemplateCopy)
181+
log.Info().Stringer("hash", signedTx.Hash()).Msgf("tx with nonce %d is underpriced, increasing fee by 10%%. From %d To %d", txTemplate.Nonce, oldGasPrice, txTemplateCopy.GasPrice)
182+
time.Sleep(time.Second)
183+
continue
184+
} else {
185+
log.Info().Stringer("hash", signedTx.Hash()).Msgf("another tx with nonce %d already exists, skipping it", txTemplate.Nonce)
186+
break out
187+
}
188+
}
189+
log.Error().Err(err).Msg("Unable to send tx")
190+
return err
191+
}
192+
193+
// if we get here, just break the infinite loop and move to the next
194+
lastTx = signedTx
195+
break
196+
}
197+
}
198+
199+
if lastTx != nil {
200+
log.Info().Stringer("hash", lastTx.Hash()).Msg("waiting for the last tx to get mined")
201+
err := WaitMineTransaction(cmd.Context(), rpcClient, lastTx, 600)
202+
if err != nil {
203+
log.Error().Err(err).Msg("Unable to wait for last tx to get mined")
204+
return err
205+
}
206+
log.Info().Stringer("addr", addr).Msg("Nonce gap fixed successfully")
207+
currentNonce, err = rpcClient.NonceAt(cmd.Context(), addr, nil)
208+
if err != nil {
209+
log.Error().Err(err).Msg("Unable to get current nonce")
210+
return err
211+
}
212+
log.Info().Stringer("addr", addr).Msgf("Current nonce: %d", currentNonce)
213+
return nil
214+
}
215+
216+
return nil
217+
}
218+
219+
func init() {
220+
inputFixNonceGapArgs.rpcURL = FixNonceGapCmd.PersistentFlags().StringP(ArgRpcURL, "r", "http://localhost:8545", "The RPC endpoint url")
221+
inputFixNonceGapArgs.privateKey = FixNonceGapCmd.PersistentFlags().String(ArgPrivateKey, "", "the private key to be used when sending the txs to fix the nonce gap")
222+
inputFixNonceGapArgs.replace = FixNonceGapCmd.PersistentFlags().Bool(ArgReplace, false, "replace the existing txs in the pool")
223+
inputFixNonceGapArgs.maxNonce = FixNonceGapCmd.PersistentFlags().Uint64(ArgMaxNonce, 0, "when set, the max nonce will be this value instead of trying to get it from the pool")
224+
fatalIfError(FixNonceGapCmd.MarkPersistentFlagRequired(ArgPrivateKey))
225+
}
226+
227+
// Wait for the transaction to be mined
228+
func WaitMineTransaction(ctx context.Context, client *ethclient.Client, tx *types.Transaction, txTimeout uint64) error {
229+
timeout := time.NewTimer(time.Duration(txTimeout) * time.Second)
230+
defer timeout.Stop()
231+
for {
232+
select {
233+
case <-timeout.C:
234+
err := fmt.Errorf("timeout waiting for transaction to be mined")
235+
return err
236+
default:
237+
r, err := client.TransactionReceipt(ctx, tx.Hash())
238+
if err != nil {
239+
if !errors.Is(err, ethereum.NotFound) {
240+
log.Error().Err(err)
241+
return err
242+
}
243+
time.Sleep(1 * time.Second)
244+
continue
245+
}
246+
if r.Status != 0 {
247+
log.Info().Stringer("hash", r.TxHash).Msg("transaction successful")
248+
return nil
249+
} else if r.Status == 0 {
250+
log.Error().Stringer("hash", r.TxHash).Msg("transaction failed")
251+
return nil
252+
}
253+
time.Sleep(1 * time.Second)
254+
}
255+
}
256+
}
257+
258+
func fatalIfError(err error) {
259+
if err == nil {
260+
return
261+
}
262+
log.Fatal().Err(err).Msg("Unexpected error occurred")
263+
}
264+
265+
func getMaxNonceFromTxPool(addr common.Address) (uint64, error) {
266+
var result PoolContent
267+
err := rpcClient.Client().Call(&result, "txpool_content")
268+
if err != nil {
269+
return 0, err
270+
}
271+
272+
txCollections := []PoolContentTxs{
273+
result.BaseFee,
274+
result.Pending,
275+
result.Queued,
276+
}
277+
278+
maxNonceFound := uint64(0)
279+
for _, txCollection := range txCollections {
280+
// get only txs from the address we are looking for
281+
txs, found := txCollection[addr.String()]
282+
if !found {
283+
continue
284+
}
285+
286+
// iterate over the transactions and get the nonce
287+
for nonce := range txs {
288+
nonceInt, ok := new(big.Int).SetString(nonce, 10)
289+
if !ok {
290+
err = fmt.Errorf("invalid nonce found: %s", nonce)
291+
return 0, err
292+
}
293+
294+
if nonceInt.Uint64() > maxNonceFound {
295+
maxNonceFound = nonceInt.Uint64()
296+
}
297+
}
298+
}
299+
300+
return maxNonceFound, nil
301+
}
302+
303+
type PoolContent struct {
304+
BaseFee PoolContentTxs
305+
Pending PoolContentTxs
306+
Queued PoolContentTxs
307+
}
308+
309+
type PoolContentTxs map[string]map[string]any

cmd/root.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package cmd
22

33
import (
44
"fmt"
5+
"os"
6+
7+
"github.com/0xPolygon/polygon-cli/cmd/fixnoncegap"
58
"github.com/0xPolygon/polygon-cli/cmd/retest"
69
"github.com/0xPolygon/polygon-cli/cmd/ulxly"
7-
"os"
810

911
"github.com/0xPolygon/polygon-cli/cmd/fork"
1012
"github.com/0xPolygon/polygon-cli/cmd/p2p"
@@ -109,13 +111,14 @@ func NewPolycliCommand() *cobra.Command {
109111
// Define commands.
110112
cmd.AddCommand(
111113
abi.ABICmd,
114+
dbbench.DBBenchCmd,
112115
dumpblocks.DumpblocksCmd,
113116
ecrecover.EcRecoverCmd,
117+
enr.ENRCmd,
118+
fixnoncegap.FixNonceGapCmd,
114119
fork.ForkCmd,
115120
fund.FundCmd,
116121
hash.HashCmd,
117-
enr.ENRCmd,
118-
dbbench.DBBenchCmd,
119122
loadtest.LoadtestCmd,
120123
metricsToDash.MetricsToDashCmd,
121124
mnemonic.MnemonicCmd,

doc/polycli.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ Polycli is a collection of tools that are meant to be useful while building, tes
4545

4646
- [polycli enr](polycli_enr.md) - Convert between ENR and Enode format
4747

48+
- [polycli fix-nonce-gap](polycli_fix-nonce-gap.md) - Send txs to fix the nonce gap for a specific account
49+
4850
- [polycli fork](polycli_fork.md) - Take a forked block and walk up the chain to do analysis.
4951

5052
- [polycli fund](polycli_fund.md) - Bulk fund crypto wallets automatically.

0 commit comments

Comments
 (0)