Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions cmd/loadtest/cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func init() {
func initPersistentFlags() {
pf := LoadtestCmd.PersistentFlags()
pf.StringVarP(&cfg.RPCURL, flag.RPCURL, "r", flag.DefaultRPCURL, "the RPC endpoint URL")
pf.StringVar(&cfg.SendRPCURL, "send-rpc-url", "", "secondary RPC endpoint used only to broadcast transactions (eth_sendRawTransaction / eth_sendRawTransactionPrivate); all other calls use --rpc-url")
pf.Int64VarP(&cfg.Requests, "requests", "n", 1, "number of requests to perform for the benchmarking session (default of 1 leads to non-representative results)")
pf.Int64VarP(&cfg.Concurrency, "concurrency", "c", 1, "number of requests to perform concurrently (default: one at a time)")
pf.Int64VarP(&cfg.TimeLimit, "time-limit", "t", -1, "maximum seconds to spend benchmarking (default: no limit)")
Expand All @@ -117,6 +118,7 @@ func initPersistentFlags() {
pf.BoolVar(&cfg.PrivateTxs, "private-txs", false, "send transactions via eth_sendRawTransactionPrivate")
pf.Uint64Var(&cfg.EthAmountInWei, "eth-amount-in-wei", 0, "amount of ether in wei to send per transaction")
pf.Float64Var(&cfg.RateLimit, "rate-limit", 4, "requests per second limit (use negative value to remove limit)")
pf.DurationVar(&cfg.RateLimitRampDuration, "rate-limit-ramp-duration", 0, "linearly ramp rate limit from max(1% of --rate-limit, 1 TPS) to full --rate-limit over this duration (e.g. 3m; 0 disables ramp)")
pf.BoolVar(&cfg.AdaptiveRateLimit, "adaptive-rate-limit", false, "enable AIMD-style congestion control to automatically adjust request rate")
pf.Uint64Var(&cfg.AdaptiveTargetSize, "adaptive-target-size", 1000, "target queue size for adaptive rate limiting (speed up if smaller, back off if larger)")
pf.Uint64Var(&cfg.AdaptiveRateLimitIncrement, "adaptive-rate-limit-increment", 50, "size of additive increases for adaptive rate limiting")
Expand Down
13 changes: 13 additions & 0 deletions cmd/loadtest/loadtestUsage.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ Here is a simple example that runs 1000 requests at a max rate of 1 request per
$ polycli loadtest --verbosity 700 --chain-id 1256 --concurrency 1 --requests 1000 --rate-limit 1 --mode t --rpc-url http://localhost:8888
```

### Separate Broadcast Endpoint

By default, all RPC calls (gas estimation, chain ID, nonces, receipts, and transaction broadcast) go to `--rpc-url`. The `--send-rpc-url` flag routes only the transaction broadcast (`eth_sendRawTransaction`, or `eth_sendRawTransactionPrivate` when combined with `--private-txs`) to a secondary endpoint while everything else, including account funding, stays on `--rpc-url`. This is useful for:

- **Private mempools**: an endpoint that only accepts `eth_sendRawTransactionPrivate`.
- **Gossip-only broadcasters**: a light client connected to the p2p network that can broadcast transactions but has no chain state to answer other queries.

```bash
$ polycli loadtest --rpc-url http://fullnode:8545 --send-rpc-url http://broadcaster:8545 --mode t
```

Like `--private-txs`, this flag is only supported by the modes that broadcast transactions explicitly: `transaction`, `blob`, `contract-call`, and `recall`.

### Gas Manager

The loadtest command includes an optional gas manager for controlling transaction gas limits and pricing. Enable it with `--gas-manager-enabled`, then use the `--gas-manager-*` flags to:
Expand Down
15 changes: 15 additions & 0 deletions doc/polycli_loadtest.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,19 @@ Here is a simple example that runs 1000 requests at a max rate of 1 request per
$ polycli loadtest --verbosity 700 --chain-id 1256 --concurrency 1 --requests 1000 --rate-limit 1 --mode t --rpc-url http://localhost:8888
```

### Separate Broadcast Endpoint

By default, all RPC calls (gas estimation, chain ID, nonces, receipts, and transaction broadcast) go to `--rpc-url`. The `--send-rpc-url` flag routes only the transaction broadcast (`eth_sendRawTransaction`, or `eth_sendRawTransactionPrivate` when combined with `--private-txs`) to a secondary endpoint while everything else, including account funding, stays on `--rpc-url`. This is useful for:

- **Private mempools**: an endpoint that only accepts `eth_sendRawTransactionPrivate`.
- **Gossip-only broadcasters**: a light client connected to the p2p network that can broadcast transactions but has no chain state to answer other queries.

```bash
$ polycli loadtest --rpc-url http://fullnode:8545 --send-rpc-url http://broadcaster:8545 --mode t
```

Like `--private-txs`, this flag is only supported by the modes that broadcast transactions explicitly: `transaction`, `blob`, `contract-call`, and `recall`.

### Gas Manager

The loadtest command includes an optional gas manager for controlling transaction gas limits and pricing. Enable it with `--gas-manager-enabled`, then use the `--gas-manager-*` flags to:
Expand Down Expand Up @@ -174,6 +187,7 @@ The codebase has a contract that used for load testing. It's written in Solidity
--proxy string use the proxy specified
--random-recipients send to random addresses instead of fixed address in transfer tests
--rate-limit float requests per second limit (use negative value to remove limit) (default 4)
--rate-limit-ramp-duration duration linearly ramp rate limit from max(1% of --rate-limit, 1 TPS) to full --rate-limit over this duration (e.g. 3m; 0 disables ramp)
--recall-blocks uint number of blocks that we'll attempt to fetch for recall (default 50)
--receipt-retry-initial-delay-ms uint initial delay in milliseconds for receipt polling (uses exponential backoff with jitter) (default 100)
--receipt-retry-max uint maximum polling attempts for transaction receipt with --wait-for-receipt (default 30)
Expand All @@ -183,6 +197,7 @@ The codebase has a contract that used for load testing. It's written in Solidity
-r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545")
--seed int a seed for generating random values and addresses (default 123456)
--send-only alias for --fire-and-forget
--send-rpc-url string secondary RPC endpoint used only to broadcast transactions (eth_sendRawTransaction / eth_sendRawTransactionPrivate); all other calls use --rpc-url
--sending-accounts-count uint number of sending accounts to use (avoids pool account queue)
--sending-accounts-file string file with sending account private keys, one per line (avoids pool queue and preserves accounts across runs)
--sequential-nonce-fetch fetch nonces sequentially instead of in parallel (use if hitting rate limits)
Expand Down
2 changes: 2 additions & 0 deletions doc/polycli_loadtest_uniswapv3.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,11 +114,13 @@ The command also inherits flags from parent commands.
--private-txs send transactions via eth_sendRawTransactionPrivate
--random-recipients send to random addresses instead of fixed address in transfer tests
--rate-limit float requests per second limit (use negative value to remove limit) (default 4)
--rate-limit-ramp-duration duration linearly ramp rate limit from max(1% of --rate-limit, 1 TPS) to full --rate-limit over this duration (e.g. 3m; 0 disables ramp)
-n, --requests int number of requests to perform for the benchmarking session (default of 1 leads to non-representative results) (default 1)
--rpc-headers string custom HTTP headers for RPC requests (format: "key1:value1,key2:value2")
-r, --rpc-url string the RPC endpoint URL (default "http://localhost:8545")
--seed int a seed for generating random values and addresses (default 123456)
--send-only alias for --fire-and-forget
--send-rpc-url string secondary RPC endpoint used only to broadcast transactions (eth_sendRawTransaction / eth_sendRawTransactionPrivate); all other calls use --rpc-url
--stop-on-insufficient-funds stop sending from account when it encounters insufficient funds error
--summarize produce execution summary after load test (can take a long time for large tests)
-t, --time-limit int maximum seconds to spend benchmarking (default: no limit) (default -1)
Expand Down
36 changes: 32 additions & 4 deletions loadtest/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import (
"math/big"
"os"
"strings"
"time"

"github.com/0xPolygon/polygon-cli/loadtest/uniswapv3"
"github.com/0xPolygon/polygon-cli/util"
"github.com/ethereum/go-ethereum/common"
)

Expand Down Expand Up @@ -38,6 +40,7 @@ const (
type Config struct {
// Network connection
RPCURL string
SendRPCURL string
ChainID uint64
Proxy string
RPCHeaders string
Expand Down Expand Up @@ -77,6 +80,7 @@ type Config struct {

// Rate limiting
RateLimit float64
RateLimitRampDuration time.Duration
AdaptiveRateLimit bool
AdaptiveTargetSize uint64
AdaptiveRateLimitIncrement uint64
Expand Down Expand Up @@ -206,8 +210,29 @@ func (c *Config) Validate() error {
return errors.New("gas price multiplier should be non-zero")
}

if c.RateLimitRampDuration < 0 {
return errors.New("--rate-limit-ramp-duration must be positive")
}
if c.RateLimitRampDuration > 0 {
if c.AdaptiveRateLimit {
return errors.New("--rate-limit-ramp-duration and --adaptive-rate-limit are mutually exclusive")
}
if c.RateLimit <= 0 {
return errors.New("--rate-limit-ramp-duration requires a positive --rate-limit to ramp up to")
}
}

if c.PrivateTxs {
if err := c.validatePrivateTxsModes(); err != nil {
if err := c.validateModesSupportRawSend("--private-txs"); err != nil {
return err
}
}

if c.SendRPCURL != "" {
if err := util.ValidateURL(c.SendRPCURL); err != nil {
return fmt.Errorf("invalid --send-rpc-url %q: %w", c.SendRPCURL, err)
}
if err := c.validateModesSupportRawSend("--send-rpc-url"); err != nil {
return err
}
}
Expand All @@ -233,8 +258,11 @@ func (c *Config) Validate() error {
return nil
}

// validatePrivateTxsModes checks that all specified modes support --private-txs.
func (c *Config) validatePrivateTxsModes() error {
// validateModesSupportRawSend checks that all specified modes broadcast their
// transactions explicitly (rather than inside contract bindings), which is
// required by flags that alter how transactions are sent, such as
// --private-txs and --send-rpc-url.
func (c *Config) validateModesSupportRawSend(flagName string) error {
supported := map[string]bool{
"t": true, "transaction": true,
"b": true, "blob": true,
Expand All @@ -244,7 +272,7 @@ func (c *Config) validatePrivateTxsModes() error {

for _, mode := range c.Modes {
if !supported[mode] {
return fmt.Errorf("--private-txs is not supported for mode %q; supported modes: transaction, blob, contract-call, recall", mode)
return fmt.Errorf("%s is not supported for mode %q; supported modes: transaction, blob, contract-call, recall", flagName, mode)
}
}

Expand Down
146 changes: 146 additions & 0 deletions loadtest/config/config_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package config

import (
"strings"
"testing"
"time"
)

// validConfig returns a minimal config that passes Validate.
func validConfig() *Config {
return &Config{
AdaptiveBackoffFactor: 2,
GasPriceMultiplier: 1,
RateLimit: 4,
Modes: []string{"t"},
}
}

func TestValidateRateLimitRampDuration(t *testing.T) {
tests := []struct {
name string
rampDuration time.Duration
rateLimit float64
adaptiveRateLimit bool
wantErr string
}{
{
name: "no ramp",
rateLimit: 4,
},
{
name: "valid ramp",
rampDuration: 3 * time.Minute,
rateLimit: 100,
},
{
name: "negative duration",
rampDuration: -time.Minute,
rateLimit: 100,
wantErr: "--rate-limit-ramp-duration must be positive",
},
{
name: "mutually exclusive with adaptive",
rampDuration: 3 * time.Minute,
rateLimit: 100,
adaptiveRateLimit: true,
wantErr: "--rate-limit-ramp-duration and --adaptive-rate-limit are mutually exclusive",
},
{
name: "requires positive rate limit",
rampDuration: 3 * time.Minute,
rateLimit: -1,
wantErr: "--rate-limit-ramp-duration requires a positive --rate-limit",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validConfig()
cfg.RateLimitRampDuration = tt.rampDuration
cfg.RateLimit = tt.rateLimit
cfg.AdaptiveRateLimit = tt.adaptiveRateLimit

err := cfg.Validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error %q does not contain %q", err.Error(), tt.wantErr)
}
})
}
}

func TestValidateSendRPCURL(t *testing.T) {
tests := []struct {
name string
sendRPCURL string
privateTxs bool
modes []string
wantErr string
}{
{
name: "unset send-rpc-url with any mode",
modes: []string{"erc20"},
},
{
name: "supported modes",
sendRPCURL: "http://localhost:8546",
modes: []string{"t", "transaction", "b", "blob", "cc", "contract-call", "R", "recall"},
},
{
name: "unsupported mode",
sendRPCURL: "http://localhost:8546",
modes: []string{"erc20"},
wantErr: `--send-rpc-url is not supported for mode "erc20"`,
},
{
name: "invalid url scheme",
sendRPCURL: "localhost:8546",
modes: []string{"t"},
wantErr: "invalid --send-rpc-url",
},
{
name: "combined with private-txs",
sendRPCURL: "https://private.example.com",
privateTxs: true,
modes: []string{"t"},
},
{
name: "private-txs unsupported mode",
privateTxs: true,
modes: []string{"uniswapv3"},
wantErr: `--private-txs is not supported for mode "uniswapv3"`,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := validConfig()
cfg.SendRPCURL = tt.sendRPCURL
cfg.PrivateTxs = tt.privateTxs
cfg.Modes = tt.modes

err := cfg.Validate()
if tt.wantErr == "" {
if err != nil {
t.Fatalf("Validate() unexpected error: %v", err)
}
return
}
if err == nil {
t.Fatalf("Validate() expected error containing %q, got nil", tt.wantErr)
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("Validate() error %q does not contain %q", err.Error(), tt.wantErr)
}
})
}
}
5 changes: 5 additions & 0 deletions loadtest/mode/dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ type Dependencies struct {
Client *ethclient.Client
RPCClient *ethrpc.Client

// SendClient/SendRPCClient are used only to broadcast signed
// transactions. They alias Client/RPCClient unless --send-rpc-url is set.
SendClient *ethclient.Client
SendRPCClient *ethrpc.Client

// Contract instances
LoadTesterContract *tester.LoadTester
LoadTesterAddress common.Address
Expand Down
4 changes: 2 additions & 2 deletions loadtest/modes/blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,9 @@ func (m *BlobMode) Execute(ctx context.Context, cfg *config.Config, deps *mode.D
} else if cfg.OutputRawTxOnly {
err = mode.OutputRawTransaction(stx)
} else if cfg.PrivateTxs {
err = mode.SendRawTransactionPrivate(ctx, deps.RPCClient, stx)
err = mode.SendRawTransactionPrivate(ctx, deps.SendRPCClient, stx)
} else {
err = deps.Client.SendTransaction(ctx, stx)
err = deps.SendClient.SendTransaction(ctx, stx)
}
return
}
Expand Down
4 changes: 2 additions & 2 deletions loadtest/modes/contractcall.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,9 @@ func (m *ContractCallMode) Execute(ctx context.Context, cfg *config.Config, deps
} else if cfg.OutputRawTxOnly {
err = mode.OutputRawTransaction(stx)
} else if cfg.PrivateTxs {
err = mode.SendRawTransactionPrivate(ctx, deps.RPCClient, stx)
err = mode.SendRawTransactionPrivate(ctx, deps.SendRPCClient, stx)
} else {
err = deps.Client.SendTransaction(ctx, stx)
err = deps.SendClient.SendTransaction(ctx, stx)
}
return
}
4 changes: 2 additions & 2 deletions loadtest/modes/recall.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,9 +105,9 @@ func (m *RecallMode) Execute(ctx context.Context, cfg *config.Config, deps *mode
} else if cfg.OutputRawTxOnly {
err = mode.OutputRawTransaction(stx)
} else if cfg.PrivateTxs {
err = mode.SendRawTransactionPrivate(ctx, deps.RPCClient, stx)
err = mode.SendRawTransactionPrivate(ctx, deps.SendRPCClient, stx)
} else {
err = deps.Client.SendTransaction(ctx, stx)
err = deps.SendClient.SendTransaction(ctx, stx)
}
return
}
Expand Down
4 changes: 2 additions & 2 deletions loadtest/modes/transaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ func (m *TransactionMode) Execute(ctx context.Context, cfg *config.Config, deps
} else if cfg.OutputRawTxOnly {
err = mode.OutputRawTransaction(stx)
} else if cfg.PrivateTxs {
err = mode.SendRawTransactionPrivate(ctx, deps.RPCClient, stx)
err = mode.SendRawTransactionPrivate(ctx, deps.SendRPCClient, stx)
} else {
err = deps.Client.SendTransaction(ctx, stx)
err = deps.SendClient.SendTransaction(ctx, stx)
}

return
Expand Down
Loading