From e5b2a482b20e18af943fe26e3dd244f4a8ac2761 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Tue, 30 Dec 2025 20:49:59 +0530 Subject: [PATCH 01/27] loadtest: check for preconf after sending tx --- cmd/loadtest/app.go | 2 ++ cmd/loadtest/loadtest.go | 14 +++++++++++++- util/receipt.go | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 1 deletion(-) diff --git a/cmd/loadtest/app.go b/cmd/loadtest/app.go index 77f90a0c2..66f20a619 100644 --- a/cmd/loadtest/app.go +++ b/cmd/loadtest/app.go @@ -69,6 +69,7 @@ type ( SummaryOutputMode string LegacyTransactionMode bool FireAndForget bool + CheckForPreconf bool RecallLength uint64 ContractAddress string ContractCallData string @@ -271,6 +272,7 @@ func initFlags() { pf.BoolVar(<p.LegacyTransactionMode, "legacy", false, "send a legacy transaction instead of an EIP1559 transaction") pf.BoolVar(<p.FireAndForget, "fire-and-forget", false, "send transactions and load without waiting for it to be mined") pf.BoolVar(<p.FireAndForget, "send-only", false, "alias for --fire-and-forget") + pf.BoolVar(<p.CheckForPreconf, "check-preconf", false, "check for preconf status after sending tx") // Local flags. f := LoadtestCmd.Flags() diff --git a/cmd/loadtest/loadtest.go b/cmd/loadtest/loadtest.go index 27bb9d8ef..3b681b14a 100644 --- a/cmd/loadtest/loadtest.go +++ b/cmd/loadtest/loadtest.go @@ -22,6 +22,7 @@ import ( "sync" "time" + "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/signer/core/apitypes" "github.com/holiman/uint256" @@ -926,6 +927,17 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro if !inputLoadTestParams.FireAndForget { recordSample(routineID, requestID, tErr, startReq, endReq, sendingTops.Nonce.Uint64()) } + go func(hash common.Hash, tErr error) { + if tErr == nil && inputLoadTestParams.CheckForPreconf { + preconfStatus, err := util.WaitPreconf(context.Background(), c, ltTxHash) + if err != nil { + log.Error().Err(err).Msg("Error fetching preconf") + } else { + log.Info().Bool("preconf", preconfStatus).Msg("Fetched preconf status") + } + } + }(ltTxHash, tErr) + log.Info().Str("hash", ltTxHash.Hex()).Msg("Sent tx") if tErr == nil && inputLoadTestParams.WaitForReceipt { receiptMaxRetries := inputLoadTestParams.ReceiptRetryMax receiptRetryInitialDelayMs := inputLoadTestParams.ReceiptRetryInitialDelayMs @@ -976,7 +988,7 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro } } } - log.Trace(). + log.Info(). Int64("routineID", routineID). Int64("requestID", requestID). Stringer("txhash", ltTxHash). diff --git a/util/receipt.go b/util/receipt.go index 5c7142b98..86f30791f 100644 --- a/util/receipt.go +++ b/util/receipt.go @@ -82,3 +82,38 @@ func internalWaitReceipt(ctx context.Context, client *ethclient.Client, txHash c } } } + +func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { + return internalWaitPreconf(ctx, client, txHash) +} + +func internalWaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { + var maxRetries uint = 3 + var timeout time.Duration = 30 * time.Second + var delay time.Duration = time.Second + + // Create context with timeout + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for attempt := uint(0); ; attempt++ { + var res interface{} + err := client.Client().CallContext(ctx, &res, "eth_checkPreconfStatus", txHash.Hex()) + if err == nil { + return res.(bool), nil + } + + // If maxRetries > 0 and we've reached the limit, exit + // Note: effectiveMaxRetries is always > 0 due to default above + if maxRetries > 0 && attempt >= maxRetries-1 { + return false, fmt.Errorf("failed to get receipt after %d attempts: %w", maxRetries, err) + } + + select { + case <-timeoutCtx.Done(): + return false, timeoutCtx.Err() + case <-time.After(delay): + // Continue + } + } +} From 18ae4d9299689cb22e9f68cc1f2c1030753d95e1 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Tue, 30 Dec 2025 22:10:27 +0530 Subject: [PATCH 02/27] util: hardcode multicall gas fee and tip cap --- util/multicall3.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/util/multicall3.go b/util/multicall3.go index 0479fbd49..1135b182b 100644 --- a/util/multicall3.go +++ b/util/multicall3.go @@ -206,6 +206,8 @@ func Multicall3FundAccountsWithNativeToken(c *ethclient.Client, tops *bind.Trans } tops.Value = big.NewInt(0).Mul(amount, big.NewInt(int64(len(accounts)))) + tops.GasFeeCap = big.NewInt(300000000000) // 300 Gwei + tops.GasTipCap = big.NewInt(30000000000) // 30 Gwei return sc.Aggregate3Value(tops, calls) } From 760a1f82886029992910e2111a16e5e04978c2c1 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Tue, 30 Dec 2025 22:14:43 +0530 Subject: [PATCH 03/27] util: hardcode multicall gas fee and tip cap --- util/multicall3.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/multicall3.go b/util/multicall3.go index 1135b182b..dec326a0f 100644 --- a/util/multicall3.go +++ b/util/multicall3.go @@ -206,8 +206,8 @@ func Multicall3FundAccountsWithNativeToken(c *ethclient.Client, tops *bind.Trans } tops.Value = big.NewInt(0).Mul(amount, big.NewInt(int64(len(accounts)))) - tops.GasFeeCap = big.NewInt(300000000000) // 300 Gwei - tops.GasTipCap = big.NewInt(30000000000) // 30 Gwei + tops.GasFeeCap = big.NewInt(30000000000) // 30 Gwei + tops.GasTipCap = big.NewInt(30000000000) // 30 Gwei return sc.Aggregate3Value(tops, calls) } From e399bc9390f0b70cea5e38409731bc6318dab52d Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Tue, 30 Dec 2025 22:15:34 +0530 Subject: [PATCH 04/27] util: hardcode multicall gas fee and tip cap --- util/multicall3.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/util/multicall3.go b/util/multicall3.go index dec326a0f..66659483d 100644 --- a/util/multicall3.go +++ b/util/multicall3.go @@ -206,8 +206,8 @@ func Multicall3FundAccountsWithNativeToken(c *ethclient.Client, tops *bind.Trans } tops.Value = big.NewInt(0).Mul(amount, big.NewInt(int64(len(accounts)))) - tops.GasFeeCap = big.NewInt(30000000000) // 30 Gwei - tops.GasTipCap = big.NewInt(30000000000) // 30 Gwei + tops.GasFeeCap = big.NewInt(25000000000) // 25 Gwei + tops.GasTipCap = big.NewInt(25000000000) // 25 Gwei return sc.Aggregate3Value(tops, calls) } From 6ed9a407ff479d1615b8b2d37fa712be3ad6c13d Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Tue, 30 Dec 2025 22:17:47 +0530 Subject: [PATCH 05/27] util: limit accounts to fund per tx --- cmd/loadtest/account.go | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/cmd/loadtest/account.go b/cmd/loadtest/account.go index 5da775de2..eba460b0c 100644 --- a/cmd/loadtest/account.go +++ b/cmd/loadtest/account.go @@ -462,14 +462,16 @@ func (ap *AccountPool) fundAccountsWithMulticall3(ctx context.Context, tops *bin log.Debug(). Msg("funding sending accounts with multicall3") - const defaultAccsToFundPerTx = 400 - accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, ap.client) - if err != nil { - log.Warn().Err(err). - Uint64("defaultAccsToFundPerTx", defaultAccsToFundPerTx). - Msg("failed to get multicall3 max accounts to fund per tx, falling back to default") - accsToFundPerTx = defaultAccsToFundPerTx - } + // const defaultAccsToFundPerTx = 100 + // accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, ap.client) + // if err != nil { + // log.Warn().Err(err). + // Uint64("defaultAccsToFundPerTx", defaultAccsToFundPerTx). + // Msg("failed to get multicall3 max accounts to fund per tx, falling back to default") + // accsToFundPerTx = defaultAccsToFundPerTx + // } + var accsToFundPerTx uint64 = 100 + var err error log.Debug().Uint64("accsToFundPerTx", accsToFundPerTx).Msg("multicall3 max accounts to fund per tx") chSize := (uint64(len(ap.accounts)) / accsToFundPerTx) + 1 From e54199db9087a570a9468e6f1169eba023a341c0 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Tue, 30 Dec 2025 22:19:47 +0530 Subject: [PATCH 06/27] util: skip multicall for funding accounts --- cmd/loadtest/account.go | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/cmd/loadtest/account.go b/cmd/loadtest/account.go index eba460b0c..df7b27bf2 100644 --- a/cmd/loadtest/account.go +++ b/cmd/loadtest/account.go @@ -442,19 +442,19 @@ func (ap *AccountPool) FundAccounts(ctx context.Context) error { return errors.New(errMsg) } - log.Debug().Msg("checking if multicall3 is supported") - multicall3Addr, _ := util.IsMulticall3Supported(ctx, ap.client, true, tops, nil) - if multicall3Addr != nil { - log.Info(). - Stringer("address", multicall3Addr). - Msg("multicall3 is supported and will be used to fund accounts") - } else { - log.Info().Msg("multicall3 is not supported, will use EOA transfers to fund accounts") - } + // log.Debug().Msg("checking if multicall3 is supported") + // multicall3Addr, _ := util.IsMulticall3Supported(ctx, ap.client, true, tops, nil) + // if multicall3Addr != nil { + // log.Info(). + // Stringer("address", multicall3Addr). + // Msg("multicall3 is supported and will be used to fund accounts") + // } else { + // log.Info().Msg("multicall3 is not supported, will use EOA transfers to fund accounts") + // } - if multicall3Addr != nil { - return ap.fundAccountsWithMulticall3(ctx, tops, multicall3Addr) - } + // if multicall3Addr != nil { + // return ap.fundAccountsWithMulticall3(ctx, tops, multicall3Addr) + // } return ap.fundAccountsWithEOATransfers(ctx, tops) } From e6ee695e608226ebd8d475678fdf2bf3a4eb5de9 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Wed, 31 Dec 2025 15:01:13 +0530 Subject: [PATCH 07/27] cmd/loadtest: log --- cmd/loadtest/account.go | 1 + 1 file changed, 1 insertion(+) diff --git a/cmd/loadtest/account.go b/cmd/loadtest/account.go index df7b27bf2..1132c779b 100644 --- a/cmd/loadtest/account.go +++ b/cmd/loadtest/account.go @@ -1160,6 +1160,7 @@ func (ap *AccountPool) createEOATransferTx(ctx context.Context, sender *ecdsa.Pr Data: nil, Value: amount, } + log.Info().Uint64("gasFeeCap", dynamicFeeTx.GasFeeCap.Uint64()).Uint64("gasTipCap", dynamicFeeTx.GasTipCap.Uint64()).Msg("Creating fund tx") tx = types.NewTx(dynamicFeeTx) } From add1b178c32c0db0ffac818f0d0243a87378f9ad Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Wed, 31 Dec 2025 15:08:40 +0530 Subject: [PATCH 08/27] cmd/loadtest: dump private keys to file --- cmd/loadtest/account.go | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/cmd/loadtest/account.go b/cmd/loadtest/account.go index 1132c779b..7b63077ba 100644 --- a/cmd/loadtest/account.go +++ b/cmd/loadtest/account.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "math/big" + "os" "slices" "strings" "sync" @@ -204,13 +205,37 @@ func (ap *AccountPool) AllAccountsReady() (bool, int, int) { // Adds N random accounts to the pool func (ap *AccountPool) AddRandomN(ctx context.Context, n uint64) error { + // for i := uint64(0); i < n; i++ { + // err := ap.AddRandom(ctx) + // if err != nil { + // return fmt.Errorf("failed to add random account: %w", err) + // } + // } + pks := make([]*ecdsa.PrivateKey, n) for i := uint64(0); i < n; i++ { - err := ap.AddRandom(ctx) + privateKey, err := crypto.GenerateKey() if err != nil { - return fmt.Errorf("failed to add random account: %w", err) + return fmt.Errorf("failed to generate private key: %w", err) } + pks[i] = privateKey } - return nil + + // Dump data to a file "pks" + f, err := os.Create("./privatekeys") + if err != nil { + log.Error().Err(err).Msg("failed to create private keys file") + } + defer f.Close() + for _, pk := range pks { + // pkBytes := crypto.FromECDSA(pk) + pkStr := util.GetPrivateKeyHex(pk) + _, err := fmt.Fprintf(f, "%s\n", pkStr) + if err != nil { + return fmt.Errorf("failed to write private key: %w", err) + } + } + + return ap.AddN(ctx, pks...) } // Adds a random account to the pool From b4e1c09ce39d53da1f835f39c2ae9b0cd8e5cb71 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 13:33:25 +0530 Subject: [PATCH 09/27] cmd/loadtest: implement preconf tracker --- cmd/loadtest/loadtest.go | 19 ++-- cmd/loadtest/preconf_tracker.go | 184 ++++++++++++++++++++++++++++++++ util/receipt.go | 38 +++++-- 3 files changed, 223 insertions(+), 18 deletions(-) create mode 100644 cmd/loadtest/preconf_tracker.go diff --git a/cmd/loadtest/loadtest.go b/cmd/loadtest/loadtest.go index 3b681b14a..5f8be34a9 100644 --- a/cmd/loadtest/loadtest.go +++ b/cmd/loadtest/loadtest.go @@ -22,7 +22,6 @@ import ( "sync" "time" - "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto/kzg4844" "github.com/ethereum/go-ethereum/signer/core/apitypes" "github.com/holiman/uint256" @@ -826,6 +825,10 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro mustCheckMaxBaseFee, maxBaseFeeCtxCancel, waitBaseFeeToDrop := setupBaseFeeMonitoring(ctx, c, ltp) + // setup tracking for preconfs + preconfTracker := NewPreconfTracker(c) + defer preconfTracker.Stats() + log.Debug().Msg("Starting main load test loop") var wg sync.WaitGroup for routineID := int64(0); routineID < maxRoutines; routineID++ { @@ -927,17 +930,9 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro if !inputLoadTestParams.FireAndForget { recordSample(routineID, requestID, tErr, startReq, endReq, sendingTops.Nonce.Uint64()) } - go func(hash common.Hash, tErr error) { - if tErr == nil && inputLoadTestParams.CheckForPreconf { - preconfStatus, err := util.WaitPreconf(context.Background(), c, ltTxHash) - if err != nil { - log.Error().Err(err).Msg("Error fetching preconf") - } else { - log.Info().Bool("preconf", preconfStatus).Msg("Fetched preconf status") - } - } - }(ltTxHash, tErr) - log.Info().Str("hash", ltTxHash.Hex()).Msg("Sent tx") + if tErr == nil && inputLoadTestParams.CheckForPreconf { + go preconfTracker.Track(ltTxHash) + } if tErr == nil && inputLoadTestParams.WaitForReceipt { receiptMaxRetries := inputLoadTestParams.ReceiptRetryMax receiptRetryInitialDelayMs := inputLoadTestParams.ReceiptRetryInitialDelayMs diff --git a/cmd/loadtest/preconf_tracker.go b/cmd/loadtest/preconf_tracker.go new file mode 100644 index 000000000..d6ca47eea --- /dev/null +++ b/cmd/loadtest/preconf_tracker.go @@ -0,0 +1,184 @@ +package loadtest + +import ( + "context" + "encoding/csv" + "os" + "strconv" + "sync" + "sync/atomic" + "time" + + "github.com/0xPolygon/polygon-cli/util" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/rs/zerolog/log" +) + +type PreconfTracker struct { + client *ethclient.Client + + // metrics + preconfSuccess atomic.Uint64 + preconfFail atomic.Uint64 + totalTasks atomic.Uint64 + bothFailedCount atomic.Uint64 + uneffectivePreconf atomic.Uint64 + falsePositiveCount atomic.Uint64 + confidence atomic.Uint64 + + preconfDurationLock sync.Mutex + preconfDurations []time.Duration +} + +func NewPreconfTracker(client *ethclient.Client) PreconfTracker { + return PreconfTracker{ + client: client, + preconfDurations: make([]time.Duration, 0, 1024), + } +} + +func (pt *PreconfTracker) Track(txHash common.Hash) { + currentBlock, err := pt.client.BlockNumber(context.Background()) + if err != nil { + return + } + + // wait for preconf + var wg sync.WaitGroup + var preconfStatus bool + var preconfError error + var preconfDuration time.Duration + wg.Add(1) + go func() { + defer wg.Done() + + preconfStartTime := time.Now() + defer func() { + preconfDuration = time.Since(preconfStartTime) + }() + + preconfStatus, preconfError = util.WaitPreconf(context.Background(), pt.client, txHash) + if preconfStatus { + pt.preconfSuccess.Add(1) + } else { + pt.preconfFail.Add(1) + } + }() + + // wait for receipt + var receipt *types.Receipt + var receiptError error + var receiptDuration time.Duration + wg.Add(1) + go func() { + defer wg.Done() + + time.Sleep(100 * time.Millisecond) + + receiptTime := time.Now() + defer func() { + receiptDuration = time.Since(receiptTime) + }() + + receipt, receiptError = util.WaitReceiptNew(context.Background(), pt.client, txHash) + }() + + wg.Wait() + + if preconfStatus { + pt.preconfDurationLock.Lock() + pt.preconfDurations = append(pt.preconfDurations, preconfDuration) + pt.preconfDurationLock.Unlock() + } + + pt.totalTasks.Add(1) + + // both failed case. no tx inclusion in txpool or block + if preconfError != nil && receiptError != nil { + pt.bothFailedCount.Add(1) + } + + // both result arrived + if preconfError == nil && receiptError == nil { + // receipt arrived early before preconf suggesting that + // preconf wasn't effective. + if preconfDuration > receiptDuration { + pt.uneffectivePreconf.Add(1) + } + } + + // receipt arrived but preconf failed suggesting that + // preconf wasn't effective + if receiptError == nil && preconfError != nil { + pt.uneffectivePreconf.Add(1) + } + + // false positive. preconf said tx is included but never got executed. + // not most accurate as we only check for receipts for 1m and not forever + if preconfError == nil && receiptError != nil { + pt.falsePositiveCount.Add(1) + } + + // both result arrived + if preconfError == nil && receiptError == nil { + // after how many blocks did the tx got mined + blockDiff := receipt.BlockNumber.Uint64() - currentBlock + // if receipt got received in less than 10 blocks and preconf said + // true, increase the confidence meter. + if blockDiff < 10 { + pt.confidence.Add(1) + } + } +} + +func (pt *PreconfTracker) Stats() { + log.Info().Uint64("total_tasks", pt.totalTasks.Load()). + Uint64("preconf_success", pt.preconfSuccess.Load()). + Uint64("preconf_fail", pt.preconfFail.Load()). + Uint64("both_failed", pt.bothFailedCount.Load()). + Uint64("uneffective_preconf", pt.uneffectivePreconf.Load()). + Uint64("false_positives", pt.falsePositiveCount.Load()). + Uint64("confidence", pt.confidence.Load()). + Msg("Preconf Tracker Stats") + + pt.preconfDurationLock.Lock() + path := "preconf_durations" + time.Now().String() + ".csv" + err := dumpDurationsCSV(path, pt.preconfDurations) + if err != nil { + log.Error().Err(err).Msg("Error dumping preconf durations") + } else { + log.Info().Str("path", path).Msg("Dumped preconf durations into file") + } + pt.preconfDurationLock.Unlock() +} + +func dumpDurationsCSV(path string, durations []time.Duration) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + // header + if err := w.Write([]string{"idx", "duration_ns", "duration_ms"}); err != nil { + return err + } + + for i, d := range durations { + row := []string{ + strconv.Itoa(i), + strconv.FormatInt(d.Nanoseconds(), 10), + strconv.FormatInt(d.Milliseconds(), 10), + } + if err := w.Write(row); err != nil { + return err + } + } + + return w.Error() +} diff --git a/util/receipt.go b/util/receipt.go index 86f30791f..688a1c21d 100644 --- a/util/receipt.go +++ b/util/receipt.go @@ -83,14 +83,40 @@ func internalWaitReceipt(ctx context.Context, client *ethclient.Client, txHash c } } -func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { - return internalWaitPreconf(ctx, client, txHash) +func WaitReceiptNew(ctx context.Context, client *ethclient.Client, txHash common.Hash) (*types.Receipt, error) { + var maxRetries uint = 10 + var timeout time.Duration = time.Minute + var delay time.Duration = 2 * time.Second + + // Create context with timeout + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + for attempt := uint(0); ; attempt++ { + receipt, err := client.TransactionReceipt(timeoutCtx, txHash) + if err == nil && receipt != nil { + return receipt, nil + } + + // If maxRetries > 0 and we've reached the limit, exit + // Note: effectiveMaxRetries is always > 0 due to default above + if maxRetries > 0 && attempt >= maxRetries-1 { + return nil, fmt.Errorf("failed to get receipt after %d attempts: %w", maxRetries, err) + } + + select { + case <-timeoutCtx.Done(): + return nil, timeoutCtx.Err() + case <-time.After(delay): + // Continue + } + } } -func internalWaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { - var maxRetries uint = 3 - var timeout time.Duration = 30 * time.Second - var delay time.Duration = time.Second +func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { + var maxRetries uint = 15 + var timeout time.Duration = time.Minute + var delay time.Duration = 1 * time.Second // Create context with timeout timeoutCtx, cancel := context.WithTimeout(ctx, timeout) From 5dc34677fc1014d5de3e7b722a9e41ad5300a4a9 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 14:52:10 +0530 Subject: [PATCH 10/27] logs --- cmd/loadtest/loadtest.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cmd/loadtest/loadtest.go b/cmd/loadtest/loadtest.go index 5f8be34a9..5e0078df9 100644 --- a/cmd/loadtest/loadtest.go +++ b/cmd/loadtest/loadtest.go @@ -828,6 +828,7 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro // setup tracking for preconfs preconfTracker := NewPreconfTracker(c) defer preconfTracker.Stats() + log.Info().Msg("Setup preconf tracker") log.Debug().Msg("Starting main load test loop") var wg sync.WaitGroup @@ -931,6 +932,7 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro recordSample(routineID, requestID, tErr, startReq, endReq, sendingTops.Nonce.Uint64()) } if tErr == nil && inputLoadTestParams.CheckForPreconf { + log.Info().Msg("Send tx for preconf tracking...") go preconfTracker.Track(ltTxHash) } if tErr == nil && inputLoadTestParams.WaitForReceipt { From 8d309ddd35bd14692da52f1bcf2f4e81d6d96e43 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 15:00:15 +0530 Subject: [PATCH 11/27] better setup for preconf tracker --- cmd/loadtest/loadtest.go | 24 ++++++++++++++---------- cmd/loadtest/preconf_tracker.go | 4 ++-- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/cmd/loadtest/loadtest.go b/cmd/loadtest/loadtest.go index 5e0078df9..16d06c12b 100644 --- a/cmd/loadtest/loadtest.go +++ b/cmd/loadtest/loadtest.go @@ -546,6 +546,8 @@ func runLoadTest(ctx context.Context) error { rpc.SetHeader("Accept-Encoding", "identity") ec := ethclient.NewClient(rpc) + var preconfTracker *PreconfTracker + // Define the main loop function. // Make sure to define any logic associated to the load test (initialization, main load test loop // or completion steps) in this function in order to handle cancellation signals properly. @@ -555,7 +557,12 @@ func runLoadTest(ctx context.Context) error { return err } - if err = mainLoop(ctx, ec, rpc); err != nil { + if inputLoadTestParams.CheckForPreconf { + preconfTracker = NewPreconfTracker(ec) + log.Info().Msg("Done setting up preconf tracker...") + } + + if err = mainLoop(ctx, ec, rpc, preconfTracker); err != nil { log.Error().Err(err).Msg("Error during the main load test loop") return err } @@ -593,6 +600,9 @@ func runLoadTest(ctx context.Context) error { log.Info().Msg("Time's up") case <-sigCh: log.Info().Msg("Interrupted.. Stopping load test") + if preconfTracker != nil { + preconfTracker.Stats() + } if inputLoadTestParams.ShouldProduceSummary { finalBlockNumber, err = ec.BlockNumber(ctx) if err != nil { @@ -668,7 +678,7 @@ func updateRateLimit(ctx context.Context, rl *rate.Limiter, rpc *ethrpc.Client, } } -func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) error { +func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client, pt *PreconfTracker) error { ltp := inputLoadTestParams log.Trace().Interface("Input Params", ltp).Msg("Params") @@ -825,11 +835,6 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro mustCheckMaxBaseFee, maxBaseFeeCtxCancel, waitBaseFeeToDrop := setupBaseFeeMonitoring(ctx, c, ltp) - // setup tracking for preconfs - preconfTracker := NewPreconfTracker(c) - defer preconfTracker.Stats() - log.Info().Msg("Setup preconf tracker") - log.Debug().Msg("Starting main load test loop") var wg sync.WaitGroup for routineID := int64(0); routineID < maxRoutines; routineID++ { @@ -931,9 +936,8 @@ func mainLoop(ctx context.Context, c *ethclient.Client, rpc *ethrpc.Client) erro if !inputLoadTestParams.FireAndForget { recordSample(routineID, requestID, tErr, startReq, endReq, sendingTops.Nonce.Uint64()) } - if tErr == nil && inputLoadTestParams.CheckForPreconf { - log.Info().Msg("Send tx for preconf tracking...") - go preconfTracker.Track(ltTxHash) + if tErr == nil && inputLoadTestParams.CheckForPreconf && pt != nil { + go pt.Track(ltTxHash) } if tErr == nil && inputLoadTestParams.WaitForReceipt { receiptMaxRetries := inputLoadTestParams.ReceiptRetryMax diff --git a/cmd/loadtest/preconf_tracker.go b/cmd/loadtest/preconf_tracker.go index d6ca47eea..7eb722e56 100644 --- a/cmd/loadtest/preconf_tracker.go +++ b/cmd/loadtest/preconf_tracker.go @@ -32,8 +32,8 @@ type PreconfTracker struct { preconfDurations []time.Duration } -func NewPreconfTracker(client *ethclient.Client) PreconfTracker { - return PreconfTracker{ +func NewPreconfTracker(client *ethclient.Client) *PreconfTracker { + return &PreconfTracker{ client: client, preconfDurations: make([]time.Duration, 0, 1024), } From 12d062fd0826531eb4a4e89a2c8a87be61585afa Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 18:20:01 +0530 Subject: [PATCH 12/27] update timeouts and retries for preconf tracker --- util/receipt.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/util/receipt.go b/util/receipt.go index 688a1c21d..c5679a747 100644 --- a/util/receipt.go +++ b/util/receipt.go @@ -84,9 +84,9 @@ func internalWaitReceipt(ctx context.Context, client *ethclient.Client, txHash c } func WaitReceiptNew(ctx context.Context, client *ethclient.Client, txHash common.Hash) (*types.Receipt, error) { - var maxRetries uint = 10 + var maxRetries uint = 20 var timeout time.Duration = time.Minute - var delay time.Duration = 2 * time.Second + var delay time.Duration = 3 * time.Second // Create context with timeout timeoutCtx, cancel := context.WithTimeout(ctx, timeout) @@ -114,9 +114,9 @@ func WaitReceiptNew(ctx context.Context, client *ethclient.Client, txHash common } func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { - var maxRetries uint = 15 + var maxRetries uint = 20 var timeout time.Duration = time.Minute - var delay time.Duration = 1 * time.Second + var delay time.Duration = 1500 * time.Millisecond // Create context with timeout timeoutCtx, cancel := context.WithTimeout(ctx, timeout) From 9b478a88470c7add2492deced923982e66eb471e Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 18:21:33 +0530 Subject: [PATCH 13/27] typo --- cmd/loadtest/preconf_tracker.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/loadtest/preconf_tracker.go b/cmd/loadtest/preconf_tracker.go index 7eb722e56..c98e422f4 100644 --- a/cmd/loadtest/preconf_tracker.go +++ b/cmd/loadtest/preconf_tracker.go @@ -125,7 +125,7 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { if preconfError == nil && receiptError == nil { // after how many blocks did the tx got mined blockDiff := receipt.BlockNumber.Uint64() - currentBlock - // if receipt got received in less than 10 blocks and preconf said + // if receipt received in less than 10 blocks and preconf said // true, increase the confidence meter. if blockDiff < 10 { pt.confidence.Add(1) From d3919e4cf18ffc3d32114213d5acfc32e9d37696 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 19:07:18 +0530 Subject: [PATCH 14/27] move metrics for better accuracy --- cmd/loadtest/preconf_tracker.go | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/cmd/loadtest/preconf_tracker.go b/cmd/loadtest/preconf_tracker.go index c98e422f4..3f4a70f38 100644 --- a/cmd/loadtest/preconf_tracker.go +++ b/cmd/loadtest/preconf_tracker.go @@ -60,11 +60,6 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { }() preconfStatus, preconfError = util.WaitPreconf(context.Background(), pt.client, txHash) - if preconfStatus { - pt.preconfSuccess.Add(1) - } else { - pt.preconfFail.Add(1) - } }() // wait for receipt @@ -87,14 +82,16 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { wg.Wait() + pt.totalTasks.Add(1) if preconfStatus { + pt.preconfSuccess.Add(1) pt.preconfDurationLock.Lock() pt.preconfDurations = append(pt.preconfDurations, preconfDuration) pt.preconfDurationLock.Unlock() + } else { + pt.preconfFail.Add(1) } - pt.totalTasks.Add(1) - // both failed case. no tx inclusion in txpool or block if preconfError != nil && receiptError != nil { pt.bothFailedCount.Add(1) From 812c53e8b6fac15dc52ac6ad18a521048b680244 Mon Sep 17 00:00:00 2001 From: Manav Darji Date: Fri, 2 Jan 2026 20:14:50 +0530 Subject: [PATCH 15/27] dump block diffs --- cmd/loadtest/preconf_tracker.go | 53 +++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/cmd/loadtest/preconf_tracker.go b/cmd/loadtest/preconf_tracker.go index 3f4a70f38..547aaa107 100644 --- a/cmd/loadtest/preconf_tracker.go +++ b/cmd/loadtest/preconf_tracker.go @@ -28,8 +28,9 @@ type PreconfTracker struct { falsePositiveCount atomic.Uint64 confidence atomic.Uint64 - preconfDurationLock sync.Mutex - preconfDurations []time.Duration + mu sync.Mutex + preconfDurations []time.Duration + blockDiffs []uint64 } func NewPreconfTracker(client *ethclient.Client) *PreconfTracker { @@ -85,9 +86,9 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { pt.totalTasks.Add(1) if preconfStatus { pt.preconfSuccess.Add(1) - pt.preconfDurationLock.Lock() + pt.mu.Lock() pt.preconfDurations = append(pt.preconfDurations, preconfDuration) - pt.preconfDurationLock.Unlock() + pt.mu.Unlock() } else { pt.preconfFail.Add(1) } @@ -125,6 +126,9 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { // if receipt received in less than 10 blocks and preconf said // true, increase the confidence meter. if blockDiff < 10 { + pt.mu.Lock() + pt.blockDiffs = append(pt.blockDiffs, blockDiff) + pt.mu.Unlock() pt.confidence.Add(1) } } @@ -140,7 +144,7 @@ func (pt *PreconfTracker) Stats() { Uint64("confidence", pt.confidence.Load()). Msg("Preconf Tracker Stats") - pt.preconfDurationLock.Lock() + pt.mu.Lock() path := "preconf_durations" + time.Now().String() + ".csv" err := dumpDurationsCSV(path, pt.preconfDurations) if err != nil { @@ -148,7 +152,44 @@ func (pt *PreconfTracker) Stats() { } else { log.Info().Str("path", path).Msg("Dumped preconf durations into file") } - pt.preconfDurationLock.Unlock() + + path = "preconf_block_diffs" + time.Now().String() + ".csv" + err = dumpBlockDiff(path, pt.blockDiffs) + if err != nil { + log.Error().Err(err).Msg("Error dumping preconf block diffs") + } else { + log.Info().Str("path", path).Msg("Dumped preconf block diffs into file") + } + + pt.mu.Unlock() +} + +func dumpBlockDiff(path string, diffs []uint64) error { + f, err := os.Create(path) + if err != nil { + return err + } + defer f.Close() + + w := csv.NewWriter(f) + defer w.Flush() + + // header + if err := w.Write([]string{"idx", "diff"}); err != nil { + return err + } + + for i, d := range diffs { + row := []string{ + strconv.Itoa(i), + strconv.FormatUint(d, 10), + } + if err := w.Write(row); err != nil { + return err + } + } + + return w.Error() } func dumpDurationsCSV(path string, durations []time.Duration) error { From c68cd8088c1592167897883c68336f7020a43f30 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 11:30:56 -0500 Subject: [PATCH 16/27] fix: make dumping sending account private keys more generic --- cmd/loadtest/cmd.go | 1 + loadtest/account.go | 34 ++++++++++++++-------------------- loadtest/config/config.go | 1 + loadtest/runner.go | 32 ++++++++++++++++++++++++++++++++ 4 files changed, 48 insertions(+), 20 deletions(-) diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index 2c0434cca..d61ff6ba9 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -150,6 +150,7 @@ func initFlags() { f.BoolVar(&cfg.PreFundSendingAccounts, "pre-fund-sending-accounts", false, "fund all sending accounts at start instead of on first use") f.BoolVar(&cfg.RefundRemainingFunds, "refund-remaining-funds", false, "refund remaining balance to funding account after completion") f.StringVar(&cfg.SendingAccountsFile, "sending-accounts-file", "", "file with sending account private keys, one per line (avoids pool queue and preserves accounts across runs)") + f.StringVar(&cfg.DumpSendingAccountsFile, "dump-sending-accounts-file", "", "file path to dump generated private keys when using --sending-accounts-count") f.Uint64Var(&cfg.MaxBaseFeeWei, "max-base-fee-wei", 0, "maximum base fee in wei (pause sending new transactions when exceeded, useful during network congestion)") f.StringSliceVarP(&cfg.Modes, "mode", "m", []string{"t"}, `testing mode (can specify multiple like "d,t"): 2, erc20 - send ERC20 tokens diff --git a/loadtest/account.go b/loadtest/account.go index 8f815dcfc..4dd45d42b 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -6,7 +6,6 @@ import ( "errors" "fmt" "math/big" - "os" "slices" "strings" "sync" @@ -221,30 +220,25 @@ func (ap *AccountPool) AllAccountsReady() (bool, int, int) { // AddRandomN adds N random accounts to the pool. func (ap *AccountPool) AddRandomN(ctx context.Context, n uint64) error { - pks := make([]*ecdsa.PrivateKey, n) - for i := range n { - privateKey, err := crypto.GenerateKey() + for range n { + err := ap.AddRandom(ctx) if err != nil { - return fmt.Errorf("failed to generate private key: %w", err) + return fmt.Errorf("failed to add random account: %w", err) } - pks[i] = privateKey } + return nil +} - // Dump data to a file "privatekeys" - f, err := os.Create("./privatekeys") - if err != nil { - log.Error().Err(err).Msg("failed to create private keys file") - } - defer f.Close() - for _, pk := range pks { - pkStr := util.GetPrivateKeyHex(pk) - _, err := fmt.Fprintf(f, "%s\n", pkStr) - if err != nil { - return fmt.Errorf("failed to write private key: %w", err) - } - } +// GetPrivateKeys returns the private keys of all accounts in the pool. +func (ap *AccountPool) GetPrivateKeys() []*ecdsa.PrivateKey { + ap.mu.Lock() + defer ap.mu.Unlock() - return ap.AddN(ctx, pks...) + pks := make([]*ecdsa.PrivateKey, len(ap.accounts)) + for i, acc := range ap.accounts { + pks[i] = acc.privateKey + } + return pks } // AddRandom adds a random account to the pool. diff --git a/loadtest/config/config.go b/loadtest/config/config.go index 6b5775931..7e365d467 100644 --- a/loadtest/config/config.go +++ b/loadtest/config/config.go @@ -103,6 +103,7 @@ type Config struct { RefundRemainingFunds bool SendingAccountsFile string CheckBalanceBeforeFunding bool + DumpSendingAccountsFile string // Summary output ShouldProduceSummary bool diff --git a/loadtest/runner.go b/loadtest/runner.go index c89caf984..ec3fda0c1 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -310,6 +310,13 @@ func (r *Runner) initAccountPool(ctx context.Context) error { return errors.New("unable to set account pool: " + err.Error()) } + // Dump private keys to file if configured + if r.cfg.DumpSendingAccountsFile != "" { + if err := r.dumpPrivateKeys(); err != nil { + return err + } + } + // Wait for all accounts to be ready for { rdy, rdyCount, accQty := r.accountPool.AllAccountsReady() @@ -1343,6 +1350,31 @@ func (r *Runner) GetAccountPool() *AccountPool { return r.accountPool } +// dumpPrivateKeys writes the private keys of all accounts in the pool to a file. +func (r *Runner) dumpPrivateKeys() error { + pks := r.accountPool.GetPrivateKeys() + + f, err := os.Create(r.cfg.DumpSendingAccountsFile) + if err != nil { + return fmt.Errorf("failed to create private keys file: %w", err) + } + defer f.Close() + + for _, pk := range pks { + pkStr := util.GetPrivateKeyHex(pk) + if _, err := fmt.Fprintf(f, "%s\n", pkStr); err != nil { + return fmt.Errorf("failed to write private key: %w", err) + } + } + + log.Info(). + Str("file", r.cfg.DumpSendingAccountsFile). + Int("count", len(pks)). + Msg("dumped private keys to file") + + return nil +} + // Close cleans up runner resources. func (r *Runner) Close() { if r.rpcClient != nil { From fdba6a7ac631728a54ee220986e42fd2b7fdd771 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 11:44:38 -0500 Subject: [PATCH 17/27] fix: simplify wait preconf and wait receipt --- loadtest/preconf_tracker.go | 4 +-- util/receipt.go | 65 ++++++++++++++----------------------- 2 files changed, 26 insertions(+), 43 deletions(-) diff --git a/loadtest/preconf_tracker.go b/loadtest/preconf_tracker.go index 547aaa107..124bf13d8 100644 --- a/loadtest/preconf_tracker.go +++ b/loadtest/preconf_tracker.go @@ -60,7 +60,7 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { preconfDuration = time.Since(preconfStartTime) }() - preconfStatus, preconfError = util.WaitPreconf(context.Background(), pt.client, txHash) + preconfStatus, preconfError = util.WaitPreconf(context.Background(), pt.client, txHash, time.Minute) }() // wait for receipt @@ -78,7 +78,7 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { receiptDuration = time.Since(receiptTime) }() - receipt, receiptError = util.WaitReceiptNew(context.Background(), pt.client, txHash) + receipt, receiptError = util.WaitReceiptWithTimeout(context.Background(), pt.client, txHash, time.Minute) }() wg.Wait() diff --git a/util/receipt.go b/util/receipt.go index c5679a747..ab6246cdb 100644 --- a/util/receipt.go +++ b/util/receipt.go @@ -83,62 +83,45 @@ func internalWaitReceipt(ctx context.Context, client *ethclient.Client, txHash c } } -func WaitReceiptNew(ctx context.Context, client *ethclient.Client, txHash common.Hash) (*types.Receipt, error) { - var maxRetries uint = 20 - var timeout time.Duration = time.Minute - var delay time.Duration = 3 * time.Second - - // Create context with timeout - timeoutCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - for attempt := uint(0); ; attempt++ { - receipt, err := client.TransactionReceipt(timeoutCtx, txHash) - if err == nil && receipt != nil { - return receipt, nil - } - - // If maxRetries > 0 and we've reached the limit, exit - // Note: effectiveMaxRetries is always > 0 due to default above - if maxRetries > 0 && attempt >= maxRetries-1 { - return nil, fmt.Errorf("failed to get receipt after %d attempts: %w", maxRetries, err) - } - - select { - case <-timeoutCtx.Done(): - return nil, timeoutCtx.Err() - case <-time.After(delay): - // Continue - } +// WaitPreconf waits for a preconf status check with a specified timeout. +// Uses exponential backoff with jitter, similar to WaitReceiptWithTimeout. +func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash, timeout time.Duration) (bool, error) { + if timeout == 0 { + timeout = 1 * time.Minute } -} - -func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Hash) (bool, error) { - var maxRetries uint = 20 - var timeout time.Duration = time.Minute - var delay time.Duration = 1500 * time.Millisecond - // Create context with timeout timeoutCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() + const initialDelayMs = 100 + for attempt := uint(0); ; attempt++ { - var res interface{} - err := client.Client().CallContext(ctx, &res, "eth_checkPreconfStatus", txHash.Hex()) + var res any + err := client.Client().CallContext(timeoutCtx, &res, "eth_checkPreconfStatus", txHash.Hex()) if err == nil { return res.(bool), nil } - // If maxRetries > 0 and we've reached the limit, exit - // Note: effectiveMaxRetries is always > 0 due to default above - if maxRetries > 0 && attempt >= maxRetries-1 { - return false, fmt.Errorf("failed to get receipt after %d attempts: %w", maxRetries, err) + // Calculate delay with exponential backoff and jitter + baseDelay := time.Duration(initialDelayMs) * time.Millisecond + exponentialDelay := baseDelay * time.Duration(1< maxDelay { + exponentialDelay = maxDelay + } + + maxJitter := exponentialDelay / 2 + if maxJitter <= 0 { + maxJitter = 1 * time.Millisecond } + jitter := time.Duration(rand.Int63n(int64(maxJitter))) + totalDelay := exponentialDelay + jitter select { case <-timeoutCtx.Done(): return false, timeoutCtx.Err() - case <-time.After(delay): + case <-time.After(totalDelay): // Continue } } From 6d80101ae11568dd51525cb58f9f1882f1c45eb7 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 11:49:01 -0500 Subject: [PATCH 18/27] revert: multicall3 changes --- loadtest/account.go | 50 +++++++++++++++++++++++---------------------- 1 file changed, 26 insertions(+), 24 deletions(-) diff --git a/loadtest/account.go b/loadtest/account.go index 4dd45d42b..1ff0d6833 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -469,19 +469,19 @@ func (ap *AccountPool) FundAccounts(ctx context.Context) error { return errors.New(errMsg) } - // log.Debug().Msg("checking if multicall3 is supported") - // multicall3Addr, _ := util.IsMulticall3Supported(ctx, ap.client, true, tops, nil) - // if multicall3Addr != nil { - // log.Info(). - // Stringer("address", multicall3Addr). - // Msg("multicall3 is supported and will be used to fund accounts") - // } else { - // log.Info().Msg("multicall3 is not supported, will use EOA transfers to fund accounts") - // } - - // if multicall3Addr != nil { - // return ap.fundAccountsWithMulticall3(ctx, tops, multicall3Addr) - // } + log.Debug().Msg("checking if multicall3 is supported") + multicall3Addr, _ := util.IsMulticall3Supported(ctx, ap.client, true, tops, nil) + if multicall3Addr != nil { + log.Info(). + Stringer("address", multicall3Addr). + Msg("multicall3 is supported and will be used to fund accounts") + } else { + log.Info().Msg("multicall3 is not supported, will use EOA transfers to fund accounts") + } + + if multicall3Addr != nil { + return ap.fundAccountsWithMulticall3(ctx, tops, multicall3Addr) + } return ap.fundAccountsWithEOATransfers(ctx, tops) } @@ -489,16 +489,14 @@ func (ap *AccountPool) fundAccountsWithMulticall3(ctx context.Context, tops *bin log.Debug(). Msg("funding sending accounts with multicall3") - // const defaultAccsToFundPerTx = 100 - // accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, ap.client) - // if err != nil { - // log.Warn().Err(err). - // Uint64("defaultAccsToFundPerTx", defaultAccsToFundPerTx). - // Msg("failed to get multicall3 max accounts to fund per tx, falling back to default") - // accsToFundPerTx = defaultAccsToFundPerTx - // } - var accsToFundPerTx uint64 = 100 - var err error + const defaultAccsToFundPerTx = 100 + accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, ap.client) + if err != nil { + log.Warn().Err(err). + Uint64("defaultAccsToFundPerTx", defaultAccsToFundPerTx). + Msg("failed to get multicall3 max accounts to fund per tx, falling back to default") + accsToFundPerTx = defaultAccsToFundPerTx + } log.Debug().Uint64("accsToFundPerTx", accsToFundPerTx).Msg("multicall3 max accounts to fund per tx") chSize := (uint64(len(ap.accounts)) / accsToFundPerTx) + 1 @@ -1195,8 +1193,12 @@ func (ap *AccountPool) createEOATransferTx(ctx context.Context, sender *ecdsa.Pr Data: nil, Value: amount, } - log.Info().Uint64("gasFeeCap", dynamicFeeTx.GasFeeCap.Uint64()).Uint64("gasTipCap", dynamicFeeTx.GasTipCap.Uint64()).Msg("Creating fund tx") tx = types.NewTx(dynamicFeeTx) + + log.Info(). + Uint64("gasFeeCap", dynamicFeeTx.GasFeeCap.Uint64()). + Uint64("gasTipCap", dynamicFeeTx.GasTipCap.Uint64()). + Msg("Creating fund tx") } signedTx, err := tops.Signer(tops.From, tx) From b0f2ec052741b4e514a6bd2ad8a39abf35d385aa Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 11:49:34 -0500 Subject: [PATCH 19/27] revert: default accounts to fund per tx --- loadtest/account.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loadtest/account.go b/loadtest/account.go index 1ff0d6833..15fd39445 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -489,7 +489,7 @@ func (ap *AccountPool) fundAccountsWithMulticall3(ctx context.Context, tops *bin log.Debug(). Msg("funding sending accounts with multicall3") - const defaultAccsToFundPerTx = 100 + const defaultAccsToFundPerTx = 400 accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, ap.client) if err != nil { log.Warn().Err(err). From 6a2aa562c33cd870d7a79a8a4900da5b8af4906c Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 11:53:11 -0500 Subject: [PATCH 20/27] feat: add accounts per funding tx flag --- cmd/fund/cmd.go | 4 ++++ cmd/fund/fund.go | 10 ++++++---- cmd/loadtest/cmd.go | 1 + loadtest/account.go | 15 +++++++++++---- loadtest/config/config.go | 1 + loadtest/runner.go | 1 + 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/cmd/fund/cmd.go b/cmd/fund/cmd.go index 1905e04f4..700979644 100644 --- a/cmd/fund/cmd.go +++ b/cmd/fund/cmd.go @@ -43,6 +43,9 @@ type cmdFundParams struct { TokenAmount *big.Int ApproveSpender string ApproveAmount *big.Int + + // Multicall3 batching + AccountsPerFundingTx uint64 } var ( @@ -117,6 +120,7 @@ func init() { // Contract parameters. f.StringVar(¶ms.FunderAddress, "funder-address", "", "address of pre-deployed funder contract") f.StringVar(¶ms.Multicall3Address, "multicall3-address", "", "address of pre-deployed multicall3 contract") + f.Uint64Var(¶ms.AccountsPerFundingTx, "accounts-per-funding-tx", 400, "number of accounts to fund per multicall3 transaction") // RPC parameters. f.Float64Var(¶ms.RateLimit, "rate-limit", 4, "requests per second limit (use negative value to remove limit)") diff --git a/cmd/fund/fund.go b/cmd/fund/fund.go index 4d63c0c1a..a7f4d39d3 100644 --- a/cmd/fund/fund.go +++ b/cmd/fund/fund.go @@ -368,13 +368,15 @@ func fundWalletsWithMulticall3(ctx context.Context, c *ethclient.Client, tops *b log.Debug(). Msg("funding wallets with multicall3") - const defaultAccsToFundPerTx = 400 accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, c) if err != nil { log.Warn().Err(err). - Uint64("defaultAccsToFundPerTx", defaultAccsToFundPerTx). - Msg("failed to get multicall3 max accounts to fund per tx, falling back to default") - accsToFundPerTx = defaultAccsToFundPerTx + Uint64("fallback", params.AccountsPerFundingTx). + Msg("failed to get multicall3 max accounts to fund per tx, falling back to flag value") + accsToFundPerTx = params.AccountsPerFundingTx + } + if params.AccountsPerFundingTx > 0 && params.AccountsPerFundingTx < accsToFundPerTx { + accsToFundPerTx = params.AccountsPerFundingTx } log.Debug().Uint64("accsToFundPerTx", accsToFundPerTx).Msg("multicall3 max accounts to fund per tx") chSize := (uint64(len(wallets)) / accsToFundPerTx) + 1 diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index d61ff6ba9..fdc439c9c 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -151,6 +151,7 @@ func initFlags() { f.BoolVar(&cfg.RefundRemainingFunds, "refund-remaining-funds", false, "refund remaining balance to funding account after completion") f.StringVar(&cfg.SendingAccountsFile, "sending-accounts-file", "", "file with sending account private keys, one per line (avoids pool queue and preserves accounts across runs)") f.StringVar(&cfg.DumpSendingAccountsFile, "dump-sending-accounts-file", "", "file path to dump generated private keys when using --sending-accounts-count") + f.Uint64Var(&cfg.AccountsPerFundingTx, "accounts-per-funding-tx", 400, "number of accounts to fund per multicall3 transaction") f.Uint64Var(&cfg.MaxBaseFeeWei, "max-base-fee-wei", 0, "maximum base fee in wei (pause sending new transactions when exceeded, useful during network congestion)") f.StringSliceVarP(&cfg.Modes, "mode", "m", []string{"t"}, `testing mode (can specify multiple like "d,t"): 2, erc20 - send ERC20 tokens diff --git a/loadtest/account.go b/loadtest/account.go index 15fd39445..eb19cf48f 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -31,6 +31,7 @@ type AccountPoolConfig struct { RefundRemainingFunds bool CheckBalanceBeforeFunding bool LegacyTxMode bool + AccountsPerFundingTx uint64 // Gas override settings ForceGasPrice uint64 ForcePriorityGasPrice uint64 @@ -489,13 +490,19 @@ func (ap *AccountPool) fundAccountsWithMulticall3(ctx context.Context, tops *bin log.Debug(). Msg("funding sending accounts with multicall3") - const defaultAccsToFundPerTx = 400 + fallbackAccsPerTx := ap.cfg.AccountsPerFundingTx + if fallbackAccsPerTx == 0 { + fallbackAccsPerTx = 400 + } accsToFundPerTx, err := util.Multicall3MaxAccountsToFundPerTx(ctx, ap.client) if err != nil { log.Warn().Err(err). - Uint64("defaultAccsToFundPerTx", defaultAccsToFundPerTx). - Msg("failed to get multicall3 max accounts to fund per tx, falling back to default") - accsToFundPerTx = defaultAccsToFundPerTx + Uint64("fallback", fallbackAccsPerTx). + Msg("failed to get multicall3 max accounts to fund per tx, falling back to flag value") + accsToFundPerTx = fallbackAccsPerTx + } + if fallbackAccsPerTx > 0 && fallbackAccsPerTx < accsToFundPerTx { + accsToFundPerTx = fallbackAccsPerTx } log.Debug().Uint64("accsToFundPerTx", accsToFundPerTx).Msg("multicall3 max accounts to fund per tx") chSize := (uint64(len(ap.accounts)) / accsToFundPerTx) + 1 diff --git a/loadtest/config/config.go b/loadtest/config/config.go index 7e365d467..f594d9084 100644 --- a/loadtest/config/config.go +++ b/loadtest/config/config.go @@ -104,6 +104,7 @@ type Config struct { SendingAccountsFile string CheckBalanceBeforeFunding bool DumpSendingAccountsFile string + AccountsPerFundingTx uint64 // Summary output ShouldProduceSummary bool diff --git a/loadtest/runner.go b/loadtest/runner.go index ec3fda0c1..201e574f7 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -259,6 +259,7 @@ func (r *Runner) initAccountPool(ctx context.Context) error { RefundRemainingFunds: r.cfg.RefundRemainingFunds, CheckBalanceBeforeFunding: r.cfg.CheckBalanceBeforeFunding, LegacyTxMode: r.cfg.LegacyTxMode, + AccountsPerFundingTx: r.cfg.AccountsPerFundingTx, ForceGasPrice: r.cfg.ForceGasPrice, ForcePriorityGasPrice: r.cfg.ForcePriorityGasPrice, GasPriceMultiplier: r.cfg.BigGasPriceMultiplier, From f855a86edda881477061627d4e74eb2839354dd8 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 12:00:52 -0500 Subject: [PATCH 21/27] chore: simplify code --- loadtest/account.go | 5 --- loadtest/preconf_tracker.go | 61 ++++++++++++++++--------------------- util/receipt.go | 7 ++++- 3 files changed, 33 insertions(+), 40 deletions(-) diff --git a/loadtest/account.go b/loadtest/account.go index eb19cf48f..ed092856c 100644 --- a/loadtest/account.go +++ b/loadtest/account.go @@ -1201,11 +1201,6 @@ func (ap *AccountPool) createEOATransferTx(ctx context.Context, sender *ecdsa.Pr Value: amount, } tx = types.NewTx(dynamicFeeTx) - - log.Info(). - Uint64("gasFeeCap", dynamicFeeTx.GasFeeCap.Uint64()). - Uint64("gasTipCap", dynamicFeeTx.GasTipCap.Uint64()). - Msg("Creating fund tx") } signedTx, err := tops.Signer(tops.From, tx) diff --git a/loadtest/preconf_tracker.go b/loadtest/preconf_tracker.go index 124bf13d8..e44c8593d 100644 --- a/loadtest/preconf_tracker.go +++ b/loadtest/preconf_tracker.go @@ -93,38 +93,27 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { pt.preconfFail.Add(1) } - // both failed case. no tx inclusion in txpool or block - if preconfError != nil && receiptError != nil { + switch { + case preconfError != nil && receiptError != nil: + // Both failed: no tx inclusion in txpool or block pt.bothFailedCount.Add(1) - } - // both result arrived - if preconfError == nil && receiptError == nil { - // receipt arrived early before preconf suggesting that - // preconf wasn't effective. - if preconfDuration > receiptDuration { - pt.uneffectivePreconf.Add(1) - } - } + case preconfError == nil && receiptError != nil: + // False positive: preconf said tx is included but never got executed + pt.falsePositiveCount.Add(1) - // receipt arrived but preconf failed suggesting that - // preconf wasn't effective - if receiptError == nil && preconfError != nil { + case preconfError != nil && receiptError == nil: + // Receipt arrived but preconf failed: preconf wasn't effective pt.uneffectivePreconf.Add(1) - } - - // false positive. preconf said tx is included but never got executed. - // not most accurate as we only check for receipts for 1m and not forever - if preconfError == nil && receiptError != nil { - pt.falsePositiveCount.Add(1) - } - // both result arrived - if preconfError == nil && receiptError == nil { - // after how many blocks did the tx got mined + case preconfError == nil && receiptError == nil: + // Both succeeded + if preconfDuration > receiptDuration { + // Receipt arrived before preconf: preconf wasn't effective + pt.uneffectivePreconf.Add(1) + } + // Track block diff for confidence blockDiff := receipt.BlockNumber.Uint64() - currentBlock - // if receipt received in less than 10 blocks and preconf said - // true, increase the confidence meter. if blockDiff < 10 { pt.mu.Lock() pt.blockDiffs = append(pt.blockDiffs, blockDiff) @@ -144,24 +133,28 @@ func (pt *PreconfTracker) Stats() { Uint64("confidence", pt.confidence.Load()). Msg("Preconf Tracker Stats") + // Copy data under lock, then write files without holding lock pt.mu.Lock() - path := "preconf_durations" + time.Now().String() + ".csv" - err := dumpDurationsCSV(path, pt.preconfDurations) - if err != nil { + durations := make([]time.Duration, len(pt.preconfDurations)) + copy(durations, pt.preconfDurations) + blockDiffs := make([]uint64, len(pt.blockDiffs)) + copy(blockDiffs, pt.blockDiffs) + pt.mu.Unlock() + + timestamp := time.Now().Format("20060102_150405") + path := "preconf_durations_" + timestamp + ".csv" + if err := dumpDurationsCSV(path, durations); err != nil { log.Error().Err(err).Msg("Error dumping preconf durations") } else { log.Info().Str("path", path).Msg("Dumped preconf durations into file") } - path = "preconf_block_diffs" + time.Now().String() + ".csv" - err = dumpBlockDiff(path, pt.blockDiffs) - if err != nil { + path = "preconf_block_diffs_" + timestamp + ".csv" + if err := dumpBlockDiff(path, blockDiffs); err != nil { log.Error().Err(err).Msg("Error dumping preconf block diffs") } else { log.Info().Str("path", path).Msg("Dumped preconf block diffs into file") } - - pt.mu.Unlock() } func dumpBlockDiff(path string, diffs []uint64) error { diff --git a/util/receipt.go b/util/receipt.go index ab6246cdb..a49c0a1ec 100644 --- a/util/receipt.go +++ b/util/receipt.go @@ -94,6 +94,10 @@ func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Ha defer cancel() const initialDelayMs = 100 + timer := time.NewTimer(0) + defer timer.Stop() + // Drain initial timer since we want to check immediately on first iteration + <-timer.C for attempt := uint(0); ; attempt++ { var res any @@ -118,10 +122,11 @@ func WaitPreconf(ctx context.Context, client *ethclient.Client, txHash common.Ha jitter := time.Duration(rand.Int63n(int64(maxJitter))) totalDelay := exponentialDelay + jitter + timer.Reset(totalDelay) select { case <-timeoutCtx.Done(): return false, timeoutCtx.Err() - case <-time.After(totalDelay): + case <-timer.C: // Continue } } From e38401b947e2fccaa6ca816205030aaaf68f6f39 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 12:11:36 -0500 Subject: [PATCH 22/27] fix: flag for preconf stats file --- cmd/loadtest/cmd.go | 1 + loadtest/config/config.go | 1 + loadtest/preconf_tracker.go | 24 +++++++++++++++--------- loadtest/runner.go | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index fdc439c9c..6024b44df 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -122,6 +122,7 @@ func initPersistentFlags() { pf.BoolVar(&cfg.FireAndForget, "fire-and-forget", false, "send transactions and load without waiting for it to be mined") pf.BoolVar(&cfg.FireAndForget, "send-only", false, "alias for --fire-and-forget") pf.BoolVar(&cfg.CheckForPreconf, "check-preconf", false, "check for preconf status after sending tx") + pf.StringVar(&cfg.PreconfStatsFile, "preconf-stats-file", "", "base path for preconf stats output files (e.g., 'preconf' creates preconf_durations.csv and preconf_block_diffs.csv)") initGasManagerFlags() } diff --git a/loadtest/config/config.go b/loadtest/config/config.go index f594d9084..67ecc023f 100644 --- a/loadtest/config/config.go +++ b/loadtest/config/config.go @@ -54,6 +54,7 @@ type Config struct { LegacyTxMode bool FireAndForget bool CheckForPreconf bool + PreconfStatsFile string WaitForReceipt bool ReceiptRetryMax uint ReceiptRetryDelay uint // initial delay in milliseconds diff --git a/loadtest/preconf_tracker.go b/loadtest/preconf_tracker.go index e44c8593d..e2a2bcba7 100644 --- a/loadtest/preconf_tracker.go +++ b/loadtest/preconf_tracker.go @@ -17,14 +17,15 @@ import ( ) type PreconfTracker struct { - client *ethclient.Client + client *ethclient.Client + statsFilePath string // metrics preconfSuccess atomic.Uint64 preconfFail atomic.Uint64 totalTasks atomic.Uint64 bothFailedCount atomic.Uint64 - uneffectivePreconf atomic.Uint64 + ineffectivePreconf atomic.Uint64 falsePositiveCount atomic.Uint64 confidence atomic.Uint64 @@ -33,9 +34,10 @@ type PreconfTracker struct { blockDiffs []uint64 } -func NewPreconfTracker(client *ethclient.Client) *PreconfTracker { +func NewPreconfTracker(client *ethclient.Client, statsFilePath string) *PreconfTracker { return &PreconfTracker{ client: client, + statsFilePath: statsFilePath, preconfDurations: make([]time.Duration, 0, 1024), } } @@ -104,13 +106,13 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { case preconfError != nil && receiptError == nil: // Receipt arrived but preconf failed: preconf wasn't effective - pt.uneffectivePreconf.Add(1) + pt.ineffectivePreconf.Add(1) case preconfError == nil && receiptError == nil: // Both succeeded if preconfDuration > receiptDuration { // Receipt arrived before preconf: preconf wasn't effective - pt.uneffectivePreconf.Add(1) + pt.ineffectivePreconf.Add(1) } // Track block diff for confidence blockDiff := receipt.BlockNumber.Uint64() - currentBlock @@ -128,11 +130,15 @@ func (pt *PreconfTracker) Stats() { Uint64("preconf_success", pt.preconfSuccess.Load()). Uint64("preconf_fail", pt.preconfFail.Load()). Uint64("both_failed", pt.bothFailedCount.Load()). - Uint64("uneffective_preconf", pt.uneffectivePreconf.Load()). + Uint64("ineffective_preconf", pt.ineffectivePreconf.Load()). Uint64("false_positives", pt.falsePositiveCount.Load()). Uint64("confidence", pt.confidence.Load()). Msg("Preconf Tracker Stats") + if pt.statsFilePath == "" { + return + } + // Copy data under lock, then write files without holding lock pt.mu.Lock() durations := make([]time.Duration, len(pt.preconfDurations)) @@ -141,15 +147,15 @@ func (pt *PreconfTracker) Stats() { copy(blockDiffs, pt.blockDiffs) pt.mu.Unlock() - timestamp := time.Now().Format("20060102_150405") - path := "preconf_durations_" + timestamp + ".csv" + timestamp := time.Now().Format(time.RFC3339) + path := pt.statsFilePath + "_durations_" + timestamp + ".csv" if err := dumpDurationsCSV(path, durations); err != nil { log.Error().Err(err).Msg("Error dumping preconf durations") } else { log.Info().Str("path", path).Msg("Dumped preconf durations into file") } - path = "preconf_block_diffs_" + timestamp + ".csv" + path = pt.statsFilePath + "_block_diffs_" + timestamp + ".csv" if err := dumpBlockDiff(path, blockDiffs); err != nil { log.Error().Err(err).Msg("Error dumping preconf block diffs") } else { diff --git a/loadtest/runner.go b/loadtest/runner.go index 201e574f7..c04b5d648 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -130,7 +130,7 @@ func (r *Runner) Init(ctx context.Context) error { // Initialize preconf tracker if configured if r.cfg.CheckForPreconf { - r.preconfTracker = NewPreconfTracker(r.client) + r.preconfTracker = NewPreconfTracker(r.client, r.cfg.PreconfStatsFile) log.Info().Msg("Preconf tracker initialized") } From 0719923d98235f4f08f1367c1de8fb39869a47e5 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 12:12:41 -0500 Subject: [PATCH 23/27] revert: multicall3 tops --- util/multicall3.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/util/multicall3.go b/util/multicall3.go index 66659483d..0479fbd49 100644 --- a/util/multicall3.go +++ b/util/multicall3.go @@ -206,8 +206,6 @@ func Multicall3FundAccountsWithNativeToken(c *ethclient.Client, tops *bind.Trans } tops.Value = big.NewInt(0).Mul(amount, big.NewInt(int64(len(accounts)))) - tops.GasFeeCap = big.NewInt(25000000000) // 25 Gwei - tops.GasTipCap = big.NewInt(25000000000) // 25 Gwei return sc.Aggregate3Value(tops, calls) } From 89dbf6833bba01815841af0fa7377746b1eb0091 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 12:27:01 -0500 Subject: [PATCH 24/27] fix: cleanup fund --- cmd/fund/fund.go | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/cmd/fund/fund.go b/cmd/fund/fund.go index a7f4d39d3..b933e967a 100644 --- a/cmd/fund/fund.go +++ b/cmd/fund/fund.go @@ -63,7 +63,7 @@ func runFunding(ctx context.Context) error { } else if len(params.Seed) > 0 { // get addresses from seed addresses, privateKeys, err = getAddressesAndKeysFromSeed(params.Seed, int(params.WalletsNumber)) } else { // get addresses from private key - addresses, privateKeys, err = getAddressesAndKeysFromPrivateKey(ctx, c) + addresses, privateKeys, err = getAddressesAndKeysFromPrivateKey() } // check errors after getting addresses if err != nil { @@ -141,7 +141,7 @@ func getAddressesAndKeysFromKeyFile(keyFilePath string) ([]common.Address, []*ec return addresses, privateKeys, nil } -func getAddressesAndKeysFromPrivateKey(ctx context.Context, c *ethclient.Client) ([]common.Address, []*ecdsa.PrivateKey, error) { +func getAddressesAndKeysFromPrivateKey() ([]common.Address, []*ecdsa.PrivateKey, error) { // Derive or generate a set of wallets. var addresses []common.Address var privateKeys []*ecdsa.PrivateKey @@ -276,7 +276,7 @@ func generateWalletsWithKeys(n int) ([]common.Address, []*ecdsa.PrivateKey, erro // Generate private keys. privateKeys := make([]*ecdsa.PrivateKey, n) addresses := make([]common.Address, n) - for i := 0; i < n; i++ { + for i := range n { pk, err := crypto.GenerateKey() if err != nil { log.Error().Err(err).Msg("Error generating key") @@ -320,7 +320,7 @@ func saveToFile(fileName string, privateKeys []*ecdsa.PrivateKey) error { } // fundWallets funds multiple wallets using the provided Funder contract. -func fundWallets(ctx context.Context, c *ethclient.Client, tops *bind.TransactOpts, contract *funder.Funder, wallets []common.Address) error { +func fundWallets(tops *bind.TransactOpts, contract *funder.Funder, wallets []common.Address) error { // Fund wallets. switch len(wallets) { case 0: @@ -346,7 +346,7 @@ func fundWalletsWithFunder(ctx context.Context, c *ethclient.Client, tops *bind. // If ERC20 mode is enabled, fund with tokens instead of ETH if params.TokenAddress != "" { log.Info().Str("tokenAddress", params.TokenAddress).Msg("Starting ERC20 token funding (ETH funding disabled)") - if err = fundWalletsWithERC20(ctx, c, tops, privateKey, addresses, privateKeys); err != nil { + if err = fundWalletsWithERC20(ctx, c, tops, addresses, privateKeys); err != nil { return err } log.Info().Msg("Wallet(s) funded with ERC20 tokens! 🪙") @@ -357,7 +357,7 @@ func fundWalletsWithFunder(ctx context.Context, c *ethclient.Client, tops *bind. if err != nil { return err } - if err = fundWallets(ctx, c, tops, contract, addresses); err != nil { + if err = fundWallets(tops, contract, addresses); err != nil { return err } } @@ -396,7 +396,7 @@ func fundWalletsWithMulticall3(ctx context.Context, c *ethclient.Client, tops *b if params.RateLimit <= 0.0 { rl = nil } - for i := 0; i < len(wallets); i++ { + for i := range wallets { wallet := wallets[i] // if account is the funding account, skip it if wallet == tops.From { @@ -510,7 +510,7 @@ func getAddressesAndKeysFromSeed(seed string, numWallets int) ([]common.Address, addresses := make([]common.Address, numWallets) privateKeys := make([]*ecdsa.PrivateKey, numWallets) - for i := 0; i < numWallets; i++ { + for i := range numWallets { // Create a deterministic string by combining seed with index and current date // Format: seed_index_YYYYMMDD (e.g., "ephemeral_test_0_20241010") currentDate := time.Now().Format("20060102") // YYYYMMDD format @@ -541,7 +541,7 @@ func getAddressesAndKeysFromSeed(seed string, numWallets int) ([]common.Address, } // fundWalletsWithERC20 funds multiple wallets with ERC20 tokens by minting directly to each wallet and optionally approving a spender. -func fundWalletsWithERC20(ctx context.Context, c *ethclient.Client, tops *bind.TransactOpts, privateKey *ecdsa.PrivateKey, wallets []common.Address, walletsPrivateKeys []*ecdsa.PrivateKey) error { +func fundWalletsWithERC20(ctx context.Context, c *ethclient.Client, tops *bind.TransactOpts, wallets []common.Address, walletsPrivateKeys []*ecdsa.PrivateKey) error { if len(wallets) == 0 { return errors.New("no wallet to fund with ERC20 tokens") } From be6ed7d404b5c244deafdcdb8de9199e7865f826 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 15:14:55 -0500 Subject: [PATCH 25/27] feat: output to json --- cmd/loadtest/cmd.go | 2 +- doc/polycli_fund.md | 35 +++--- doc/polycli_loadtest.md | 4 + doc/polycli_loadtest_uniswapv3.md | 2 + loadtest/preconf_tracker.go | 184 ++++++++++++++++-------------- 5 files changed, 123 insertions(+), 104 deletions(-) diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index 6024b44df..0edeb71c0 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -122,7 +122,7 @@ func initPersistentFlags() { pf.BoolVar(&cfg.FireAndForget, "fire-and-forget", false, "send transactions and load without waiting for it to be mined") pf.BoolVar(&cfg.FireAndForget, "send-only", false, "alias for --fire-and-forget") pf.BoolVar(&cfg.CheckForPreconf, "check-preconf", false, "check for preconf status after sending tx") - pf.StringVar(&cfg.PreconfStatsFile, "preconf-stats-file", "", "base path for preconf stats output files (e.g., 'preconf' creates preconf_durations.csv and preconf_block_diffs.csv)") + pf.StringVar(&cfg.PreconfStatsFile, "preconf-stats-file", "", "base path for preconf stats JSON output (e.g., 'preconf' creates preconf-{timestamp}.json)") initGasManagerFlags() } diff --git a/doc/polycli_fund.md b/doc/polycli_fund.md index 3930d8c0f..66fb545aa 100644 --- a/doc/polycli_fund.md +++ b/doc/polycli_fund.md @@ -84,23 +84,24 @@ $ cast balance 0x5D8121cf716B70d3e345adB58157752304eED5C3 ## Flags ```bash - --addresses strings comma-separated list of wallet addresses to fund - --approve-amount big.Int amount of ERC20 tokens to approve for the spender (default 1000000000000000000000) - --approve-spender string address to approve for spending tokens from each funded wallet - --eth-amount big.Int amount of wei to send to each wallet (default 50000000000000000) - -f, --file string output JSON file path for storing addresses and private keys of funded wallets (default "wallets.json") - --funder-address string address of pre-deployed funder contract - --hd-derivation derive wallets to fund from private key in deterministic way (default true) - -h, --help help for fund - --key-file string file containing accounts private keys, one per line - --multicall3-address string address of pre-deployed multicall3 contract - -n, --number uint number of wallets to fund (default 10) - --private-key string hex encoded private key to use for sending transactions (default "0x42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") - --rate-limit float requests per second limit (use negative value to remove limit) (default 4) - -r, --rpc-url string RPC endpoint URL (default "http://localhost:8545") - --seed string seed string for deterministic wallet generation (e.g., 'ephemeral_test') - --token-address string address of the ERC20 token contract to mint and fund (if provided, enables ERC20 mode) - --token-amount big.Int amount of ERC20 tokens to transfer from private-key wallet to each wallet (default 1000000000000000000) + --accounts-per-funding-tx uint number of accounts to fund per multicall3 transaction (default 400) + --addresses strings comma-separated list of wallet addresses to fund + --approve-amount big.Int amount of ERC20 tokens to approve for the spender (default 1000000000000000000000) + --approve-spender string address to approve for spending tokens from each funded wallet + --eth-amount big.Int amount of wei to send to each wallet (default 50000000000000000) + -f, --file string output JSON file path for storing addresses and private keys of funded wallets (default "wallets.json") + --funder-address string address of pre-deployed funder contract + --hd-derivation derive wallets to fund from private key in deterministic way (default true) + -h, --help help for fund + --key-file string file containing accounts private keys, one per line + --multicall3-address string address of pre-deployed multicall3 contract + -n, --number uint number of wallets to fund (default 10) + --private-key string hex encoded private key to use for sending transactions (default "0x42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") + --rate-limit float requests per second limit (use negative value to remove limit) (default 4) + -r, --rpc-url string RPC endpoint URL (default "http://localhost:8545") + --seed string seed string for deterministic wallet generation (e.g., 'ephemeral_test') + --token-address string address of the ERC20 token contract to mint and fund (if provided, enables ERC20 mode) + --token-amount big.Int amount of ERC20 tokens to transfer from private-key wallet to each wallet (default 1000000000000000000) ``` The command also inherits flags from parent commands. diff --git a/doc/polycli_loadtest.md b/doc/polycli_loadtest.md index ff1c7ec74..355cc36bc 100644 --- a/doc/polycli_loadtest.md +++ b/doc/polycli_loadtest.md @@ -107,6 +107,7 @@ The codebase has a contract that used for load testing. It's written in Solidity ```bash --account-funding-amount big.Int amount in wei to fund sending accounts (set to 0 to disable) + --accounts-per-funding-tx uint number of accounts to fund per multicall3 transaction (default 400) --adaptive-backoff-factor float multiplicative decrease factor for adaptive rate limiting (default 2) --adaptive-cycle-duration-seconds uint interval in seconds to check queue size and adjust rates for adaptive rate limiting (default 10) --adaptive-rate-limit enable AIMD-style congestion control to automatically adjust request rate @@ -118,9 +119,11 @@ The codebase has a contract that used for load testing. It's written in Solidity --calldata string hex encoded calldata: function signature + encoded arguments (requires --mode contract-call and --contract-address) --chain-id uint chain ID for the transactions --check-balance-before-funding check account balance before funding sending accounts (saves gas when accounts are already funded) + --check-preconf check for preconf status after sending tx -c, --concurrency int number of requests to perform concurrently (default: one at a time) (default 1) --contract-address string contract address for --mode contract-call (requires --calldata) --contract-call-payable mark function as payable using value from --eth-amount-in-wei (requires --mode contract-call and --contract-address) + --dump-sending-accounts-file string file path to dump generated private keys when using --sending-accounts-count --erc20-address string address of pre-deployed ERC20 contract --erc721-address string address of pre-deployed ERC721 contract --eth-amount-in-wei uint amount of ether in wei to send per transaction @@ -159,6 +162,7 @@ The codebase has a contract that used for load testing. It's written in Solidity --output-mode string format mode for summary output (json | text) (default "text") --output-raw-tx-only output raw signed transaction hex without sending (works with most modes except RPC and UniswapV3) --pre-fund-sending-accounts fund all sending accounts at start instead of on first use + --preconf-stats-file string base path for preconf stats JSON output (e.g., 'preconf' creates preconf-{timestamp}.json) --priority-gas-price uint gas tip price for EIP-1559 transactions --private-key string hex encoded private key to use for sending transactions (default "42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") --proxy string use the proxy specified diff --git a/doc/polycli_loadtest_uniswapv3.md b/doc/polycli_loadtest_uniswapv3.md index 127a28f67..a608a821b 100644 --- a/doc/polycli_loadtest_uniswapv3.md +++ b/doc/polycli_loadtest_uniswapv3.md @@ -83,6 +83,7 @@ The command also inherits flags from parent commands. --adaptive-target-size uint target queue size for adaptive rate limiting (speed up if smaller, back off if larger) (default 1000) --batch-size uint batch size for receipt fetching (default: 999) (default 999) --chain-id uint chain ID for the transactions + --check-preconf check for preconf status after sending tx -c, --concurrency int number of requests to perform concurrently (default: one at a time) (default 1) --config string config file (default is $HOME/.polygon-cli.yaml) --eth-amount-in-wei uint amount of ether in wei to send per transaction @@ -104,6 +105,7 @@ The command also inherits flags from parent commands. --nonce uint use this flag to manually set the starting nonce --output-mode string format mode for summary output (json | text) (default "text") --output-raw-tx-only output raw signed transaction hex without sending (works with most modes except RPC and UniswapV3) + --preconf-stats-file string base path for preconf stats JSON output (e.g., 'preconf' creates preconf-{timestamp}.json) --pretty-logs output logs in pretty format instead of JSON (default true) --priority-gas-price uint gas tip price for EIP-1559 transactions --private-key string hex encoded private key to use for sending transactions (default "42b6e34dc21598a807dc19d7784c71b2a7a01f6480dc6f58258f78e539f1a1fa") diff --git a/loadtest/preconf_tracker.go b/loadtest/preconf_tracker.go index e2a2bcba7..9d443fc91 100644 --- a/loadtest/preconf_tracker.go +++ b/loadtest/preconf_tracker.go @@ -2,9 +2,8 @@ package loadtest import ( "context" - "encoding/csv" + "encoding/json" "os" - "strconv" "sync" "sync/atomic" "time" @@ -16,11 +15,41 @@ import ( "github.com/rs/zerolog/log" ) +// PreconfTxResult holds per-transaction preconf and receipt data. +type PreconfTxResult struct { + TxHash string `json:"tx_hash"` + PreconfDurationMs int64 `json:"preconf_duration_ms,omitempty"` + ReceiptDurationMs int64 `json:"receipt_duration_ms,omitempty"` + BlockDiff uint64 `json:"block_diff,omitempty"` + GasUsed uint64 `json:"gas_used,omitempty"` + Status uint64 `json:"status,omitempty"` // 1 = success, 0 = fail +} + +// PreconfSummary holds aggregate stats from the preconf tracker. +type PreconfSummary struct { + TotalTasks uint64 `json:"total_tasks"` + PreconfSuccess uint64 `json:"preconf_success"` + PreconfFail uint64 `json:"preconf_fail"` + BothFailed uint64 `json:"both_failed"` + IneffectivePreconf uint64 `json:"ineffective_preconf"` + FalsePositives uint64 `json:"false_positives"` + Confidence uint64 `json:"confidence"` + ReceiptSuccess uint64 `json:"receipt_success"` + ReceiptFail uint64 `json:"receipt_fail"` + TotalGasUsed uint64 `json:"total_gas_used"` +} + +// PreconfStats is the JSON output structure containing summary and per-tx data. +type PreconfStats struct { + Summary PreconfSummary `json:"summary"` + Transactions []PreconfTxResult `json:"transactions"` +} + type PreconfTracker struct { client *ethclient.Client statsFilePath string - // metrics + // preconf metrics preconfSuccess atomic.Uint64 preconfFail atomic.Uint64 totalTasks atomic.Uint64 @@ -29,16 +58,20 @@ type PreconfTracker struct { falsePositiveCount atomic.Uint64 confidence atomic.Uint64 - mu sync.Mutex - preconfDurations []time.Duration - blockDiffs []uint64 + // receipt metrics + receiptSuccess atomic.Uint64 + receiptFail atomic.Uint64 + totalGasUsed atomic.Uint64 + + mu sync.Mutex + txResults []PreconfTxResult } func NewPreconfTracker(client *ethclient.Client, statsFilePath string) *PreconfTracker { return &PreconfTracker{ - client: client, - statsFilePath: statsFilePath, - preconfDurations: make([]time.Duration, 0, 1024), + client: client, + statsFilePath: statsFilePath, + txResults: make([]PreconfTxResult, 0, 1024), } } @@ -85,16 +118,36 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { wg.Wait() + // Build per-transaction result + result := PreconfTxResult{ + TxHash: txHash.Hex(), + } + pt.totalTasks.Add(1) if preconfStatus { pt.preconfSuccess.Add(1) - pt.mu.Lock() - pt.preconfDurations = append(pt.preconfDurations, preconfDuration) - pt.mu.Unlock() + result.PreconfDurationMs = preconfDuration.Milliseconds() } else { pt.preconfFail.Add(1) } + // Track receipt metrics + if receiptError == nil { + pt.receiptSuccess.Add(1) + pt.totalGasUsed.Add(receipt.GasUsed) + result.ReceiptDurationMs = receiptDuration.Milliseconds() + result.GasUsed = receipt.GasUsed + result.Status = receipt.Status + result.BlockDiff = receipt.BlockNumber.Uint64() - currentBlock + } else { + pt.receiptFail.Add(1) + } + + // Append result under lock + pt.mu.Lock() + pt.txResults = append(pt.txResults, result) + pt.mu.Unlock() + switch { case preconfError != nil && receiptError != nil: // Both failed: no tx inclusion in txpool or block @@ -114,12 +167,8 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { // Receipt arrived before preconf: preconf wasn't effective pt.ineffectivePreconf.Add(1) } - // Track block diff for confidence - blockDiff := receipt.BlockNumber.Uint64() - currentBlock - if blockDiff < 10 { - pt.mu.Lock() - pt.blockDiffs = append(pt.blockDiffs, blockDiff) - pt.mu.Unlock() + // Track confidence (block diff < 10) + if result.BlockDiff < 10 { pt.confidence.Add(1) } } @@ -133,89 +182,52 @@ func (pt *PreconfTracker) Stats() { Uint64("ineffective_preconf", pt.ineffectivePreconf.Load()). Uint64("false_positives", pt.falsePositiveCount.Load()). Uint64("confidence", pt.confidence.Load()). + Uint64("receipt_success", pt.receiptSuccess.Load()). + Uint64("receipt_fail", pt.receiptFail.Load()). + Uint64("total_gas_used", pt.totalGasUsed.Load()). Msg("Preconf Tracker Stats") if pt.statsFilePath == "" { return } - // Copy data under lock, then write files without holding lock + // Copy txResults under lock pt.mu.Lock() - durations := make([]time.Duration, len(pt.preconfDurations)) - copy(durations, pt.preconfDurations) - blockDiffs := make([]uint64, len(pt.blockDiffs)) - copy(blockDiffs, pt.blockDiffs) + txResults := make([]PreconfTxResult, len(pt.txResults)) + copy(txResults, pt.txResults) pt.mu.Unlock() - timestamp := time.Now().Format(time.RFC3339) - path := pt.statsFilePath + "_durations_" + timestamp + ".csv" - if err := dumpDurationsCSV(path, durations); err != nil { - log.Error().Err(err).Msg("Error dumping preconf durations") - } else { - log.Info().Str("path", path).Msg("Dumped preconf durations into file") - } - - path = pt.statsFilePath + "_block_diffs_" + timestamp + ".csv" - if err := dumpBlockDiff(path, blockDiffs); err != nil { - log.Error().Err(err).Msg("Error dumping preconf block diffs") - } else { - log.Info().Str("path", path).Msg("Dumped preconf block diffs into file") - } -} - -func dumpBlockDiff(path string, diffs []uint64) error { - f, err := os.Create(path) - if err != nil { - return err - } - defer f.Close() - - w := csv.NewWriter(f) - defer w.Flush() - - // header - if err := w.Write([]string{"idx", "diff"}); err != nil { - return err - } - - for i, d := range diffs { - row := []string{ - strconv.Itoa(i), - strconv.FormatUint(d, 10), - } - if err := w.Write(row); err != nil { - return err - } + // Build JSON output + output := PreconfStats{ + Summary: PreconfSummary{ + TotalTasks: pt.totalTasks.Load(), + PreconfSuccess: pt.preconfSuccess.Load(), + PreconfFail: pt.preconfFail.Load(), + BothFailed: pt.bothFailedCount.Load(), + IneffectivePreconf: pt.ineffectivePreconf.Load(), + FalsePositives: pt.falsePositiveCount.Load(), + Confidence: pt.confidence.Load(), + ReceiptSuccess: pt.receiptSuccess.Load(), + ReceiptFail: pt.receiptFail.Load(), + TotalGasUsed: pt.totalGasUsed.Load(), + }, + Transactions: txResults, } - return w.Error() -} + // Write JSON file + timestamp := time.Now().Format(time.RFC3339) + path := pt.statsFilePath + "-" + timestamp + ".json" -func dumpDurationsCSV(path string, durations []time.Duration) error { - f, err := os.Create(path) + data, err := json.MarshalIndent(output, "", " ") if err != nil { - return err - } - defer f.Close() - - w := csv.NewWriter(f) - defer w.Flush() - - // header - if err := w.Write([]string{"idx", "duration_ns", "duration_ms"}); err != nil { - return err + log.Error().Err(err).Msg("Error marshaling preconf stats") + return } - for i, d := range durations { - row := []string{ - strconv.Itoa(i), - strconv.FormatInt(d.Nanoseconds(), 10), - strconv.FormatInt(d.Milliseconds(), 10), - } - if err := w.Write(row); err != nil { - return err - } + if err := os.WriteFile(path, data, 0644); err != nil { + log.Error().Err(err).Msg("Error writing preconf stats file") + return } - return w.Error() + log.Info().Str("path", path).Msg("Dumped preconf stats into file") } From cf51d83da8f79e8ea4374d56418f67fb6f39c570 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 15:24:28 -0500 Subject: [PATCH 26/27] fix: simplify code --- loadtest/preconf_tracker.go | 44 +++++++++++++++---------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/loadtest/preconf_tracker.go b/loadtest/preconf_tracker.go index 9d443fc91..c02cbc8a0 100644 --- a/loadtest/preconf_tracker.go +++ b/loadtest/preconf_tracker.go @@ -175,17 +175,20 @@ func (pt *PreconfTracker) Track(txHash common.Hash) { } func (pt *PreconfTracker) Stats() { - log.Info().Uint64("total_tasks", pt.totalTasks.Load()). - Uint64("preconf_success", pt.preconfSuccess.Load()). - Uint64("preconf_fail", pt.preconfFail.Load()). - Uint64("both_failed", pt.bothFailedCount.Load()). - Uint64("ineffective_preconf", pt.ineffectivePreconf.Load()). - Uint64("false_positives", pt.falsePositiveCount.Load()). - Uint64("confidence", pt.confidence.Load()). - Uint64("receipt_success", pt.receiptSuccess.Load()). - Uint64("receipt_fail", pt.receiptFail.Load()). - Uint64("total_gas_used", pt.totalGasUsed.Load()). - Msg("Preconf Tracker Stats") + summary := PreconfSummary{ + TotalTasks: pt.totalTasks.Load(), + PreconfSuccess: pt.preconfSuccess.Load(), + PreconfFail: pt.preconfFail.Load(), + BothFailed: pt.bothFailedCount.Load(), + IneffectivePreconf: pt.ineffectivePreconf.Load(), + FalsePositives: pt.falsePositiveCount.Load(), + Confidence: pt.confidence.Load(), + ReceiptSuccess: pt.receiptSuccess.Load(), + ReceiptFail: pt.receiptFail.Load(), + TotalGasUsed: pt.totalGasUsed.Load(), + } + + log.Info().Any("summary", summary).Msg("Preconf tracker stats") if pt.statsFilePath == "" { return @@ -199,18 +202,7 @@ func (pt *PreconfTracker) Stats() { // Build JSON output output := PreconfStats{ - Summary: PreconfSummary{ - TotalTasks: pt.totalTasks.Load(), - PreconfSuccess: pt.preconfSuccess.Load(), - PreconfFail: pt.preconfFail.Load(), - BothFailed: pt.bothFailedCount.Load(), - IneffectivePreconf: pt.ineffectivePreconf.Load(), - FalsePositives: pt.falsePositiveCount.Load(), - Confidence: pt.confidence.Load(), - ReceiptSuccess: pt.receiptSuccess.Load(), - ReceiptFail: pt.receiptFail.Load(), - TotalGasUsed: pt.totalGasUsed.Load(), - }, + Summary: summary, Transactions: txResults, } @@ -220,14 +212,14 @@ func (pt *PreconfTracker) Stats() { data, err := json.MarshalIndent(output, "", " ") if err != nil { - log.Error().Err(err).Msg("Error marshaling preconf stats") + log.Error().Err(err).Msg("Failed to marshal preconf stats") return } if err := os.WriteFile(path, data, 0644); err != nil { - log.Error().Err(err).Msg("Error writing preconf stats file") + log.Error().Err(err).Msg("Failed to write preconf stats file") return } - log.Info().Str("path", path).Msg("Dumped preconf stats into file") + log.Info().Str("path", path).Msg("Wrote preconf stats file") } From 34f87f64c7caa6b1c8d0a86b39304c231fd89c15 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 28 Jan 2026 16:09:16 -0500 Subject: [PATCH 27/27] refactor: rename preconf_tracker to preconf --- loadtest/{preconf_tracker.go => preconf.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename loadtest/{preconf_tracker.go => preconf.go} (100%) diff --git a/loadtest/preconf_tracker.go b/loadtest/preconf.go similarity index 100% rename from loadtest/preconf_tracker.go rename to loadtest/preconf.go