Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
e5b2a48
loadtest: check for preconf after sending tx
manav2401 Dec 30, 2025
18ae4d9
util: hardcode multicall gas fee and tip cap
manav2401 Dec 30, 2025
760a1f8
util: hardcode multicall gas fee and tip cap
manav2401 Dec 30, 2025
e399bc9
util: hardcode multicall gas fee and tip cap
manav2401 Dec 30, 2025
6ed9a40
util: limit accounts to fund per tx
manav2401 Dec 30, 2025
e54199d
util: skip multicall for funding accounts
manav2401 Dec 30, 2025
e6ee695
cmd/loadtest: log
manav2401 Dec 31, 2025
add1b17
cmd/loadtest: dump private keys to file
manav2401 Dec 31, 2025
b4e1c09
cmd/loadtest: implement preconf tracker
manav2401 Jan 2, 2026
5dc3467
logs
manav2401 Jan 2, 2026
8d309dd
better setup for preconf tracker
manav2401 Jan 2, 2026
12d062f
update timeouts and retries for preconf tracker
manav2401 Jan 2, 2026
9b478a8
typo
manav2401 Jan 2, 2026
d3919e4
move metrics for better accuracy
manav2401 Jan 2, 2026
812c53e
dump block diffs
manav2401 Jan 2, 2026
0b38117
Merge branch 'main' into preconf-loadtest
minhd-vu Jan 28, 2026
c68cd80
fix: make dumping sending account private keys more generic
minhd-vu Jan 28, 2026
fdba6a7
fix: simplify wait preconf and wait receipt
minhd-vu Jan 28, 2026
6d80101
revert: multicall3 changes
minhd-vu Jan 28, 2026
b0f2ec0
revert: default accounts to fund per tx
minhd-vu Jan 28, 2026
6a2aa56
feat: add accounts per funding tx flag
minhd-vu Jan 28, 2026
f855a86
chore: simplify code
minhd-vu Jan 28, 2026
e38401b
fix: flag for preconf stats file
minhd-vu Jan 28, 2026
0719923
revert: multicall3 tops
minhd-vu Jan 28, 2026
89dbf68
fix: cleanup fund
minhd-vu Jan 28, 2026
be6ed7d
feat: output to json
minhd-vu Jan 28, 2026
cf51d83
fix: simplify code
minhd-vu Jan 28, 2026
34f87f6
refactor: rename preconf_tracker to preconf
minhd-vu Jan 28, 2026
3a2e7f5
feat: track percentiles
minhd-vu Feb 4, 2026
f4e5dff
Merge branch 'main' into preconf-loadtest
minhd-vu Feb 5, 2026
7d69ca9
fix: lint
minhd-vu Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/fund/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,9 @@ type cmdFundParams struct {
TokenAmount *big.Int
ApproveSpender string
ApproveAmount *big.Int

// Multicall3 batching
AccountsPerFundingTx uint64
}

var (
Expand Down Expand Up @@ -117,6 +120,7 @@ func init() {
// Contract parameters.
f.StringVar(&params.FunderAddress, "funder-address", "", "address of pre-deployed funder contract")
f.StringVar(&params.Multicall3Address, "multicall3-address", "", "address of pre-deployed multicall3 contract")
f.Uint64Var(&params.AccountsPerFundingTx, "accounts-per-funding-tx", 400, "number of accounts to fund per multicall3 transaction")

// RPC parameters.
f.Float64Var(&params.RateLimit, "rate-limit", 4, "requests per second limit (use negative value to remove limit)")
Expand Down
100 changes: 52 additions & 48 deletions cmd/fund/fund.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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:
Expand All @@ -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! 🪙")
Expand All @@ -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
}
}
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
}
Expand Down Expand Up @@ -575,52 +577,54 @@ func fundWalletsWithERC20(ctx context.Context, c *ethclient.Client, tops *bind.T

// If approve spender is specified, approve tokens from each wallet
if params.ApproveSpender != "" && len(walletsPrivateKeys) > 0 {
spenderAddress := common.HexToAddress(params.ApproveSpender)
log.Info().Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Starting bulk approve for all wallets")

// Create ABI for approve(address, uint256) function
approveABI, err := abi.JSON(strings.NewReader(`[{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"}]`))
if err != nil {
log.Error().Err(err).Msg("Unable to parse approve ABI")
if err := approveSpenderForWallets(ctx, c, tokenAddress, wallets, walletsPrivateKeys); err != nil {
return err
}
}

// Get chain ID for signing transactions
chainID, err := c.ChainID(ctx)
if err != nil {
log.Error().Err(err).Msg("Unable to get chain ID for approve transactions")
return err
}
return nil
}

// Approve from each wallet
for i, walletPrivateKey := range walletsPrivateKeys {
if i >= len(wallets) {
break // Safety check
}
func approveSpenderForWallets(ctx context.Context, c *ethclient.Client, tokenAddress common.Address, wallets []common.Address, walletsPrivateKeys []*ecdsa.PrivateKey) error {
spenderAddress := common.HexToAddress(params.ApproveSpender)
log.Info().Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Starting bulk approve for all wallets")

wallet := wallets[i]
log.Debug().Int("wallet", i+1).Int("total", len(wallets)).Str("address", wallet.String()).Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Approving spender from wallet")
// Create ABI for approve(address, uint256) function
approveABI, err := abi.JSON(strings.NewReader(`[{"type":"function","name":"approve","inputs":[{"name":"spender","type":"address"},{"name":"amount","type":"uint256"}],"outputs":[{"name":"","type":"bool"}],"stateMutability":"nonpayable"}]`))
if err != nil {
log.Error().Err(err).Msg("Unable to parse approve ABI")
return err
}

// Create transaction options for this wallet
walletTops, err := bind.NewKeyedTransactorWithChainID(walletPrivateKey, chainID)
if err != nil {
log.Error().Err(err).Str("wallet", wallet.String()).Msg("Unable to create transaction signer for wallet")
return err
}
chainID, err := c.ChainID(ctx)
if err != nil {
log.Error().Err(err).Msg("Unable to get chain ID for approve transactions")
return err
}

// Create bound contract for approve call
approveContract := bind.NewBoundContract(tokenAddress, approveABI, c, c, c)
approveContract := bind.NewBoundContract(tokenAddress, approveABI, c, c, c)

// Call approve(address, uint256) function from this wallet
_, err = approveContract.Transact(walletTops, "approve", spenderAddress, params.ApproveAmount)
if err != nil {
log.Error().Err(err).Str("wallet", wallet.String()).Str("spender", spenderAddress.String()).Msg("Unable to approve spender from wallet")
return err
}
for i, walletPrivateKey := range walletsPrivateKeys {
if i >= len(wallets) {
break
}

log.Info().Int("count", len(wallets)).Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Successfully approved spender for all wallets")
wallet := wallets[i]
log.Debug().Int("wallet", i+1).Int("total", len(wallets)).Str("address", wallet.String()).Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Approving spender from wallet")

walletTops, err := bind.NewKeyedTransactorWithChainID(walletPrivateKey, chainID)
if err != nil {
log.Error().Err(err).Str("wallet", wallet.String()).Msg("Unable to create transaction signer for wallet")
return err
}

_, err = approveContract.Transact(walletTops, "approve", spenderAddress, params.ApproveAmount)
if err != nil {
log.Error().Err(err).Str("wallet", wallet.String()).Str("spender", spenderAddress.String()).Msg("Unable to approve spender from wallet")
return err
}
}

log.Info().Int("count", len(wallets)).Str("spender", spenderAddress.String()).Str("amount", params.ApproveAmount.String()).Msg("Successfully approved spender for all wallets")
return nil
}
4 changes: 4 additions & 0 deletions cmd/loadtest/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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", "", "path for preconf stats JSON output, updated every 2 seconds")

initGasManagerFlags()
}
Expand Down Expand Up @@ -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
Expand Down
35 changes: 18 additions & 17 deletions doc/polycli_fund.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions doc/polycli_loadtest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 path for preconf stats JSON output, updated every 2 seconds
--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
Expand Down
Loading