diff --git a/funder/deployer.go b/funder/deployer.go new file mode 100644 index 0000000..8d5a924 --- /dev/null +++ b/funder/deployer.go @@ -0,0 +1,66 @@ +package funder + +import ( + "fmt" + "os" + "strings" + + "github.com/ethereum/go-ethereum/crypto" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/types" +) + +// Deployer returns the account that signs the run's contract deployments. +// +// With funding configured that account is the funding root: the one key the run +// knows to hold a balance, and the key this package already deploys Disperse +// from. Without funding — or under mock deploy, where no deployment reaches a +// chain — it is a fresh random account, payable only by a chain that credits +// unknown senders (a mock chain or a pre-funded genesis). +// +// It fails only when funding is configured and its root key cannot be read or +// parsed. +func Deployer(cfg *config.LoadConfig) (types.Account, error) { + if cfg.Funding == nil || cfg.MockDeploy { + return types.NewAccount(false), nil + } + return rootAccount(cfg.Funding) +} + +// rootAccount resolves the configured root key into the account that spends it. +func rootAccount(fc *config.FundingConfig) (types.Account, error) { + rootKeyHex, err := resolveRootKey(fc) + if err != nil { + return types.Account{}, err + } + // TrimSpace: a SOPS-mounted key file commonly carries a trailing newline. + rootKey, err := crypto.HexToECDSA(strings.TrimPrefix(strings.TrimSpace(rootKeyHex), "0x")) + if err != nil { + return types.Account{}, fmt.Errorf("funder: parse root key: %w", err) + } + return types.AccountFromKey(rootKey, false), nil +} + +// resolveRootKey reads the hex root key from the configured file, else from the +// configured environment variable. +func resolveRootKey(fc *config.FundingConfig) (string, error) { + if fc.RootKeyFile != "" { + b, err := os.ReadFile(fc.RootKeyFile) + if err != nil { + return "", fmt.Errorf("funder: read rootKeyFile: %w", err) + } + if len(strings.TrimSpace(string(b))) == 0 { + return "", fmt.Errorf("funder: rootKeyFile %s is empty", fc.RootKeyFile) + } + return string(b), nil + } + if fc.RootKeyEnv != "" { + v := os.Getenv(fc.RootKeyEnv) + if v == "" { + return "", fmt.Errorf("funder: env %s is empty", fc.RootKeyEnv) + } + return v, nil + } + return "", fmt.Errorf("funder: no root key (set funding.rootKeyFile or funding.rootKeyEnv)") +} diff --git a/funder/deployer_test.go b/funder/deployer_test.go new file mode 100644 index 0000000..7fd5bc0 --- /dev/null +++ b/funder/deployer_test.go @@ -0,0 +1,140 @@ +package funder_test + +import ( + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/funder" + "github.com/sei-protocol/sei-load/types" +) + +// testKey returns a private key and its hex encoding. +func testKey(t *testing.T) (types.Account, string) { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + return types.AccountFromKey(key, false), hex.EncodeToString(crypto.FromECDSA(key)) +} + +// writeKeyFile writes keyHex to a file the way a mounted secret carries it. +func writeKeyFile(t *testing.T, keyHex string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "root-key.hex") + require.NoError(t, os.WriteFile(path, []byte(keyHex), 0o600)) + return path +} + +// With funding configured the deployer is the root key, whichever way the key +// is supplied and however the secret is padded. +func TestDeployerIsTheFundingRoot(t *testing.T) { + root, keyHex := testKey(t) + + t.Run("file", func(t *testing.T) { + cfg := &config.LoadConfig{Funding: &config.FundingConfig{ + RootKeyFile: writeKeyFile(t, keyHex), + }} + deployer, err := funder.Deployer(cfg) + require.NoError(t, err) + require.Equal(t, root.Address, deployer.Address) + require.NotNil(t, deployer.PrivKey) + require.False(t, deployer.Tracked, "the deployer is not a pool account") + }) + + t.Run("file with 0x prefix and trailing newline", func(t *testing.T) { + cfg := &config.LoadConfig{Funding: &config.FundingConfig{ + RootKeyFile: writeKeyFile(t, "0x"+keyHex+"\n"), + }} + deployer, err := funder.Deployer(cfg) + require.NoError(t, err) + require.Equal(t, root.Address, deployer.Address) + }) + + t.Run("env", func(t *testing.T) { + t.Setenv("SEILOAD_TEST_ROOT_KEY", keyHex) + cfg := &config.LoadConfig{Funding: &config.FundingConfig{ + RootKeyEnv: "SEILOAD_TEST_ROOT_KEY", + }} + deployer, err := funder.Deployer(cfg) + require.NoError(t, err) + require.Equal(t, root.Address, deployer.Address) + }) +} + +// Without funding the deployer is a fresh key: a chain that credits unknown +// senders pays for it, and no two runs share it. +func TestDeployerWithoutFundingIsFresh(t *testing.T) { + cfg := &config.LoadConfig{} + + first, err := funder.Deployer(cfg) + require.NoError(t, err) + second, err := funder.Deployer(cfg) + require.NoError(t, err) + + require.NotNil(t, first.PrivKey) + require.NotEqual(t, common.Address{}, first.Address) + require.NotEqual(t, first.Address, second.Address) +} + +// Mock deploy never reaches a chain, so it never reads the root key: a dry run +// works on a host that has no key mounted. +func TestDeployerUnderMockDeployIgnoresTheRootKey(t *testing.T) { + root, keyHex := testKey(t) + cfg := &config.LoadConfig{ + MockDeploy: true, + Funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, keyHex) + ".absent"}, + } + + deployer, err := funder.Deployer(cfg) + require.NoError(t, err) + require.NotNil(t, deployer.PrivKey) + require.NotEqual(t, root.Address, deployer.Address) +} + +// An unusable root key fails at resolution, where the run can still report it. +func TestDeployerRootKeyErrors(t *testing.T) { + _, keyHex := testKey(t) + + for _, tc := range []struct { + name string + funding *config.FundingConfig + want string + }{ + { + name: "no key source", + funding: &config.FundingConfig{}, + want: "no root key", + }, + { + name: "missing file", + funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, keyHex) + ".absent"}, + want: "read rootKeyFile", + }, + { + name: "empty file", + funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, "\n")}, + want: "is empty", + }, + { + name: "not a key", + funding: &config.FundingConfig{RootKeyFile: writeKeyFile(t, "not-a-key")}, + want: "parse root key", + }, + { + name: "empty env", + funding: &config.FundingConfig{RootKeyEnv: "SEILOAD_TEST_ROOT_KEY_UNSET"}, + want: "is empty", + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := funder.Deployer(&config.LoadConfig{Funding: tc.funding}) + require.ErrorContains(t, err, tc.want) + }) + } +} diff --git a/funder/doc.go b/funder/doc.go index 1e20526..37761b6 100644 --- a/funder/doc.go +++ b/funder/doc.go @@ -1,5 +1,7 @@ -// Package funder funds seiload's generated account pool from a single root key -// so a load run can execute against a real chain. +// Package funder owns seiload's funded identity: it resolves the root key and +// spends it, both to fund the generated account pool and to name the account +// that signs the run's contract deployments, so a load run can execute against +// a real chain. // // # Why // @@ -12,9 +14,8 @@ // // # Flow // -// FundAccounts runs once at startup, after the generator and sender are built -// and before prewarm and dispatch (both spend gas the accounts don't have until -// funded): +// FundAccounts runs once at startup, after the generator is built and before +// prewarm and dispatch (both spend gas the accounts don't have until funded): // // 1. Resolve the root key (rootKeyFile, preferred; or rootKeyEnv). // 2. Dial the EVM RPC and enumerate every account across the pools. @@ -23,17 +24,37 @@ // 4. Deploy a fresh Disperse contract. // 5. disperseEther the per-account amount to the underfunded set, in batches. // +// # The contract deployer +// +// Deployer names the account that signs the run's contract deployments, and the +// generator receives it (see the generator package doc). With funding configured +// that account is the root: paying for a deployment is a funding concern, and +// the root is the one key a run knows to hold a balance. Without funding it is a +// fresh random account, payable only by a chain that credits unknown senders. +// Scenario contracts are deployer-neutral — none of them gates a load +// transaction on who deployed it — so the choice costs the workload nothing. +// +// # One nonce stream +// +// When the root is also the deployer, the scenario deployments and this +// package's Disperse deployment plus disperseEther batches are one EVM nonce +// stream on one key. Both phases run on the startup goroutine, in sequence, and +// each awaits its receipt before it sends the next tx, so every tx reads a +// pending nonce that already counts the one before it. Overlapping the phases, +// or pinning auth.Nonce in either, collides them. +// // # Cosmos to EVM association // // The root is a single secp256k1 key with both a cosmos (sei1) and an EVM (0x) // representation. Its usei must be EVM-spendable, which on Sei requires the -// account to be associated. The Disperse deploy is the root's first EVM tx, and -// the Sei ante handler auto-associates the sender on its first EVM tx — pulling -// the cosmos balance to the EVM side within that tx. So no explicit association -// step is needed, provided the root is funded at its EVM (cast) address or is -// already associated. Recipients receive native value via the Disperse -// contract, which credits their EVM balance directly; each self-associates on -// its own first load tx. +// account to be associated. The Sei ante handler auto-associates the sender on +// its first EVM tx — pulling the cosmos balance to the EVM side within that tx +// — and the root's first EVM tx of a run is a deploy: a scenario contract when +// the profile has one, else Disperse. So no explicit association step is +// needed, provided the root is funded at its EVM (cast) address or is already +// associated. Recipients receive native value via the Disperse contract, which +// credits their EVM balance directly; each self-associates on its own first +// load tx. // // # Self-deploy, not a configured address // diff --git a/funder/funder.go b/funder/funder.go index d3b638d..f016e16 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -6,42 +6,40 @@ import ( "log" "maps" "math/big" - "os" "slices" - "strings" "sync" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/ethclient" "golang.org/x/sync/errgroup" "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/bindings" + "github.com/sei-protocol/sei-load/types" + "github.com/sei-protocol/sei-load/utils" ) const balanceCheckConcurrency = 16 +// waitTimeout bounds one awaited transaction — the Disperse deploy or a batch. +// Funding runs before the sender starts, so an unbounded wait against a chain +// that accepts a transaction and never mines it leaves the process alive and +// silent with no load offered and nothing to time it out: a profile need not set +// a run duration, and the deploy half of startup already bounds itself. +const waitTimeout = 30 * time.Second + // FundAccounts funds every account to at least the configured // per-account amount from cfg.Funding's root key, or is a no-op when // cfg.Funding is nil. See the package doc for the funding flow, the EVM // auto-association precondition, and the restart/idempotency semantics. -func FundAccounts(ctx context.Context, cfg *config.LoadConfig, addrs []common.Address) error { +func FundAccounts(ctx context.Context, cfg *config.LoadConfig, root types.Account, addrs []common.Address) error { fc := cfg.Funding if fc == nil { return nil } - rootKeyHex, err := resolveRootKey(fc) - if err != nil { - return err - } - // TrimSpace: a SOPS-mounted key file commonly carries a trailing newline. - rootKey, err := crypto.HexToECDSA(strings.TrimPrefix(strings.TrimSpace(rootKeyHex), "0x")) - if err != nil { - return fmt.Errorf("funder: parse root key: %w", err) - } if len(cfg.Endpoints) == 0 { return fmt.Errorf("funder: no endpoints configured") } @@ -59,7 +57,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, addrs []common.Ad } amount := fc.FundAmount() log.Printf("💰 funder: %d accounts, target %s wei each, from %s", - len(addrs), amount.String(), crypto.PubkeyToAddress(rootKey.PublicKey).Hex()) + len(addrs), amount.String(), root.Address.Hex()) underfunded, err := filterUnderfunded(ctx, client, addrs, amount) if err != nil { @@ -72,7 +70,7 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, addrs []common.Ad log.Printf("💰 funder: %d of %d need funding", len(underfunded), len(addrs)) chainID := cfg.GetChainID() - auth, err := bind.NewKeyedTransactorWithChainID(rootKey, chainID) + auth, err := bind.NewKeyedTransactorWithChainID(root.PrivKey, chainID) if err != nil { return fmt.Errorf("funder: transactor: %w", err) } @@ -112,27 +110,6 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, addrs []common.Ad return nil } -func resolveRootKey(fc *config.FundingConfig) (string, error) { - if fc.RootKeyFile != "" { - b, err := os.ReadFile(fc.RootKeyFile) - if err != nil { - return "", fmt.Errorf("funder: read rootKeyFile: %w", err) - } - if len(strings.TrimSpace(string(b))) == 0 { - return "", fmt.Errorf("funder: rootKeyFile %s is empty", fc.RootKeyFile) - } - return string(b), nil - } - if fc.RootKeyEnv != "" { - v := os.Getenv(fc.RootKeyEnv) - if v == "" { - return "", fmt.Errorf("funder: env %s is empty", fc.RootKeyEnv) - } - return v, nil - } - return "", fmt.Errorf("funder: no root key (set funding.rootKeyFile or funding.rootKeyEnv)") -} - func unique[T comparable](vs []T) []T { m := make(map[T]struct{}) for _, v := range vs { @@ -171,9 +148,8 @@ func filterUnderfunded(ctx context.Context, client *ethclient.Client, addrs []co return underfunded, nil } -// deployDisperse deploys a fresh Disperse contract (the root's first EVM tx, -// which also auto-associates it) and verifies it has code. See the package doc -// for why this is not a configurable address. +// deployDisperse deploys a fresh Disperse contract and verifies it has code. +// See the package doc for why this is not a configurable address. func deployDisperse(ctx context.Context, client *ethclient.Client, auth *bind.TransactOpts) (*bindings.Disperse, error) { addr, tx, d, err := bindings.DeployDisperse(auth, client, big.NewInt(0), big.NewInt(0)) if err != nil { @@ -195,12 +171,14 @@ func deployDisperse(ctx context.Context, client *ethclient.Client, auth *bind.Tr // waitSuccess blocks until tx is mined and asserts it did not revert. func waitSuccess(ctx context.Context, client *ethclient.Client, tx *ethtypes.Transaction, what string) error { - receipt, err := bind.WaitMined(ctx, client, tx) - if err != nil { - return fmt.Errorf("funder: wait %s (%s): %w", what, tx.Hash().Hex(), err) - } - if receipt.Status != ethtypes.ReceiptStatusSuccessful { - return fmt.Errorf("funder: %s reverted (tx %s)", what, tx.Hash().Hex()) - } - return nil + return utils.WithinBudget(ctx, waitTimeout, "funder: "+what, func(ctx context.Context) error { + receipt, err := bind.WaitMined(ctx, client, tx) + if err != nil { + return fmt.Errorf("funder: wait %s (%s): %w", what, tx.Hash().Hex(), err) + } + if receipt.Status != ethtypes.ReceiptStatusSuccessful { + return fmt.Errorf("funder: %s reverted (tx %s)", what, tx.Hash().Hex()) + } + return nil + }) } diff --git a/generator/deploy_test.go b/generator/deploy_test.go new file mode 100644 index 0000000..af66c4b --- /dev/null +++ b/generator/deploy_test.go @@ -0,0 +1,148 @@ +package generator_test + +import ( + "encoding/hex" + "os" + "path/filepath" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/funder" + "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// contractConfig is a two-contract profile against chain: the case every +// committed profile leaves untested, since the one profile that configures +// funding runs EVMTransfer only. +func contractConfig(chain *mockChain) *config.LoadConfig { + return &config.LoadConfig{ + ChainID: 7777, + Endpoints: []string{chain.url}, + Accounts: &config.AccountConfig{Accounts: 4}, + Scenarios: []config.Scenario{ + {Name: scenarios.StorageRW, Weight: 1}, + {Name: scenarios.ERC20, Weight: 1}, + }, + } +} + +// rootKeyFile writes a hex private key the way a mounted secret carries it: +// with a trailing newline. +func rootKeyFile(t *testing.T) (types.Account, string) { + t.Helper() + key, err := crypto.GenerateKey() + require.NoError(t, err) + path := filepath.Join(t.TempDir(), "root-key.hex") + require.NoError(t, os.WriteFile(path, []byte(hex.EncodeToString(crypto.FromECDSA(key))+"\n"), 0o600)) + return types.AccountFromKey(key, false), path +} + +// The generator deploys from the account it is handed, at the nonce that +// account has reached on chain — not from a key it mints, and not at the +// scenario's index. +func TestDeployFromReceivedDeployerAtChainNonce(t *testing.T) { + deployer := types.NewAccount(false) + const startNonce = 41 + chain := newMockChain(t, mockChainConfig{ + baseNonce: map[common.Address]uint64{deployer.Address: startNonce}, + }) + + cfg := contractConfig(chain) + rng := newTestRng(1) + gen, err := generator.NewGenerator(t.Context(), rng, cfg, deployer) + require.NoError(t, err) + + // One creation per contract scenario, in order, from the deployer. + require.Equal(t, []uint64{startNonce, startNonce + 1}, chain.noncesFrom(deployer.Address)) + for _, tx := range chain.txsFrom(deployer.Address) { + require.Nil(t, tx.To(), "a deployment is a contract creation") + } + + // The scenarios generate against the contracts those creations produced. + deployed := map[common.Address]bool{ + crypto.CreateAddress(deployer.Address, startNonce): false, + crypto.CreateAddress(deployer.Address, startNonce+1): false, + } + for _, tx := range generateN(t, rng, gen, 20) { + to := tx.EthTx.To() + require.NotNil(t, to) + _, ok := deployed[*to] + require.True(t, ok, "tx targets %s, which no deployment created", to.Hex()) + deployed[*to] = true + } + for address, used := range deployed { + require.True(t, used, "no tx targets the contract at %s", address.Hex()) + } +} + +// A deployment that mines with a failed status fails the run, and says why. +func TestDeployFailureIsAnError(t *testing.T) { + deployer := types.NewAccount(false) + chain := newMockChain(t, mockChainConfig{revertDeployments: true}) + + _, err := generator.NewGenerator(t.Context(), newTestRng(1), contractConfig(chain), deployer) + require.ErrorContains(t, err, "failed to deploy scenarios") + require.ErrorContains(t, err, scenarios.StorageRW) + require.ErrorContains(t, err, "deployment transaction failed with status 0") +} + +// A live deployment needs a key to sign with, and says so when it has none. +func TestDeployWithoutKeyIsAnError(t *testing.T) { + chain := newMockChain(t, mockChainConfig{}) + + _, err := generator.NewGenerator(t.Context(), newTestRng(1), contractConfig(chain), types.Account{}) + require.ErrorContains(t, err, "deployer has no private key") + require.Zero(t, chain.txCount(), "nothing reaches the chain unsigned") +} + +// Funding plus contract scenarios: the deployer is the funding root, and the +// scenario deployments and the funder's own transactions form one ordered nonce +// stream on that key. +func TestFundedRunSharesOneNonceStream(t *testing.T) { + root, keyPath := rootKeyFile(t) + const startNonce = 7 + chain := newMockChain(t, mockChainConfig{ + baseNonce: map[common.Address]uint64{root.Address: startNonce}, + }) + + cfg := contractConfig(chain) + cfg.Funding = &config.FundingConfig{RootKeyFile: keyPath, BatchSize: 2} + + deployer, err := funder.Deployer(cfg) + require.NoError(t, err) + require.Equal(t, root.Address, deployer.Address, "the funded root is the deployer") + + rng := newTestRng(1) + gen, err := generator.NewGenerator(t.Context(), rng, cfg, deployer) + require.NoError(t, err) + + var addrs []common.Address + for _, account := range gen.Accounts() { + addrs = append(addrs, account.Address) + } + require.Len(t, addrs, 4) + require.NoError(t, funder.FundAccounts(t.Context(), cfg, deployer, addrs)) + + // 2 scenario deployments, then the funder's Disperse deployment and 2 + // disperseEther batches for 4 accounts, each at the next nonce. + require.Equal(t, + []uint64{startNonce, startNonce + 1, startNonce + 2, startNonce + 3, startNonce + 4}, + chain.noncesFrom(root.Address), + ) + + // Only the deployments create contracts; the batches call the deployed one. + txs := chain.txsFrom(root.Address) + for _, tx := range txs[:3] { + require.Nil(t, tx.To()) + } + disperse := crypto.CreateAddress(root.Address, startNonce+2) + for _, tx := range txs[3:] { + require.Equal(t, disperse, *tx.To()) + } +} diff --git a/generator/doc.go b/generator/doc.go new file mode 100644 index 0000000..408719e --- /dev/null +++ b/generator/doc.go @@ -0,0 +1,42 @@ +// Package generator turns a load profile into a stream of transactions. +// +// # Build order +// +// NewGenerator runs three steps once, at startup: +// +// 1. createScenarios — one scenario instance per config entry, each bound to an +// account pool (its own, or the shared top-level pool). +// 2. deployAll — deploy the contract each instance needs, in sequence. +// 3. build — expand the instances by weight and shuffle them into the +// round-robin the run draws from. +// +// An error in any step fails the run. A generator that cannot deploy has nothing +// valid to generate, so a failed deployment surfaces as a startup error rather +// than as a run that sends transactions to an address holding no contract. +// +// # The deployer is received, not minted +// +// deployAll signs its deployments with the account NewGenerator is handed. +// Paying for a deployment is a funding concern, and the funder package owns the +// run's funded identity, so funder.Deployer names the account and this package +// spends it. Minting a key here cannot work: no account pool holds it, so +// funding never reaches it, and a chain that charges for gas rejects every +// deployment it signs. +// +// # Deployment nonces +// +// A deployment leaves its nonce unset, so go-ethereum reads the deployer's +// pending nonce from the chain, and deployAll waits for the receipt before it +// sends the next one. This is what makes a deployer with on-chain history safe: +// the funding root has spent nonces before the run, and spends more right after +// these deployments when it funds the pool. A nonce derived from the instance +// index is correct only for a key that starts at zero. Deploying concurrently +// reintroduces the collision the sequence prevents; the funder package doc makes +// the same argument for the same key. +// +// # Mock deploy +// +// Under config.MockDeploy no deployment reaches a chain. Each instance attaches +// its binding at a random address, which is enough to shape calldata, and the +// deployer goes unused. This is the path --dry-run and the unit tests take. +package generator diff --git a/generator/generator.go b/generator/generator.go index 9d004c9..9f9d6c4 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -102,18 +102,25 @@ func (g *generatorBuilder) mockDeployAll() error { return nil } -// DeployAll deploys all scenario instances that require deployment -func (g *generatorBuilder) deployAll() error { - deployer := types.NewAccount(false) +// deployAll deploys all scenario instances that require deployment, from the +// deployer the run was handed. Sequential by design (see package doc): each +// deployment reads its nonce from the chain and is mined before the next is +// sent, so one deployer key stays in one ordered nonce stream. +func (g *generatorBuilder) deployAll(ctx context.Context, deployer types.Account) error { if g.config.MockDeploy { return g.mockDeployAll() } + if deployer.PrivKey == nil { + return errors.New("deployer has no private key (a live deployment must be signed)") + } - // Deploy sequentially to ensure proper nonce management - for i, instance := range g.instances { - // Deploy the scenario + log.Printf("Deploying %d scenarios from %s", len(g.instances), deployer.Address.Hex()) + for _, instance := range g.instances { log.Printf("Deploying scenario %s", instance.Name) - address := instance.Scenario.Deploy(g.config, deployer, uint64(i)) + address, err := instance.Scenario.Deploy(ctx, g.config, deployer) + if err != nil { + return fmt.Errorf("deploy %s: %w", instance.Name, err) + } if address != (common.Address{}) { log.Printf("🚀 Deployed %s at address: %s\n", instance.Name, address.Hex()) } @@ -144,8 +151,10 @@ type TxSender interface { func (g *Generator) Prewarm(ctx context.Context, rng *mrand.Rand, cfg *config.LoadConfig, txSender TxSender) error { // Create EVMTransfer scenario for prewarming evmScenario := scenarios.NewEVMTransferScenario(config.Scenario{}) - // Deploy/initialize the scenario (EVMTransfer doesn't need actual deployment) - evmScenario.Deploy(cfg, types.NewAccount(false), 0) + // EVMTransfer needs no contract, so attaching is all that marks it ready. + if err := evmScenario.Attach(cfg, common.Address{}); err != nil { + return fmt.Errorf("evmScenario.Attach(): %w", err) + } for _, account := range g.Accounts() { // Create self-transfer transaction scenario := &types.TxScenario{ @@ -252,8 +261,11 @@ func ResolveSeed(cfg *config.LoadConfig) *mrand.Rand { return newSeededRand(seed) } -// NewConfigBasedGenerator is a convenience method that combines all steps. -func NewGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (*Generator, error) { +// NewGenerator builds the run's weighted generator: it creates the scenario +// instances, deploys the contracts they need from deployer, and weights them. +// Deployment spends gas, so deployer must be an account the target chain can +// charge; see funder.Deployer, which resolves it. +func NewGenerator(ctx context.Context, rng *mrand.Rand, cfg *config.LoadConfig, deployer types.Account) (*Generator, error) { b := &generatorBuilder{ config: cfg, instances: make([]*scenarioInstance, 0), @@ -265,7 +277,7 @@ func NewGenerator(rng *mrand.Rand, cfg *config.LoadConfig) (*Generator, error) { } // Step 2: Deploy all scenarios - if err := b.deployAll(); err != nil { + if err := b.deployAll(ctx, deployer); err != nil { return nil, fmt.Errorf("failed to deploy scenarios: %w", err) } diff --git a/generator/generator_test.go b/generator/generator_test.go index 5859957..d432b8f 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -104,7 +104,7 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { } rng := newTestRng(1) - gen, err := generator.NewGenerator(rng, cfg) + gen, err := generator.NewGenerator(t.Context(), rng, cfg, types.NewAccount(false)) require.NoError(t, err) require.NotNil(t, gen) diff --git a/generator/mockchain_test.go b/generator/mockchain_test.go new file mode 100644 index 0000000..8dc945d --- /dev/null +++ b/generator/mockchain_test.go @@ -0,0 +1,160 @@ +package generator_test + +import ( + "context" + "encoding/json" + "math/big" + "net/http/httptest" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/rpc" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/utils" +) + +// mockChainConfig shapes one mock chain. +type mockChainConfig struct { + // baseNonce is the nonce an address has already reached before the run, so a + // test can give a deployer on-chain history. + baseNonce map[common.Address]uint64 + // revertDeployments mines every contract creation with a failed status. + revertDeployments bool +} + +// mockChain serves the smallest eth JSON-RPC surface a deployment needs. It +// mines every transaction on arrival, reports a sender's pending nonce as its +// base plus the transactions that sender has sent, and keeps what it received. +// That makes the nonce a deployment actually used observable. +type mockChain struct { + url string + cfg mockChainConfig + state utils.Mutex[*mockChainState] +} + +type mockChainState struct { + mined []minedTx + byHash map[common.Hash]minedTx +} + +// minedTx is one transaction the chain accepted. contract is the created +// address, and is zero for anything but a contract creation. +type minedTx struct { + from common.Address + tx *ethtypes.Transaction + contract common.Address +} + +// newMockChain starts a mock chain on a local HTTP endpoint for the test's +// lifetime. +func newMockChain(t *testing.T, cfg mockChainConfig) *mockChain { + t.Helper() + chain := &mockChain{ + cfg: cfg, + state: utils.NewMutex(&mockChainState{byHash: map[common.Hash]minedTx{}}), + } + srv := rpc.NewServer() + require.NoError(t, srv.RegisterName("eth", chain)) + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + chain.url = ts.URL + return chain +} + +func (m *mockChain) SendRawTransaction(_ context.Context, raw hexutil.Bytes) (common.Hash, error) { + tx := new(ethtypes.Transaction) + if err := tx.UnmarshalBinary(raw); err != nil { + return common.Hash{}, err + } + from, err := ethtypes.Sender(ethtypes.LatestSignerForChainID(tx.ChainId()), tx) + if err != nil { + return common.Hash{}, err + } + mined := minedTx{from: from, tx: tx} + if tx.To() == nil { + mined.contract = crypto.CreateAddress(from, tx.Nonce()) + } + for state := range m.state.Lock() { + state.mined = append(state.mined, mined) + state.byHash[tx.Hash()] = mined + } + return tx.Hash(), nil +} + +func (m *mockChain) GetTransactionCount(_ context.Context, addr common.Address, _ rpc.BlockNumber) (hexutil.Uint64, error) { + return hexutil.Uint64(m.cfg.baseNonce[addr] + uint64(len(m.txsFrom(addr)))), nil +} + +func (m *mockChain) GetTransactionReceipt(_ context.Context, hash common.Hash) (*ethtypes.Receipt, error) { + var ( + mined minedTx + found bool + ) + for state := range m.state.Lock() { + mined, found = state.byHash[hash] + } + if !found { + // A null receipt is how a chain reports a transaction it has not mined. + return nil, nil + } + status := uint64(ethtypes.ReceiptStatusSuccessful) + if m.cfg.revertDeployments && mined.contract != (common.Address{}) { + status = ethtypes.ReceiptStatusFailed + } + return ðtypes.Receipt{ + Type: mined.tx.Type(), + Status: status, + CumulativeGasUsed: 21_000, + Logs: []*ethtypes.Log{}, + TxHash: hash, + ContractAddress: mined.contract, + GasUsed: 21_000, + BlockNumber: big.NewInt(1), + }, nil +} + +func (m *mockChain) GetBalance(_ context.Context, _ common.Address, _ rpc.BlockNumberOrHash) (*hexutil.Big, error) { + return (*hexutil.Big)(new(big.Int)), nil +} + +func (m *mockChain) GetCode(_ context.Context, _ common.Address, _ rpc.BlockNumberOrHash) (hexutil.Bytes, error) { + return hexutil.Bytes{0x60, 0x00}, nil +} + +func (m *mockChain) EstimateGas(_ context.Context, _ json.RawMessage, _ *rpc.BlockNumberOrHash) (hexutil.Uint64, error) { + return hexutil.Uint64(1_000_000), nil +} + +// txCount returns how many transactions the chain has accepted. +func (m *mockChain) txCount() int { + for state := range m.state.Lock() { + return len(state.mined) + } + panic("unreachable") +} + +// txsFrom returns the transactions addr sent, in arrival order. +func (m *mockChain) txsFrom(addr common.Address) []*ethtypes.Transaction { + var txs []*ethtypes.Transaction + for state := range m.state.Lock() { + for _, mined := range state.mined { + if mined.from == addr { + txs = append(txs, mined.tx) + } + } + } + return txs +} + +// noncesFrom returns the nonces addr used, in arrival order. +func (m *mockChain) noncesFrom(addr common.Address) []uint64 { + var nonces []uint64 + for _, tx := range m.txsFrom(addr) { + nonces = append(nonces, tx.Nonce()) + } + return nonces +} diff --git a/generator/scenarios/EVMTransfer.go b/generator/scenarios/EVMTransfer.go index 39da859..762348b 100644 --- a/generator/scenarios/EVMTransfer.go +++ b/generator/scenarios/EVMTransfer.go @@ -1,6 +1,7 @@ package scenarios import ( + "context" "math/big" mrand "math/rand/v2" "time" @@ -32,10 +33,10 @@ func (s *EVMTransferScenario) Name() string { } // DeployScenario implements ScenarioDeployer interface - no deployment needed for ETH transfers -func (s *EVMTransferScenario) DeployScenario(config *config.LoadConfig, deployer types2.Account, nonce uint64) common.Address { +func (s *EVMTransferScenario) DeployScenario(ctx context.Context, config *config.LoadConfig, deployer types2.Account) (common.Address, error) { // No deployment needed for simple ETH transfers // Return zero address to indicate no contract deployment - return common.Address{} + return common.Address{}, nil } // AttachScenario implements ScenarioDeployer interface - no attachment needed for ETH transfers. diff --git a/generator/scenarios/EVMTransferFast.go b/generator/scenarios/EVMTransferFast.go index 0705e11..2f7a660 100644 --- a/generator/scenarios/EVMTransferFast.go +++ b/generator/scenarios/EVMTransferFast.go @@ -1,6 +1,7 @@ package scenarios import ( + "context" "math/big" mrand "math/rand/v2" @@ -32,10 +33,10 @@ func (s *EVMTransferFastScenario) Name() string { } // DeployScenario implements ScenarioDeployer interface - no deployment needed for ETH transfers -func (s *EVMTransferFastScenario) DeployScenario(config *config.LoadConfig, deployer types2.Account, nonce uint64) common.Address { +func (s *EVMTransferFastScenario) DeployScenario(ctx context.Context, config *config.LoadConfig, deployer types2.Account) (common.Address, error) { // No deployment needed for simple ETH transfers // Return zero address to indicate no contract deployment - return common.Address{} + return common.Address{}, nil } // AttachScenario implements ScenarioDeployer interface - no attachment needed for ETH transfers. diff --git a/generator/scenarios/EVMTransferNoop.go b/generator/scenarios/EVMTransferNoop.go index 73ce9af..163c504 100644 --- a/generator/scenarios/EVMTransferNoop.go +++ b/generator/scenarios/EVMTransferNoop.go @@ -1,6 +1,7 @@ package scenarios import ( + "context" "math/big" mrand "math/rand/v2" @@ -31,10 +32,10 @@ func (s *EVMTransferNoopScenario) Name() string { } // DeployScenario implements ScenarioDeployer interface - no deployment needed for ETH transfers -func (s *EVMTransferNoopScenario) DeployScenario(config *config.LoadConfig, deployer types2.Account, nonce uint64) common.Address { +func (s *EVMTransferNoopScenario) DeployScenario(ctx context.Context, config *config.LoadConfig, deployer types2.Account) (common.Address, error) { // No deployment needed for simple ETH transfers // Return zero address to indicate no contract deployment - return common.Address{} + return common.Address{}, nil } // AttachScenario implements ScenarioDeployer interface - no attachment needed for ETH transfers. diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index e5bc353..67382d7 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -1,6 +1,7 @@ package scenarios_test import ( + "context" "encoding/json" "fmt" "math/big" @@ -354,3 +355,23 @@ func TestStorageRWDrawOrderIsStable(t *testing.T) { // Same seed, a fresh scenario: the sequence repeats. require.Equal(t, golden, draw()) } + +// TestDeployTimeoutIsNotAContextSentinel: the deploy budget must not reach the +// caller as context.DeadlineExceeded. main treats those sentinels as a clean +// shutdown so a signalled or duration-bounded run exits zero, and a deployment +// that never mined would otherwise be reported as a successful run that did +// nothing. +func TestDeployTimeoutIsNotAContextSentinel(t *testing.T) { + cfg := &config.LoadConfig{ + ChainID: 7777, + // A blackhole address: dialing is lazy, so the deploy reaches its wait and + // the budget expires there rather than at dial. + Endpoints: []string{"http://198.51.100.1:8545"}, + } + gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) + + _, err := gen.Deploy(t.Context(), cfg, types.GenerateAccounts(1, true)[0]) + require.Error(t, err) + require.NotErrorIs(t, err, context.DeadlineExceeded, + "a deploy budget that escapes as a context sentinel is read by main as a clean shutdown") +} diff --git a/generator/scenarios/base.go b/generator/scenarios/base.go index 46dbbbf..58fbdb9 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -2,6 +2,7 @@ package scenarios import ( "context" + "errors" "fmt" "log" "math/big" @@ -17,6 +18,7 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/utils" "github.com/sei-protocol/sei-load/types" + loadutils "github.com/sei-protocol/sei-load/utils" ) // bigOne is 1 in big.Int. @@ -27,16 +29,17 @@ type TxGenerator interface { Name() string Generate(rng *mrand.Rand, scenario *types.TxScenario) (*ethtypes.Transaction, error) Attach(config *config.LoadConfig, address common.Address) error - Deploy(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address + Deploy(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) } // ScenarioDeployer defines the interface for scenario-specific deployment logic // This can be implemented by both contract and non-contract scenarios type ScenarioDeployer interface { - // DeployScenario handles any setup required for the scenario - // For contracts: deploys the contract and returns its address - // For non-contracts: performs any initialization and returns zero address - DeployScenario(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address + // DeployScenario handles any setup required for the scenario. + // For contracts: deploys the contract from deployer's account and returns its + // address, erroring if the deployment does not mine successfully. + // For non-contracts: performs any initialization and returns zero address. + DeployScenario(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) // AttachScenario connects to an existing contract. AttachScenario(config *config.LoadConfig, address common.Address) common.Address @@ -84,12 +87,18 @@ func NewScenarioBase(deployer ScenarioDeployer, cfg config.Scenario) *ScenarioBa } } -// Deploy handles the common deployment flow -func (s *ScenarioBase) Deploy(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address { +// Deploy handles the common deployment flow. A scenario whose deployment fails +// stays undeployed, so Generate reports it rather than shaping transactions +// against an address that holds no contract. +func (s *ScenarioBase) Deploy(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) { s.config = config - s.address = s.deployer.DeployScenario(config, deployer, nonce) + address, err := s.deployer.DeployScenario(ctx, config, deployer) + if err != nil { + return common.Address{}, err + } + s.address = address s.deployed = true - return s.address + return s.address, nil } // Attach connects to an existing contract. @@ -158,63 +167,72 @@ func (c *ContractScenarioBase[T]) AttachScenario(config *config.LoadConfig, addr return address } -// DeployScenario implements ScenarioDeployer interface for contract scenarios -func (c *ContractScenarioBase[T]) DeployScenario(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address { +// deployTimeout bounds one deployment end to end: the nonce fetch, the send, and +// the wait for the receipt. Nothing else bounds startup, so a chain that never +// mines the transaction would hold the run open. +const deployTimeout = 30 * time.Second + +// DeployScenario implements ScenarioDeployer interface for contract scenarios. +// It bounds the deployment at deployTimeout and reports an expiry of that budget +// without a context sentinel in the error chain: main reads those sentinels as a +// clean shutdown, so a deployment that never mined would otherwise be reported +// as a successful run that did nothing. A sentinel from the caller's own context +// is passed through, because that one really is a shutdown. +func (c *ContractScenarioBase[T]) DeployScenario(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) { + var address common.Address + err := loadutils.WithinBudget(ctx, deployTimeout, "deployment", func(ctx context.Context) error { + var err error + address, err = c.deployWithin(ctx, config, deployer) + return err + }) + return address, err +} + +func (c *ContractScenarioBase[T]) deployWithin(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) { client, err := dial(config) if err != nil { - panic("Failed to connect to Ethereum client: " + err.Error()) + return common.Address{}, fmt.Errorf("dial: %w", err) } - // Create deployment options - auth, err := utils.CreateDeploymentOpts(config.GetChainID(), client, deployer, nonce) + auth, err := utils.CreateDeploymentOpts(ctx, config.GetChainID(), deployer) if err != nil { - panic("Failed to create deployment options: " + err.Error()) + return common.Address{}, fmt.Errorf("deployment options for %s: %w", deployer.Address.Hex(), err) } - // Deploy using contract-specific logic address, tx, err := c.deployer.DeployContract(auth, client) if err != nil { - panic("Failed to deploy contract: " + err.Error()) + return common.Address{}, fmt.Errorf("send deployment from %s: %w", deployer.Address.Hex(), err) } - log.Printf("📤 Deployment transaction sent: %s", tx.Hash().Hex()) - // Wait for the deployment transaction to be mined - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - receipt, err := bind.WaitMined(ctx, client, tx) if err != nil { - panic(fmt.Sprintf("Failed to wait for deployment transaction to be mined: %v", err)) + return common.Address{}, fmt.Errorf("wait for deployment %s: %w", tx.Hash().Hex(), err) } - - // Check if deployment was successful if receipt.Status != ethtypes.ReceiptStatusSuccessful { - panic(describeFailedDeployment(ctx, client, tx, receipt)) + return common.Address{}, failedDeployment(ctx, client, tx, receipt) } - log.Printf("✅ Deployment successful at block %d (gas used: %d)", receipt.BlockNumber.Uint64(), receipt.GasUsed) - // Bind contract instance using the provided bind function - bindFunc := c.deployer.GetBindFunc() - contract, err := bindFunc(address, client) + contract, err := c.deployer.GetBindFunc()(address, client) if err != nil { - panic("Failed to bind contract: " + err.Error()) + return common.Address{}, fmt.Errorf("bind contract at %s: %w", address.Hex(), err) } - - // Store the contract instance c.deployer.SetContract(contract) - return address + return address, nil } -func describeFailedDeployment( +// failedDeployment reports a deployment that mined with a failed status. It +// replays the transaction with eth_call and asks the node for the transaction +// error, so the message carries the revert reason when the node offers one. +func failedDeployment( ctx context.Context, client *ethclient.Client, tx *ethtypes.Transaction, receipt *ethtypes.Receipt, -) string { +) error { msg := fmt.Sprintf( - "Deployment transaction failed with status %d (tx: %s, block: %d, gas used: %d/%d, contract: %s)", + "deployment transaction failed with status %d (tx: %s, block: %d, gas used: %d/%d, contract: %s)", receipt.Status, tx.Hash().Hex(), receipt.BlockNumber.Uint64(), @@ -255,7 +273,7 @@ func describeFailedDeployment( msg += fmt.Sprintf(" [eth_getTransactionErrorByHash: %s]", txErr) } - return msg + return errors.New(msg) } func deployerAddress(tx *ethtypes.Transaction) common.Address { diff --git a/generator/seed_test.go b/generator/seed_test.go index c7c61e0..f9a6ac4 100644 --- a/generator/seed_test.go +++ b/generator/seed_test.go @@ -69,7 +69,7 @@ func gasSeq(t *testing.T, seed uint64, n int) []gasDraw { t.Helper() cfg := seededConfig(t, seed) rng := newTestRng(seed) - gen, err := generator.NewGenerator(rng, cfg) + gen, err := generator.NewGenerator(t.Context(), rng, cfg, types.NewAccount(false)) require.NoError(t, err) txs := generateN(t, rng, gen, n) require.Len(t, txs, n) diff --git a/generator/utils/utils.go b/generator/utils/utils.go index e532d54..ac0e811 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -1,52 +1,83 @@ +// Package utils builds the go-ethereum transact options behind seiload's two +// transaction paths: a contract deployment, which is signed and sent live at +// startup, and a load transaction, which is shaped offline and sent by the +// sender package. +// +// # Nonce sourcing +// +// The two paths source their nonce differently, and the difference is the point. +// A load transaction pins the nonce the generator assigned it: the sender owns +// that per-account sequence, and no RPC round-trip belongs on the hot path. A +// deployment leaves the nonce unset, so go-ethereum reads the deployer's pending +// nonce from the chain. That is what makes a deployer with on-chain history safe +// — the funding root spends nonces of its own, before and after these +// deployments. package utils import ( + "context" "math/big" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" - "github.com/ethereum/go-ethereum/ethclient" loadtypes "github.com/sei-protocol/sei-load/types" ) -// CreateTransactOpts creates transaction options for contract deployment or interaction -func createTransactOpts(chainID *big.Int, account loadtypes.Account, gasLimit uint64, nonce uint64, noSend bool) (*bind.TransactOpts, error) { - // Create transactor +const ( + // deployGasLimit is the gas limit every contract creation is sent with. + deployGasLimit = 3_000_000 + // txGasLimit is the default per-transaction limit; a scenario that knows its + // own cost overrides it. + txGasLimit = 200_000 + // gasTipCapWei is the priority fee (2 gwei). + gasTipCapWei = 2_000_000_000 + // gasFeeCapWei is the max fee, base plus priority (20 gwei). + gasFeeCapWei = 20_000_000_000 + // deployGasFeeCapWei is the max fee for a contract creation (100 gwei), + // matching the funding path because both sign from the same key. + deployGasFeeCapWei = 100_000_000_000 +) + +// CreateDeploymentOpts returns the options for a contract deployment signed by +// account. The transaction is sent live, so ctx bounds the send and the nonce +// fetch behind it. +func CreateDeploymentOpts(ctx context.Context, chainID *big.Int, account loadtypes.Account) (*bind.TransactOpts, error) { auth, err := bind.NewKeyedTransactorWithChainID(account.PrivKey, chainID) if err != nil { return nil, err } - - // Set transaction parameters - auth.Nonce = big.NewInt(int64(nonce)) - auth.NoSend = noSend - - auth.GasLimit = gasLimit - auth.GasTipCap = big.NewInt(2000000000) // 2 gwei tip (priority fee) - auth.GasFeeCap = big.NewInt(20000000000) // 20 gwei max fee (base + priority) - + auth.Context = ctx + auth.GasLimit = deployGasLimit + auth.GasTipCap = big.NewInt(gasTipCapWei) + // A deploy is the first transaction on the deployer's nonce stream, and when + // funding is configured that stream belongs to the root key. Pricing it at + // the load-transaction cap would put the stream's weakest-priced transaction + // at its head, so a base fee above that cap blocks every later root + // transaction until someone replaces the nonce by hand. Match the funding + // cap instead — the same key, the same exposure, one number. + auth.GasFeeCap = big.NewInt(deployGasFeeCapWei) return auth, nil } -// CreateDeploymentOpts creates transaction options specifically for contract deployment -func CreateDeploymentOpts(chainID *big.Int, client *ethclient.Client, account loadtypes.Account, nonce uint64) (*bind.TransactOpts, error) { - // For deployment, use the account's current nonce (don't fetch from blockchain) - // This allows sequential deployments with incrementing nonces - return createTransactOpts(chainID, account, 3000000, nonce, false) // 3M gas limit for deployment -} - -// CreateTransactionOpts creates transaction options for regular contract interactions +// CreateTransactionOpts returns the options for one load transaction against a +// contract. NoSend keeps the transaction in hand for the sender, and the signer +// hands it back unsigned: the sender signs it at send time. func CreateTransactionOpts(chainID *big.Int, scenario *loadtypes.TxScenario) *bind.TransactOpts { - opts, err := createTransactOpts(chainID, scenario.Sender, 200000, scenario.Nonce, true) + auth, err := bind.NewKeyedTransactorWithChainID(scenario.Sender.PrivKey, chainID) if err != nil { panic("Failed to create transaction options: " + err.Error()) } - opts.Signer = func(address common.Address, tx *ethtypes.Transaction) (*ethtypes.Transaction, error) { + auth.Nonce = new(big.Int).SetUint64(scenario.Nonce) + auth.NoSend = true + auth.GasLimit = txGasLimit + auth.GasTipCap = big.NewInt(gasTipCapWei) + auth.GasFeeCap = big.NewInt(gasFeeCapWei) + auth.Signer = func(address common.Address, tx *ethtypes.Transaction) (*ethtypes.Transaction, error) { if address != scenario.Sender.Address { return nil, bind.ErrNotAuthorized } return tx, nil } - return opts + return auth } diff --git a/main.go b/main.go index 28690dd..d8afe7e 100644 --- a/main.go +++ b/main.go @@ -214,8 +214,15 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { inclusion := utils.None[*stats.InclusionTracker]() err = scope.Run(ctx, func(ctx context.Context, s scope.Scope) error { + // The generator deploys as it is built, so resolve who signs those + // deployments first. + deployer, err := funder.Deployer(cfg) + if err != nil { + return fmt.Errorf("failed to resolve contract deployer: %w", err) + } + // Create the generator from the config struct - gen, err := generator.NewGenerator(rng, cfg) + gen, err := generator.NewGenerator(ctx, rng, cfg, deployer) if err != nil { return fmt.Errorf("failed to create generator: %w", err) } @@ -298,13 +305,15 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { snd = sender.NewTxsWriter(cfg.Settings.TargetGas, cfg.Settings.TxsDir, writerHeight, uint64(numBlocksToWrite)) } else { // Fund the pool before prewarm/dispatch — both spend gas the accounts - // don't have until funded. - if cfg.Funding != nil && !cfg.Settings.DryRun { + // don't have until funded. MockDeploy gates it too: with no contract + // on the chain every transaction would hit a code-less address, so + // funding would spend real value on a run that exercises nothing. + if cfg.Funding != nil && !cfg.Settings.DryRun && !cfg.MockDeploy { var addrs []common.Address for _, a := range gen.Accounts() { addrs = append(addrs, a.Address) } - if err := funder.FundAccounts(ctx, cfg, addrs); err != nil { + if err := funder.FundAccounts(ctx, cfg, deployer, addrs); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } } @@ -387,12 +396,28 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { time.Sleep(d) } log.Printf("👋 Shutdown complete") - if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - err = nil + if endedOnRunContext(ctx, err) { + return nil } return err } +// endedOnRunContext reports whether err is just the run finishing: its duration +// elapsed, or the operator signalled it. Both are success. +// +// Matching the sentinels is what works here. A signalled run leaves ctx itself +// uncancelled — cobra runs on an uncancelled context and the handler reads the +// signal off a channel — so the error arrives from a background task that scope +// cancelled on the way out. Testing ctx.Err() would therefore report every +// normal SIGTERM as a failure. +// +// The cost of matching sentinels is that any deadline raised inside the run +// looks the same. Callers that bound their own work must not let a context +// sentinel escape; see DeployScenario, which formats its timeout with %v. +func endedOnRunContext(_ context.Context, err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) +} + // inclusionRegistryCap sizes the inclusion registry. A registry entry lives from // send-completion until block-match or reapAfter — far longer than a send is // in-flight — so MaxInFlight (which bounds concurrent SENDS) under-sizes it. By diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..b77f415 --- /dev/null +++ b/main_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestEndedOnRunContext: a signalled or duration-bounded run exits clean, and a +// real error does not. A signalled run leaves the outer context uncancelled — +// cobra runs uncancelled and the handler reads the signal off a channel — so the +// context error arrives from a background task that scope cancelled on the way +// out, and the predicate has to recognise it without consulting ctx. +func TestEndedOnRunContext(t *testing.T) { + t.Parallel() + + live := context.Background() + // What a signalled shutdown actually produces: scope cancels its own context + // and a background task reports it, while the outer context stays live. + backgroundTaskCanceled := fmt.Errorf("sender: %w", context.Canceled) + + cases := map[string]struct { + err error + want bool + }{ + "operator signalled, outer context still live": {backgroundTaskCanceled, true}, + "run duration elapsed": {context.DeadlineExceeded, true}, + "real error": {errors.New("dial tcp: refused"), false}, + "no error": {nil, false}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.want, endedOnRunContext(live, tc.err)) + }) + } +} diff --git a/sender/sender_test.go b/sender/sender_test.go index 071d977..8509c76 100644 --- a/sender/sender_test.go +++ b/sender/sender_test.go @@ -193,7 +193,7 @@ func TestShardedSender_WithGeneratorAndNonceRewinds(t *testing.T) { cfg := testGeneratorConfigWithAccounts(endpoints, tt.accountCount, tt.newAccountRate) rng := mrand.New(mrand.NewPCG(1, 2)) - gen, err := generator.NewGenerator(rng, cfg) + gen, err := generator.NewGenerator(t.Context(), rng, cfg, types.NewAccount(false)) require.NoError(t, err) ss := newTestShardedSender(endpoints) diff --git a/sender/writer_test.go b/sender/writer_test.go index fa5f945..5cebc96 100644 --- a/sender/writer_test.go +++ b/sender/writer_test.go @@ -92,7 +92,7 @@ func TestTxsWriter_WithGeneratorFinalFiles(t *testing.T) { cfg := testGeneratorConfigWithAccounts(nil, tt.accountCount, tt.newAccountRate) rng := mrand.New(mrand.NewPCG(5, 6)) - gen, err := generator.NewGenerator(rng, cfg) + gen, err := generator.NewGenerator(t.Context(), rng, cfg, types.NewAccount(false)) require.NoError(t, err) outDir := t.TempDir() diff --git a/types/account.go b/types/account.go index 06aab08..74067b5 100644 --- a/types/account.go +++ b/types/account.go @@ -17,10 +17,14 @@ type Account struct { // NewAccount generates new account. func NewAccount(tracked bool) Account { - privateKey := utils.OrPanic1(crypto.GenerateKey()) + return AccountFromKey(utils.OrPanic1(crypto.GenerateKey()), tracked) +} + +// AccountFromKey wraps an existing private key, deriving its EVM address. +func AccountFromKey(privKey *ecdsa.PrivateKey, tracked bool) Account { return Account{ - Address: crypto.PubkeyToAddress(privateKey.PublicKey), - PrivKey: privateKey, + Address: crypto.PubkeyToAddress(privKey.PublicKey), + PrivKey: privKey, Tracked: tracked, } } diff --git a/utils/budget.go b/utils/budget.go new file mode 100644 index 0000000..8673622 --- /dev/null +++ b/utils/budget.go @@ -0,0 +1,29 @@ +package utils + +import ( + "context" + "fmt" + "time" +) + +// WithinBudget runs work under a deadline of its own, and reports an expiry of +// that deadline without a context sentinel in the error chain. +// +// Startup work bounds itself so a chain that accepts a transaction and never +// mines it cannot hold a run open. But main treats context.Canceled and +// context.DeadlineExceeded as a clean exit — that is how a signalled or +// duration-bounded run reports success — so an internal budget that surfaced its +// own sentinel would be read as a successful run that did nothing at all. +// +// A sentinel from the parent context passes through untouched, because that one +// really is a shutdown. +func WithinBudget(ctx context.Context, budget time.Duration, what string, work func(context.Context) error) error { + within, cancel := context.WithTimeout(ctx, budget) + defer cancel() + + err := work(within) + if err != nil && ctx.Err() == nil && within.Err() != nil { + return fmt.Errorf("%s exceeded its %s budget: %v", what, budget, err) + } + return err +} diff --git a/utils/budget_test.go b/utils/budget_test.go new file mode 100644 index 0000000..a3c657d --- /dev/null +++ b/utils/budget_test.go @@ -0,0 +1,59 @@ +package utils_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/utils" +) + +// TestWithinBudgetHidesItsOwnDeadline: an expiry of the budget must not reach +// the caller as a context sentinel. main reads those as a clean exit, so an +// internal timeout that surfaced one would report a run that did nothing as a +// success. +func TestWithinBudgetHidesItsOwnDeadline(t *testing.T) { + t.Parallel() + + err := utils.WithinBudget(context.Background(), time.Millisecond, "work", func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }) + require.Error(t, err) + require.NotErrorIs(t, err, context.DeadlineExceeded) + require.Contains(t, err.Error(), "work exceeded its 1ms budget") +} + +// TestWithinBudgetPassesParentCancellation: a sentinel from the caller's context +// is a real shutdown and must reach main unchanged, or a signalled run would +// report failure. +func TestWithinBudgetPassesParentCancellation(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := utils.WithinBudget(ctx, time.Hour, "work", func(ctx context.Context) error { + <-ctx.Done() + return ctx.Err() + }) + require.ErrorIs(t, err, context.Canceled) +} + +// TestWithinBudgetLeavesOtherErrorsAlone: only a budget expiry is reworded, so a +// real failure keeps its chain and stays matchable with errors.Is. +func TestWithinBudgetLeavesOtherErrorsAlone(t *testing.T) { + t.Parallel() + + sentinel := errors.New("dial tcp: refused") + err := utils.WithinBudget(context.Background(), time.Hour, "work", func(context.Context) error { + return sentinel + }) + require.ErrorIs(t, err, sentinel) + + require.NoError(t, utils.WithinBudget(context.Background(), time.Hour, "work", + func(context.Context) error { return nil })) +}