diff --git a/cmd/fund/cmd.go b/cmd/fund/cmd.go index 1905e04f..70097964 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 4d63c0c1..b933e967 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 } } @@ -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 @@ -394,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 { @@ -508,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 @@ -539,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") } diff --git a/cmd/loadtest/cmd.go b/cmd/loadtest/cmd.go index 7968b5e5..0edeb71c 100644 --- a/cmd/loadtest/cmd.go +++ b/cmd/loadtest/cmd.go @@ -121,6 +121,8 @@ func initPersistentFlags() { pf.BoolVar(&cfg.LegacyTxMode, "legacy", false, "send a legacy transaction instead of an EIP1559 transaction") 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 JSON output (e.g., 'preconf' creates preconf-{timestamp}.json)") initGasManagerFlags() } @@ -149,6 +151,8 @@ 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.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/doc/polycli_fund.md b/doc/polycli_fund.md index 3930d8c0..66fb545a 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 ff1c7ec7..355cc36b 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 127a28f6..a608a821 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/account.go b/loadtest/account.go index 2d10fbb0..ed092856 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 @@ -229,6 +230,18 @@ func (ap *AccountPool) AddRandomN(ctx context.Context, n uint64) error { return nil } +// GetPrivateKeys returns the private keys of all accounts in the pool. +func (ap *AccountPool) GetPrivateKeys() []*ecdsa.PrivateKey { + ap.mu.Lock() + defer ap.mu.Unlock() + + 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. func (ap *AccountPool) AddRandom(ctx context.Context) error { privateKey, err := crypto.GenerateKey() @@ -477,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 c6a4c2e4..67ecc023 100644 --- a/loadtest/config/config.go +++ b/loadtest/config/config.go @@ -53,6 +53,8 @@ type Config struct { RandomRecipients bool LegacyTxMode bool FireAndForget bool + CheckForPreconf bool + PreconfStatsFile string WaitForReceipt bool ReceiptRetryMax uint ReceiptRetryDelay uint // initial delay in milliseconds @@ -102,6 +104,8 @@ type Config struct { RefundRemainingFunds bool SendingAccountsFile string CheckBalanceBeforeFunding bool + DumpSendingAccountsFile string + AccountsPerFundingTx uint64 // Summary output ShouldProduceSummary bool diff --git a/loadtest/preconf.go b/loadtest/preconf.go new file mode 100644 index 00000000..c02cbc8a --- /dev/null +++ b/loadtest/preconf.go @@ -0,0 +1,225 @@ +package loadtest + +import ( + "context" + "encoding/json" + "os" + "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" +) + +// 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 + + // preconf metrics + preconfSuccess atomic.Uint64 + preconfFail atomic.Uint64 + totalTasks atomic.Uint64 + bothFailedCount atomic.Uint64 + ineffectivePreconf atomic.Uint64 + falsePositiveCount atomic.Uint64 + confidence atomic.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, + txResults: make([]PreconfTxResult, 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, time.Minute) + }() + + // 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.WaitReceiptWithTimeout(context.Background(), pt.client, txHash, time.Minute) + }() + + wg.Wait() + + // Build per-transaction result + result := PreconfTxResult{ + TxHash: txHash.Hex(), + } + + pt.totalTasks.Add(1) + if preconfStatus { + pt.preconfSuccess.Add(1) + 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 + pt.bothFailedCount.Add(1) + + case preconfError == nil && receiptError != nil: + // False positive: preconf said tx is included but never got executed + pt.falsePositiveCount.Add(1) + + case preconfError != nil && receiptError == nil: + // Receipt arrived but preconf failed: preconf wasn't effective + pt.ineffectivePreconf.Add(1) + + case preconfError == nil && receiptError == nil: + // Both succeeded + if preconfDuration > receiptDuration { + // Receipt arrived before preconf: preconf wasn't effective + pt.ineffectivePreconf.Add(1) + } + // Track confidence (block diff < 10) + if result.BlockDiff < 10 { + pt.confidence.Add(1) + } + } +} + +func (pt *PreconfTracker) 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 + } + + // Copy txResults under lock + pt.mu.Lock() + txResults := make([]PreconfTxResult, len(pt.txResults)) + copy(txResults, pt.txResults) + pt.mu.Unlock() + + // Build JSON output + output := PreconfStats{ + Summary: summary, + Transactions: txResults, + } + + // Write JSON file + timestamp := time.Now().Format(time.RFC3339) + path := pt.statsFilePath + "-" + timestamp + ".json" + + data, err := json.MarshalIndent(output, "", " ") + if err != nil { + 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("Failed to write preconf stats file") + return + } + + log.Info().Str("path", path).Msg("Wrote preconf stats file") +} diff --git a/loadtest/runner.go b/loadtest/runner.go index e15ce2df..c04b5d64 100644 --- a/loadtest/runner.go +++ b/loadtest/runner.go @@ -55,6 +55,9 @@ type Runner struct { gasVault *gasmanager.GasVault gasPricer *gasmanager.GasPricer + // Preconf tracker + preconfTracker *PreconfTracker + // Clients client *ethclient.Client rpcClient *ethrpc.Client @@ -125,6 +128,12 @@ func (r *Runner) Init(ctx context.Context) error { return err } + // Initialize preconf tracker if configured + if r.cfg.CheckForPreconf { + r.preconfTracker = NewPreconfTracker(r.client, r.cfg.PreconfStatsFile) + log.Info().Msg("Preconf tracker initialized") + } + return nil } @@ -250,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, @@ -301,6 +311,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() @@ -382,6 +399,9 @@ func (r *Runner) Run(ctx context.Context) error { log.Info().Msg("Interrupted.. Stopping load test") interrupted = true cancel() + if r.preconfTracker != nil { + r.preconfTracker.Stats() + } if r.cfg.ShouldProduceSummary { finalBlock, err := r.client.BlockNumber(ctx) if err != nil { @@ -419,6 +439,11 @@ func (r *Runner) postLoadTest(ctx context.Context) { cfg := r.cfg results := r.GetResults() + // Output preconf stats if tracker was used + if r.preconfTracker != nil { + r.preconfTracker.Stats() + } + // Always output a light summary if we have results if len(results) > 0 { startTime := results[0].RequestTime @@ -597,6 +622,11 @@ func (r *Runner) mainLoop(ctx context.Context) error { r.RecordSample(routineID, requestID, tErr, startReq, endReq, sendingTops.Nonce.Uint64()) } + // Track preconf if configured + if tErr == nil && cfg.CheckForPreconf && r.preconfTracker != nil { + go r.preconfTracker.Track(ltTxHash) + } + // Wait for receipt if configured if tErr == nil && cfg.WaitForReceipt { _, tErr = util.WaitReceiptWithRetries(ctx, r.client, ltTxHash, cfg.ReceiptRetryMax, cfg.ReceiptRetryDelay) @@ -1321,6 +1351,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 { diff --git a/util/receipt.go b/util/receipt.go index 5c7142b9..a49c0a1e 100644 --- a/util/receipt.go +++ b/util/receipt.go @@ -82,3 +82,52 @@ func internalWaitReceipt(ctx context.Context, client *ethclient.Client, txHash c } } } + +// 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 + } + + timeoutCtx, cancel := context.WithTimeout(ctx, timeout) + 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 + err := client.Client().CallContext(timeoutCtx, &res, "eth_checkPreconfStatus", txHash.Hex()) + if err == nil { + return res.(bool), nil + } + + // 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 + + timer.Reset(totalDelay) + select { + case <-timeoutCtx.Done(): + return false, timeoutCtx.Err() + case <-timer.C: + // Continue + } + } +}