From bab1b94079988f651eaf74804901b55e6cfc5f38 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 8 May 2026 15:06:31 -0700 Subject: [PATCH 1/2] Added evm stress load test scenario --- config/config.go | 11 +++ generator/generator.go | 52 +++++++++------ generator/scenario.go | 24 ++++++- generator/scenario_test.go | 40 +++++++++++ generator/scenarios/EVMTransferStress.go | 85 ++++++++++++++++++++++++ generator/scenarios/factory.go | 7 +- profiles/evm_transfer_stress.json | 33 +++++++++ sender/writer_test.go | 5 +- types/account_pool.go | 15 ++++- types/evm_stress_keys.go | 48 +++++++++++++ types/evm_stress_keys_test.go | 23 +++++++ types/types_test.go | 12 ++++ 12 files changed, 326 insertions(+), 29 deletions(-) create mode 100644 generator/scenario_test.go create mode 100644 generator/scenarios/EVMTransferStress.go create mode 100644 profiles/evm_transfer_stress.json create mode 100644 types/evm_stress_keys.go create mode 100644 types/evm_stress_keys_test.go diff --git a/config/config.go b/config/config.go index 74105cf..8058a6e 100644 --- a/config/config.go +++ b/config/config.go @@ -57,6 +57,13 @@ func (c *LoadConfig) GetChainID() *big.Int { type AccountConfig struct { NewAccountRate float64 `json:"newAccountRate,omitempty"` Accounts int `json:"count,omitempty"` + // DeterministicEvmStressKeys uses types.EvmStressPrivateKey (sender indices + // 1..count). Fund those Cosmos accounts in genesis to match the pool. + DeterministicEvmStressKeys bool `json:"deterministicEvmStressKeys,omitempty"` + // SingleUseSenders gives each pooled account at most one turn as sender; + // when exhausted, generation stops (ok=false). One transaction per pooled sender. + // Do not enable together with settings.prewarm (prewarm shares the same pool). + SingleUseSenders bool `json:"singleUseSenders,omitempty"` } // Scenario represents each scenario in the load configuration. @@ -67,4 +74,8 @@ type Scenario struct { GasPicker *GasPicker `json:"gasPicker,omitempty"` GasFeeCapPicker *GasPicker `json:"gasFeeCapPicker,omitempty"` GasTipCapPicker *GasPicker `json:"gasTipCapPicker,omitempty"` + // FixedReceiver is an optional hex EVM address. When set, all transactions + // in this scenario are sent to this single address (single-recipient stress + // mode). If empty, the receiver is picked from the account pool as usual. + FixedReceiver string `json:"fixedReceiver,omitempty"` } diff --git a/generator/generator.go b/generator/generator.go index 324975c..6b216fa 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -22,11 +22,22 @@ type Generator interface { // scenarioInstance represents a scenario instance with its configuration type scenarioInstance struct { - Name string - Weight int - Scenario scenarios.TxGenerator - Accounts types.AccountPool - Deployed bool + Name string + Weight int + Scenario scenarios.TxGenerator + ScenarioConfig config.Scenario + Accounts types.AccountPool + Deployed bool +} + +func accountsFromConfig(ac *config.AccountConfig) []*types.Account { + if ac == nil { + return nil + } + if ac.DeterministicEvmStressKeys { + return types.GenerateEvmStressSenderAccounts(ac.Accounts) + } + return types.GenerateAccounts(ac.Accounts) } // configBasedGenerator manages scenario creation and deployment from config @@ -47,10 +58,11 @@ func (g *configBasedGenerator) createScenarios() error { // Create shared account pool if top-level account config exists if g.config.Accounts != nil { - accounts := types.GenerateAccounts(g.config.Accounts.Accounts) + accounts := accountsFromConfig(g.config.Accounts) g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{ - Accounts: accounts, - NewAccountRate: g.config.Accounts.NewAccountRate, + Accounts: accounts, + NewAccountRate: g.config.Accounts.NewAccountRate, + SingleUseSenders: g.config.Accounts.SingleUseSenders, }) g.accountPools = append(g.accountPools, g.sharedAccounts) } @@ -63,13 +75,12 @@ func (g *configBasedGenerator) createScenarios() error { var accountPool types.AccountPool if scenarioCfg.Accounts != nil { // Scenario defines its own account settings - create separate pool - accountCount := scenarioCfg.Accounts.Accounts - newAccountRate := scenarioCfg.Accounts.NewAccountRate - - accounts := types.GenerateAccounts(accountCount) + sa := scenarioCfg.Accounts + accounts := accountsFromConfig(sa) accountPool = types.NewAccountPool(&types.AccountConfig{ - Accounts: accounts, - NewAccountRate: newAccountRate, + Accounts: accounts, + NewAccountRate: sa.NewAccountRate, + SingleUseSenders: sa.SingleUseSenders, }) g.accountPools = append(g.accountPools, accountPool) } else if g.sharedAccounts != nil { @@ -98,11 +109,12 @@ func (g *configBasedGenerator) createScenarios() error { // Create scenario instance instance := &scenarioInstance{ - Name: name, - Weight: scenarioCfg.Weight, - Scenario: scenario, - Accounts: accountPool, - Deployed: false, + Name: name, + Weight: scenarioCfg.Weight, + Scenario: scenario, + ScenarioConfig: scenarioCfg, + Accounts: accountPool, + Deployed: false, } g.instances = append(g.instances, instance) @@ -170,7 +182,7 @@ func (g *configBasedGenerator) createWeightedGenerator() (Generator, error) { continue } // Create a scenarioGenerator for this scenario instance - gen := NewScenarioGenerator(instance.Accounts, instance.Scenario) + gen := NewScenarioGenerator(instance.Accounts, instance.Scenario, instance.ScenarioConfig) // Add to weighted config with the specified weight weightedConfigs = append(weightedConfigs, WeightedConfig(instance.Weight, gen)) diff --git a/generator/scenario.go b/generator/scenario.go index d286118..ece59fe 100644 --- a/generator/scenario.go +++ b/generator/scenario.go @@ -1,8 +1,12 @@ package generator import ( + "strings" "sync" + "github.com/ethereum/go-ethereum/common" + + "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/scenarios" "github.com/sei-protocol/sei-load/types" ) @@ -10,14 +14,16 @@ import ( type scenarioGenerator struct { scenario scenarios.TxGenerator accountPool types.AccountPool + scenarioCfg config.Scenario mu sync.RWMutex } func NewScenarioGenerator(accounts types.AccountPool, - txg scenarios.TxGenerator) Generator { + txg scenarios.TxGenerator, scenarioCfg config.Scenario) Generator { return &scenarioGenerator{ scenario: txg, accountPool: accounts, + scenarioCfg: scenarioCfg, } } @@ -35,11 +41,23 @@ func (g *scenarioGenerator) GenerateN(n int) []*types.LoadTx { func (g *scenarioGenerator) Generate() (*types.LoadTx, bool) { sender := g.accountPool.NextAccount() - receiver := g.accountPool.NextAccount() + if sender == nil { + return nil, false + } + var receiver common.Address + if addr := strings.TrimSpace(g.scenarioCfg.FixedReceiver); addr != "" { + receiver = common.HexToAddress(addr) + } else { + rcv := g.accountPool.NextAccount() + if rcv == nil { + return nil, false + } + receiver = rcv.Address + } return g.scenario.Generate(&types.TxScenario{ Name: g.scenario.Name(), Sender: sender, - Receiver: receiver.Address, + Receiver: receiver, }), true } diff --git a/generator/scenario_test.go b/generator/scenario_test.go new file mode 100644 index 0000000..e374c43 --- /dev/null +++ b/generator/scenario_test.go @@ -0,0 +1,40 @@ +package generator + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +func TestScenarioGenerator_FixedReceiverUsesOnePoolAccount(t *testing.T) { + accs := types.GenerateAccounts(2) + pool := types.NewAccountPool(&types.AccountConfig{ + Accounts: accs, + NewAccountRate: 0, + SingleUseSenders: true, + }) + evm := scenarios.NewEVMTransferScenario(config.Scenario{}) + cfg := &config.LoadConfig{ChainID: 713714} + evm.Deploy(cfg, accs[0]) + + fixed := "0x00000000000000000000000000000000000000aa" + want := common.HexToAddress(fixed) + gen := NewScenarioGenerator(pool, evm, config.Scenario{FixedReceiver: fixed}) + + tx1, ok := gen.Generate() + require.True(t, ok) + require.NotNil(t, tx1) + require.Equal(t, want, tx1.Scenario.Receiver) + + tx2, ok := gen.Generate() + require.True(t, ok) + require.NotNil(t, tx2) + + _, ok = gen.Generate() + require.False(t, ok) +} diff --git a/generator/scenarios/EVMTransferStress.go b/generator/scenarios/EVMTransferStress.go new file mode 100644 index 0000000..981c389 --- /dev/null +++ b/generator/scenarios/EVMTransferStress.go @@ -0,0 +1,85 @@ +package scenarios + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + + "github.com/sei-protocol/sei-load/config" + types2 "github.com/sei-protocol/sei-load/types" +) + +const EVMTransferStress = "evmtransferstress" + +// EVMTransferStressScenario is a high-fee simple ETH transfer stress pattern +// (1000 gwei cap, 1 gwei tip, 10^12+1 wei value). For parity with a one-shot +// multi-sender / single-recipient genesis setup, use profiles/evm_transfer_stress.json: +// deterministicEvmStressKeys, singleUseSenders, and fixedReceiver (types.EvmStressRecipientAddress). +type EVMTransferStressScenario struct { + *ScenarioBase + fixedReceiver *common.Address +} + +func NewEVMTransferStressScenario(cfg config.Scenario) TxGenerator { + s := &EVMTransferStressScenario{} + if cfg.FixedReceiver != "" { + addr := common.HexToAddress(cfg.FixedReceiver) + s.fixedReceiver = &addr + } + s.ScenarioBase = NewScenarioBase(s, cfg) + return s +} + +func (s *EVMTransferStressScenario) Name() string { + return EVMTransferStress +} + +func (s *EVMTransferStressScenario) DeployScenario(_ *config.LoadConfig, _ *types2.Account) common.Address { + return common.Address{} +} + +func (s *EVMTransferStressScenario) AttachScenario(_ *config.LoadConfig, _ common.Address) common.Address { + return common.Address{} +} + +func (s *EVMTransferStressScenario) CreateTransaction(cfg *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { + to := scenario.Receiver + if s.fixedReceiver != nil { + to = *s.fixedReceiver + } + + tx := ðtypes.DynamicFeeTx{ + Nonce: scenario.Sender.GetAndIncrementNonce(), + To: &to, + Value: big.NewInt(1_000_000_000_001), // 10^12+1 wei: touches both usei balance and wei remainder + Gas: 21_000, + GasTipCap: big.NewInt(1_000_000_000), // 1 gwei + GasFeeCap: big.NewInt(1_000_000_000_000), // 1000 gwei + } + + if s.scenarioConfig.GasPicker != nil { + var err error + tx.Gas, err = s.scenarioConfig.GasPicker.GenerateGas() + if err != nil { + return nil, err + } + } + if s.scenarioConfig.GasTipCapPicker != nil { + gasTipCap, err := s.scenarioConfig.GasTipCapPicker.GenerateGas() + if err != nil { + return nil, err + } + tx.GasTipCap = big.NewInt(int64(gasTipCap)) + } + if s.scenarioConfig.GasFeeCapPicker != nil { + gasFeeCap, err := s.scenarioConfig.GasFeeCapPicker.GenerateGas() + if err != nil { + return nil, err + } + tx.GasFeeCap = big.NewInt(int64(gasFeeCap)) + } + + signer := ethtypes.NewCancunSigner(cfg.GetChainID()) + return ethtypes.SignTx(ethtypes.NewTx(tx), signer, scenario.Sender.PrivKey) +} diff --git a/generator/scenarios/factory.go b/generator/scenarios/factory.go index 629f509..225345e 100644 --- a/generator/scenarios/factory.go +++ b/generator/scenarios/factory.go @@ -12,9 +12,10 @@ type ScenarioFactory func(s config.Scenario) TxGenerator // scenarioFactories maps scenario names to their factory functions var scenarioFactories = map[string]ScenarioFactory{ // Manual entries for non-contract scenarios - EVMTransfer: NewEVMTransferScenario, - EVMTransferFast: NewEVMTransferFastScenario, - EVMTransferNoop: NewEVMTransferNoopScenario, + EVMTransfer: NewEVMTransferScenario, + EVMTransferFast: NewEVMTransferFastScenario, + EVMTransferNoop: NewEVMTransferNoopScenario, + EVMTransferStress: NewEVMTransferStressScenario, // Auto-generated entries will be added below this line by make generate // DO NOT EDIT BELOW THIS LINE - AUTO-GENERATED CONTENT diff --git a/profiles/evm_transfer_stress.json b/profiles/evm_transfer_stress.json new file mode 100644 index 0000000..94c4568 --- /dev/null +++ b/profiles/evm_transfer_stress.json @@ -0,0 +1,33 @@ +{ + "chainId": 713714, + "seiChainId": "sei-chain", + "endpoints": [ + "http://127.0.0.1:8545" + ], + "accounts": { + "count": 50000, + "newAccountRate": 0.0, + "deterministicEvmStressKeys": true, + "singleUseSenders": true + }, + "scenarios": [ + { + "name": "EVMTransferStress", + "weight": 1, + "fixedReceiver": "0xDC5b20847F43d67928F49Cd4f85D696b5A7617B5" + } + ], + "settings": { + "workers": 250, + "tps": 500, + "statsInterval": "5s", + "bufferSize": 1000, + "dryRun": false, + "debug": false, + "trackReceipts": false, + "trackBlocks": false, + "trackUserLatency": false, + "prewarm": false, + "rampUp": false + } + } diff --git a/sender/writer_test.go b/sender/writer_test.go index e0247cc..05972ec 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -30,7 +30,10 @@ func TestTxsWriter_Flush(t *testing.T) { }) evmScenario.Deploy(loadConfig, sharedAccounts.NextAccount()) - generator := generator.NewScenarioGenerator(sharedAccounts, evmScenario) + generator := generator.NewScenarioGenerator(sharedAccounts, evmScenario, config.Scenario{ + Name: "EVMTransfer", + Weight: 1, + }) txs := generator.GenerateN(3) diff --git a/types/account_pool.go b/types/account_pool.go index 0770f12..70a118e 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -12,8 +12,9 @@ type AccountPool interface { // AccountConfig stores the configuration for account generation. type AccountConfig struct { - Accounts []*Account - NewAccountRate float64 + Accounts []*Account + NewAccountRate float64 + SingleUseSenders bool } type accountPool struct { @@ -40,6 +41,16 @@ func (a *accountPool) NextAccount() *Account { return GenerateAccounts(1)[0] } } + if a.cfg.SingleUseSenders { + a.mx.Lock() + defer a.mx.Unlock() + if a.idx >= len(a.Accounts) { + return nil + } + acc := a.Accounts[a.idx] + a.idx++ + return acc + } return a.Accounts[a.nextIndex()] } diff --git a/types/evm_stress_keys.go b/types/evm_stress_keys.go new file mode 100644 index 0000000..edf0c8f --- /dev/null +++ b/types/evm_stress_keys.go @@ -0,0 +1,48 @@ +package types + +import ( + "crypto/ecdsa" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" +) + +// EvmStressPrivateKey derives a deterministic secp256k1 key from a 32-byte seed (idx in high bytes). +func EvmStressPrivateKey(idx uint64) (*ecdsa.PrivateKey, error) { + seed := make([]byte, 32) + seed[0] = 0x01 + for i := 0; i < 8; i++ { + seed[1+i] = byte(idx >> (56 - 8*i)) + } + return crypto.ToECDSA(seed) +} + +// EvmStressRecipientAddress is key index 0 (fixed recipient for stress profiles). +func EvmStressRecipientAddress() common.Address { + key, err := EvmStressPrivateKey(0) + if err != nil { + panic(fmt.Sprintf("evm stress key 0: %v", err)) + } + return crypto.PubkeyToAddress(key.PublicKey) +} + +// GenerateEvmStressSenderAccounts returns accounts for indices 1..n (inclusive). +// Fund the corresponding native-chain accounts in genesis when using this pool. +func GenerateEvmStressSenderAccounts(n int) []*Account { + if n < 1 { + return nil + } + out := make([]*Account, 0, n) + for i := uint64(1); i <= uint64(n); i++ { + priv, err := EvmStressPrivateKey(i) + if err != nil { + panic(fmt.Sprintf("evm stress key %d: %v", i, err)) + } + out = append(out, &Account{ + Address: crypto.PubkeyToAddress(priv.PublicKey), + PrivKey: priv, + }) + } + return out +} diff --git a/types/evm_stress_keys_test.go b/types/evm_stress_keys_test.go new file mode 100644 index 0000000..465b218 --- /dev/null +++ b/types/evm_stress_keys_test.go @@ -0,0 +1,23 @@ +package types + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEvmStressRecipientAddressGolden(t *testing.T) { + // Golden: EvmStressPrivateKey(0) EVM address (fixed stress recipient). + require.Equal(t, + "0xDC5b20847F43d67928F49Cd4f85D696b5A7617B5", + EvmStressRecipientAddress().Hex(), + ) +} + +func TestEvmStressSenderMatchesKeyOne(t *testing.T) { + accs := GenerateEvmStressSenderAccounts(1) + require.Len(t, accs, 1) + k, err := EvmStressPrivateKey(1) + require.NoError(t, err) + require.Equal(t, accs[0].PrivKey.D, k.D) +} diff --git a/types/types_test.go b/types/types_test.go index 54b48b3..481175b 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -120,6 +120,18 @@ func TestGenerateAccounts(t *testing.T) { } } +func TestAccountPoolSingleUseExhausted(t *testing.T) { + accounts := GenerateAccounts(2) + pool := NewAccountPool(&AccountConfig{ + Accounts: accounts, + NewAccountRate: 0, + SingleUseSenders: true, + }) + require.Equal(t, accounts[0].Address, pool.NextAccount().Address) + require.Equal(t, accounts[1].Address, pool.NextAccount().Address) + require.Nil(t, pool.NextAccount()) +} + func TestAccountPoolRoundRobin(t *testing.T) { accounts := GenerateAccounts(3) config := &AccountConfig{ From 93fbf10ff074e4d8b781a44252f58ac5f8c8c657 Mon Sep 17 00:00:00 2001 From: Amir Deris Date: Fri, 8 May 2026 15:46:56 -0700 Subject: [PATCH 2/2] Addressing some feedback --- config/config.go | 47 +++++++++++++++++++++++- config/fixed_receiver_validation_test.go | 31 ++++++++++++++++ config/prewarm_validation_test.go | 29 +++++++++++++++ generator/generator.go | 12 +++++- generator/scenario_test.go | 3 +- generator/scenarios/Disperse.go | 6 ++- generator/scenarios/EVMTransferStress.go | 11 +----- main.go | 7 ++++ sender/writer_test.go | 10 ++--- types/account_pool.go | 20 ++++++++-- types/types_test.go | 31 +++++++++++++--- 11 files changed, 178 insertions(+), 29 deletions(-) create mode 100644 config/fixed_receiver_validation_test.go create mode 100644 config/prewarm_validation_test.go diff --git a/config/config.go b/config/config.go index 8058a6e..55625c6 100644 --- a/config/config.go +++ b/config/config.go @@ -4,7 +4,10 @@ import ( "encoding/json" "fmt" "math/big" + "strings" "time" + + "github.com/ethereum/go-ethereum/common" ) // LoadConfig stores the configuration for load-related settings. @@ -62,7 +65,8 @@ type AccountConfig struct { DeterministicEvmStressKeys bool `json:"deterministicEvmStressKeys,omitempty"` // SingleUseSenders gives each pooled account at most one turn as sender; // when exhausted, generation stops (ok=false). One transaction per pooled sender. - // Do not enable together with settings.prewarm (prewarm shares the same pool). + // Incompatible with newAccountRate > 0 (validated at pool creation) and with + // settings.prewarm (validated at startup via ValidatePrewarmAccountPools). SingleUseSenders bool `json:"singleUseSenders,omitempty"` } @@ -79,3 +83,44 @@ type Scenario struct { // mode). If empty, the receiver is picked from the account pool as usual. FixedReceiver string `json:"fixedReceiver,omitempty"` } + +// ValidatePrewarmAccountPools returns an error if prewarm is enabled while any +// account pool uses singleUseSenders. Prewarm iterates the same pools and is +// incompatible with single-use exhaustion semantics. +func ValidatePrewarmAccountPools(cfg *LoadConfig, prewarm bool) error { + if !prewarm || cfg == nil { + return nil + } + if cfg.Accounts != nil && cfg.Accounts.SingleUseSenders { + return fmt.Errorf("settings.prewarm cannot be used with accounts.singleUseSenders (prewarm shares the same account pool)") + } + for i, sc := range cfg.Scenarios { + if sc.Accounts != nil && sc.Accounts.SingleUseSenders { + return fmt.Errorf("settings.prewarm cannot be used with scenarios[%d] (%q) accounts.singleUseSenders", i, sc.Name) + } + } + return nil +} + +// ValidateFixedReceiverAddresses returns an error if any scenario has a +// non-empty fixedReceiver that is not a valid 20-byte hex address (0x + 40 hex). +// Malformed values would otherwise be passed to common.HexToAddress and can +// map to the zero address without an obvious failure at config load time. +func ValidateFixedReceiverAddresses(cfg *LoadConfig) error { + if cfg == nil { + return nil + } + for i, sc := range cfg.Scenarios { + addr := strings.TrimSpace(sc.FixedReceiver) + if addr == "" { + continue + } + if !common.IsHexAddress(addr) { + return fmt.Errorf( + "scenarios[%d] (%q): invalid fixedReceiver %q (want 0x-prefixed 40 hex characters)", + i, sc.Name, sc.FixedReceiver, + ) + } + } + return nil +} diff --git a/config/fixed_receiver_validation_test.go b/config/fixed_receiver_validation_test.go new file mode 100644 index 0000000..738b27f --- /dev/null +++ b/config/fixed_receiver_validation_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidateFixedReceiverAddresses(t *testing.T) { + require.NoError(t, ValidateFixedReceiverAddresses(nil)) + require.NoError(t, ValidateFixedReceiverAddresses(&LoadConfig{})) + require.NoError(t, ValidateFixedReceiverAddresses(&LoadConfig{ + Scenarios: []Scenario{ + {Name: "A", FixedReceiver: ""}, + {Name: "B", FixedReceiver: " "}, + {Name: "C", FixedReceiver: "0x0000000000000000000000000000000000000000"}, + {Name: "D", FixedReceiver: "0xDC5b20847F43d67928F49Cd4f85D696b5A7617B5"}, + }, + })) + + err := ValidateFixedReceiverAddresses(&LoadConfig{ + Scenarios: []Scenario{{Name: "bad", FixedReceiver: "0xinvalid"}}, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "fixedReceiver") + + err = ValidateFixedReceiverAddresses(&LoadConfig{ + Scenarios: []Scenario{{Name: "short", FixedReceiver: "0xabc"}}, + }) + require.Error(t, err) +} diff --git a/config/prewarm_validation_test.go b/config/prewarm_validation_test.go new file mode 100644 index 0000000..9d48a13 --- /dev/null +++ b/config/prewarm_validation_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestValidatePrewarmAccountPools(t *testing.T) { + require.NoError(t, ValidatePrewarmAccountPools(nil, true)) + require.NoError(t, ValidatePrewarmAccountPools(&LoadConfig{}, false)) + require.NoError(t, ValidatePrewarmAccountPools(&LoadConfig{ + Accounts: &AccountConfig{SingleUseSenders: true}, + }, false)) + + err := ValidatePrewarmAccountPools(&LoadConfig{ + Accounts: &AccountConfig{SingleUseSenders: true}, + }, true) + require.Error(t, err) + require.Contains(t, err.Error(), "prewarm") + + err = ValidatePrewarmAccountPools(&LoadConfig{ + Scenarios: []Scenario{ + {Name: "S", Accounts: &AccountConfig{SingleUseSenders: true}}, + }, + }, true) + require.Error(t, err) + require.Contains(t, err.Error(), "scenarios[0]") +} diff --git a/generator/generator.go b/generator/generator.go index 6b216fa..aa00e9b 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -59,11 +59,15 @@ func (g *configBasedGenerator) createScenarios() error { // Create shared account pool if top-level account config exists if g.config.Accounts != nil { accounts := accountsFromConfig(g.config.Accounts) - g.sharedAccounts = types.NewAccountPool(&types.AccountConfig{ + pool, err := types.NewAccountPool(&types.AccountConfig{ Accounts: accounts, NewAccountRate: g.config.Accounts.NewAccountRate, SingleUseSenders: g.config.Accounts.SingleUseSenders, }) + if err != nil { + return fmt.Errorf("shared account pool: %w", err) + } + g.sharedAccounts = pool g.accountPools = append(g.accountPools, g.sharedAccounts) } @@ -77,11 +81,15 @@ func (g *configBasedGenerator) createScenarios() error { // Scenario defines its own account settings - create separate pool sa := scenarioCfg.Accounts accounts := accountsFromConfig(sa) - accountPool = types.NewAccountPool(&types.AccountConfig{ + pool, err := types.NewAccountPool(&types.AccountConfig{ Accounts: accounts, NewAccountRate: sa.NewAccountRate, SingleUseSenders: sa.SingleUseSenders, }) + if err != nil { + return fmt.Errorf("scenario %q account pool: %w", scenarioCfg.Name, err) + } + accountPool = pool g.accountPools = append(g.accountPools, accountPool) } else if g.sharedAccounts != nil { // Use shared account pool from top-level config diff --git a/generator/scenario_test.go b/generator/scenario_test.go index e374c43..809342f 100644 --- a/generator/scenario_test.go +++ b/generator/scenario_test.go @@ -13,11 +13,12 @@ import ( func TestScenarioGenerator_FixedReceiverUsesOnePoolAccount(t *testing.T) { accs := types.GenerateAccounts(2) - pool := types.NewAccountPool(&types.AccountConfig{ + pool, err := types.NewAccountPool(&types.AccountConfig{ Accounts: accs, NewAccountRate: 0, SingleUseSenders: true, }) + require.NoError(t, err) evm := scenarios.NewEVMTransferScenario(config.Scenario{}) cfg := &config.LoadConfig{ChainID: 713714} evm.Deploy(cfg, accs[0]) diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 0e5c7fd..88af70a 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -24,9 +24,13 @@ type DisperseScenario struct { func NewDisperseScenario(cfg config.Scenario) TxGenerator { scenario := &DisperseScenario{} scenario.ContractScenarioBase = NewContractScenarioBase[bindings.Disperse](scenario, cfg) - scenario.pool = types.NewAccountPool(&types.AccountConfig{ + pool, err := types.NewAccountPool(&types.AccountConfig{ NewAccountRate: 1.0, }) + if err != nil { + panic("disperse account pool: " + err.Error()) + } + scenario.pool = pool return scenario } diff --git a/generator/scenarios/EVMTransferStress.go b/generator/scenarios/EVMTransferStress.go index 981c389..28ba24e 100644 --- a/generator/scenarios/EVMTransferStress.go +++ b/generator/scenarios/EVMTransferStress.go @@ -15,18 +15,14 @@ const EVMTransferStress = "evmtransferstress" // EVMTransferStressScenario is a high-fee simple ETH transfer stress pattern // (1000 gwei cap, 1 gwei tip, 10^12+1 wei value). For parity with a one-shot // multi-sender / single-recipient genesis setup, use profiles/evm_transfer_stress.json: -// deterministicEvmStressKeys, singleUseSenders, and fixedReceiver (types.EvmStressRecipientAddress). +// deterministicEvmStressKeys, singleUseSenders, and fixedReceiver on the scenario +// config (generator sets TxScenario.Receiver; types.EvmStressRecipientAddress). type EVMTransferStressScenario struct { *ScenarioBase - fixedReceiver *common.Address } func NewEVMTransferStressScenario(cfg config.Scenario) TxGenerator { s := &EVMTransferStressScenario{} - if cfg.FixedReceiver != "" { - addr := common.HexToAddress(cfg.FixedReceiver) - s.fixedReceiver = &addr - } s.ScenarioBase = NewScenarioBase(s, cfg) return s } @@ -45,9 +41,6 @@ func (s *EVMTransferStressScenario) AttachScenario(_ *config.LoadConfig, _ commo func (s *EVMTransferStressScenario) CreateTransaction(cfg *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { to := scenario.Receiver - if s.fixedReceiver != nil { - to = *s.fixedReceiver - } tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Sender.GetAndIncrementNonce(), diff --git a/main.go b/main.go index 66c73f0..34eeeb6 100644 --- a/main.go +++ b/main.go @@ -142,6 +142,13 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command, args []string) error { log.Printf("📝 Track user latency: enabled") } + if err := config.ValidatePrewarmAccountPools(cfg, settings.Prewarm); err != nil { + return err + } + if err := config.ValidateFixedReceiverAddresses(cfg); err != nil { + return err + } + // Enable mock deployment in dry-run mode if settings.DryRun { cfg.MockDeploy = true diff --git a/sender/writer_test.go b/sender/writer_test.go index 05972ec..897cf98 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -1,7 +1,6 @@ package sender import ( - "context" "testing" "github.com/sei-protocol/sei-load/config" @@ -19,10 +18,11 @@ func TestTxsWriter_Flush(t *testing.T) { ChainID: 7777, } - sharedAccounts := types.NewAccountPool(&types.AccountConfig{ + sharedAccounts, err := types.NewAccountPool(&types.AccountConfig{ Accounts: types.GenerateAccounts(10), NewAccountRate: 0.0, }) + require.NoError(t, err) evmScenario := scenarios.CreateScenario(config.Scenario{ Name: "EVMTransfer", @@ -37,21 +37,21 @@ func TestTxsWriter_Flush(t *testing.T) { txs := generator.GenerateN(3) - err := writer.Send(context.Background(), txs[0]) + err = writer.Send(t.Context(), txs[0]) require.NoError(t, err) require.Equal(t, uint64(1), writer.nextHeight) require.Equal(t, uint64(21000), writer.bufferGas) require.Len(t, writer.txBuffer, 1) require.Equal(t, txs[0], writer.txBuffer[0]) - err = writer.Send(context.Background(), txs[1]) + err = writer.Send(t.Context(), txs[1]) require.NoError(t, err) require.Equal(t, uint64(1), writer.nextHeight) require.Equal(t, uint64(42000), writer.bufferGas) require.Len(t, writer.txBuffer, 2) require.Equal(t, txs[1], writer.txBuffer[1]) - err = writer.Send(context.Background(), txs[2]) + err = writer.Send(t.Context(), txs[2]) require.NoError(t, err) // now should be flushed and have the new tx require.Equal(t, uint64(2), writer.nextHeight) diff --git a/types/account_pool.go b/types/account_pool.go index 70a118e..8d8d0e7 100644 --- a/types/account_pool.go +++ b/types/account_pool.go @@ -1,6 +1,7 @@ package types import ( + "fmt" "math/rand" "sync" ) @@ -12,8 +13,10 @@ type AccountPool interface { // AccountConfig stores the configuration for account generation. type AccountConfig struct { - Accounts []*Account - NewAccountRate float64 + Accounts []*Account + NewAccountRate float64 + // SingleUseSenders requires NewAccountRate == 0 (enforced by NewAccountPool). + // Incompatible with settings.prewarm (enforced at seiload startup in config.ValidatePrewarmAccountPools). SingleUseSenders bool } @@ -55,9 +58,18 @@ func (a *accountPool) NextAccount() *Account { } // NewAccountPool creates a new account generator from a config. -func NewAccountPool(cfg *AccountConfig) AccountPool { +func NewAccountPool(cfg *AccountConfig) (AccountPool, error) { + if cfg == nil { + return nil, fmt.Errorf("account pool config is nil") + } + if cfg.SingleUseSenders && cfg.NewAccountRate > 0 { + return nil, fmt.Errorf( + "account pool: singleUseSenders is incompatible with newAccountRate > 0 (got newAccountRate=%g)", + cfg.NewAccountRate, + ) + } return &accountPool{ Accounts: cfg.Accounts, cfg: cfg, - } + }, nil } diff --git a/types/types_test.go b/types/types_test.go index 481175b..73a4297 100644 --- a/types/types_test.go +++ b/types/types_test.go @@ -120,13 +120,25 @@ func TestGenerateAccounts(t *testing.T) { } } +func TestNewAccountPool_RejectsSingleUseWithNewAccountRate(t *testing.T) { + accounts := GenerateAccounts(2) + _, err := NewAccountPool(&AccountConfig{ + Accounts: accounts, + NewAccountRate: 0.5, + SingleUseSenders: true, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "singleUseSenders") +} + func TestAccountPoolSingleUseExhausted(t *testing.T) { accounts := GenerateAccounts(2) - pool := NewAccountPool(&AccountConfig{ + pool, err := NewAccountPool(&AccountConfig{ Accounts: accounts, NewAccountRate: 0, SingleUseSenders: true, }) + require.NoError(t, err) require.Equal(t, accounts[0].Address, pool.NextAccount().Address) require.Equal(t, accounts[1].Address, pool.NextAccount().Address) require.Nil(t, pool.NextAccount()) @@ -139,7 +151,8 @@ func TestAccountPoolRoundRobin(t *testing.T) { NewAccountRate: 0.0, // No new accounts, pure round-robin } - pool := NewAccountPool(config) + pool, err := NewAccountPool(config) + require.NoError(t, err) // The account pool starts from index 1 (due to nextIndex() incrementing first) // So the first call returns accounts[1], second returns accounts[2], third returns accounts[0] @@ -164,7 +177,8 @@ func TestAccountPoolNewAccountRate(t *testing.T) { NewAccountRate: 1.0, // Always generate new accounts } - pool := NewAccountPool(config) + pool, err := NewAccountPool(config) + require.NoError(t, err) // With 100% new account rate, should never get original accounts originalAddresses := make(map[common.Address]bool) @@ -187,7 +201,8 @@ func TestAccountPoolMixedRate(t *testing.T) { NewAccountRate: 0.5, // 50% new accounts } - pool := NewAccountPool(config) + pool, err := NewAccountPool(config) + require.NoError(t, err) originalAddresses := make(map[common.Address]bool) for _, account := range accounts { @@ -223,7 +238,8 @@ func TestAccountPoolConcurrency(t *testing.T) { NewAccountRate: 0.0, // Pure round-robin for predictable testing } - pool := NewAccountPool(config) + pool, err := NewAccountPool(config) + require.NoError(t, err) const numGoroutines = 50 const selectionsPerGoroutine = 20 @@ -461,7 +477,10 @@ func BenchmarkAccountPoolNextAccount(b *testing.B) { Accounts: accounts, NewAccountRate: 0.0, } - pool := NewAccountPool(config) + pool, err := NewAccountPool(config) + if err != nil { + b.Fatal(err) + } b.ResetTimer() for i := 0; i < b.N; i++ {