From b892243a3af15518425347604448c8418fb95112 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 20:11:16 -0700 Subject: [PATCH 1/4] fix(generator): deploy contracts from a funded deployer NewGenerator deployed every contract-backed scenario from a key it minted itself. That key held no balance and sat in no account pool, so funding never reached it and any chain that charges for gas rejected the deployment. The deploy nonce came from the scenario's index, correct only for a key that has never sent a transaction, and every failure path in DeployScenario panicked. The generator now receives its deployer. funder.Deployer resolves it: the funding root when funding is configured, otherwise a fresh key, which only a chain that credits unknown senders can pay for. Mock deploy never reads the root key, so a dry run still works with no key mounted. Each 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. The scenario deployments and the funder's own Disperse deployment and disperseEther batches therefore stay one ordered nonce stream on one key. Deployment failures return errors and fail the run. Tests cover the funding + contract-scenario intersection that no committed profile exercises: a deployer starting at a non-zero nonce, the single nonce stream shared with the funder, and a reverted deployment surfacing as an error. Co-Authored-By: Claude Opus 5 (1M context) --- funder/deployer.go | 66 ++++++++++ funder/deployer_test.go | 140 ++++++++++++++++++++++ funder/doc.go | 45 +++++-- funder/funder.go | 40 +------ generator/deploy_test.go | 148 +++++++++++++++++++++++ generator/doc.go | 42 +++++++ generator/generator.go | 36 ++++-- generator/generator_test.go | 2 +- generator/mockchain_test.go | 160 +++++++++++++++++++++++++ generator/scenarios/EVMTransfer.go | 5 +- generator/scenarios/EVMTransferFast.go | 5 +- generator/scenarios/EVMTransferNoop.go | 5 +- generator/scenarios/base.go | 79 ++++++------ generator/seed_test.go | 2 +- generator/utils/utils.go | 70 +++++++---- main.go | 9 +- sender/sender_test.go | 2 +- sender/writer_test.go | 2 +- types/account.go | 10 +- 19 files changed, 734 insertions(+), 134 deletions(-) create mode 100644 funder/deployer.go create mode 100644 funder/deployer_test.go create mode 100644 generator/deploy_test.go create mode 100644 generator/doc.go create mode 100644 generator/mockchain_test.go 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..a9de1a9 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -6,15 +6,12 @@ import ( "log" "maps" "math/big" - "os" "slices" - "strings" "sync" "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" @@ -33,15 +30,10 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, addrs []common.Ad if fc == nil { return nil } - rootKeyHex, err := resolveRootKey(fc) + root, err := rootAccount(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 +51,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 +64,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 +104,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 +142,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 { diff --git a/generator/deploy_test.go b/generator/deploy_test.go new file mode 100644 index 0000000..402265b --- /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, 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/base.go b/generator/scenarios/base.go index 46dbbbf..88261d5 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -2,6 +2,7 @@ package scenarios import ( "context" + "errors" "fmt" "log" "math/big" @@ -27,16 +28,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 +86,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 +166,60 @@ func (c *ContractScenarioBase[T]) AttachScenario(config *config.LoadConfig, addr return 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 -func (c *ContractScenarioBase[T]) DeployScenario(config *config.LoadConfig, deployer types.Account, nonce uint64) common.Address { +func (c *ContractScenarioBase[T]) DeployScenario(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) { + ctx, cancel := context.WithTimeout(ctx, deployTimeout) + defer cancel() + 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 +260,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..3940a17 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -1,52 +1,74 @@ +// 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 +) + +// 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) + auth.GasFeeCap = big.NewInt(gasFeeCapWei) 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 d22fea0..77fb995 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) } 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, } } From 685fa492ac1a7fdeef87712f042a607eb8c0875b Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 20:41:26 -0700 Subject: [PATCH 2/4] fix(generator): surface a deploy timeout, bound the funding wait, pass one root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four fixes from review, each measured rather than argued. A deploy that never mined exited 0. Converting the deploy path from panic to error routed the new 30s deployTimeout into main's shutdown filter, which nulled any context.DeadlineExceeded so the run-duration timeout could exit cleanly. The filter now requires the run context itself to have expired, so a deadline raised anywhere else surfaces. endedOnRunContext carries the rule and its truth table is tested; the test fails against the sentinel-only form. The funder's WaitMined was unbounded while the deploy half was bounded, and the deploy comment claimed nothing else bounds startup. Against a chain that accepts a transaction and never mines it, funding was still blocked at 45s — and a profile need not set a run duration, so the pod stays alive and silent with no load offered. Both halves are now bounded the same way. A contract creation was priced at the load-transaction cap of 20 gwei while the funder used 100. With one shared key the deploy is the head of the root's nonce stream, so its weakest price blocked every later root transaction once the base fee rose past it. Both now use the same cap. FundAccounts takes the resolved root instead of re-reading the key file, so the deploy and funding phases cannot disagree about which identity they are — the same two-identities-where-one-is-assumed bug this change exists to fix. MockDeploy now gates funding alongside DryRun. They expressed one intent through two predicates, so a profile setting mockDeploy with funding and no --dry-run funded a pool that then transacted against code-less addresses. Co-Authored-By: Claude Opus 5 (1M context) --- funder/funder.go | 18 +++++++++++---- generator/deploy_test.go | 2 +- generator/utils/utils.go | 11 ++++++++- main.go | 27 ++++++++++++++++++---- main_test.go | 49 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 95 insertions(+), 12 deletions(-) create mode 100644 main_test.go diff --git a/funder/funder.go b/funder/funder.go index a9de1a9..c1be4d0 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -8,6 +8,7 @@ import ( "math/big" "slices" "sync" + "time" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" @@ -17,23 +18,27 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/generator/bindings" + "github.com/sei-protocol/sei-load/types" ) 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 } - root, err := rootAccount(fc) - if err != nil { - return err - } if len(cfg.Endpoints) == 0 { return fmt.Errorf("funder: no endpoints configured") } @@ -165,6 +170,9 @@ 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 { + ctx, cancel := context.WithTimeout(ctx, waitTimeout) + defer cancel() + receipt, err := bind.WaitMined(ctx, client, tx) if err != nil { return fmt.Errorf("funder: wait %s (%s): %w", what, tx.Hash().Hex(), err) diff --git a/generator/deploy_test.go b/generator/deploy_test.go index 402265b..af66c4b 100644 --- a/generator/deploy_test.go +++ b/generator/deploy_test.go @@ -127,7 +127,7 @@ func TestFundedRunSharesOneNonceStream(t *testing.T) { addrs = append(addrs, account.Address) } require.Len(t, addrs, 4) - require.NoError(t, funder.FundAccounts(t.Context(), cfg, addrs)) + 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. diff --git a/generator/utils/utils.go b/generator/utils/utils.go index 3940a17..ac0e811 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -34,6 +34,9 @@ const ( 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 @@ -47,7 +50,13 @@ func CreateDeploymentOpts(ctx context.Context, chainID *big.Int, account loadtyp auth.Context = ctx auth.GasLimit = deployGasLimit auth.GasTipCap = big.NewInt(gasTipCapWei) - auth.GasFeeCap = big.NewInt(gasFeeCapWei) + // 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 } diff --git a/main.go b/main.go index 77fb995..67cd810 100644 --- a/main.go +++ b/main.go @@ -305,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) } } @@ -394,12 +396,27 @@ 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. +// +// The run context's own expiry is what qualifies. A deadline raised anywhere +// else carries the same sentinel — a deployment that never mined is the live +// example, since it bounds itself at 30s — and matching on the sentinel alone +// would report that failure as a clean exit, with the error text discarded and +// nothing on stdout to say the run did nothing. +func endedOnRunContext(ctx context.Context, err error) bool { + if ctx.Err() == nil { + return false + } + 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..b395cbd --- /dev/null +++ b/main_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// TestEndedOnRunContext: only the run context's own expiry turns a context error +// into a clean exit. A deployment that timed out carries the same sentinel and +// must still surface, or a run that deployed nothing reports success. +func TestEndedOnRunContext(t *testing.T) { + t.Parallel() + + live := context.Background() + expired, cancel := context.WithTimeout(context.Background(), -time.Second) + defer cancel() + canceled, cancelIt := context.WithCancel(context.Background()) + cancelIt() + + // How a deploy timeout reaches the caller: wrapped with %w through three + // layers, so errors.Is still matches the sentinel. + deployTimeout := fmt.Errorf("failed to create generator: %w", + fmt.Errorf("failed to deploy scenarios: %w", + fmt.Errorf("deploy StorageRW: %w", context.DeadlineExceeded))) + + cases := map[string]struct { + ctx context.Context + err error + want bool + }{ + "deploy timed out, run still live": {live, deployTimeout, false}, + "run duration elapsed": {expired, context.DeadlineExceeded, true}, + "operator signalled": {canceled, context.Canceled, true}, + "real error, run still live": {live, errors.New("dial tcp: refused"), false}, + "real error while run was ending": {expired, errors.New("dial tcp: refused"), false}, + "no error at all": {live, nil, false}, + "deploy timed out as the run ended": {expired, deployTimeout, true}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + require.Equal(t, tc.want, endedOnRunContext(tc.ctx, tc.err)) + }) + } +} From 9e645a7d0623e3755976617097978cf03df74802 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 21:33:03 -0700 Subject: [PATCH 3/4] fix(main): stop the deploy budget from masquerading as a clean shutdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit made main's shutdown filter require the run context to have expired, so a deploy timeout would surface rather than exit 0. That broke the case the filter exists for: cobra runs on an uncancelled context and the signal handler reads off a channel, so a signalled run leaves ctx untouched and the context error arrives from a background task scope cancelled on the way out. Every normal SIGTERM would have reported failure. Fixed at the source instead. The filter goes back to matching the sentinels, which is what a signalled run actually produces, and DeployScenario no longer lets its own budget escape as one: an expiry of the deploy timeout is reported without a context sentinel in the chain, while a sentinel from the caller's context passes through because that really is a shutdown. The first attempt wrapped only the WaitMined call. A test against a blackhole endpoint showed the budget also escapes through the send path — every %w in the function inherits it — so the conversion moved to the function boundary. Covered by TestEndedOnRunContext, which pins a background-task cancellation with a live outer context as a clean exit, and TestDeployTimeoutIsNotAContextSentinel, which asserts the deploy error carries no sentinel. Co-Authored-By: Claude Opus 5 (1M context) --- generator/scenarios/StorageRW_test.go | 21 ++++++++++++++++ generator/scenarios/base.go | 17 +++++++++++-- main.go | 19 +++++++------- main_test.go | 36 ++++++++++----------------- 4 files changed, 59 insertions(+), 34 deletions(-) diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index a66de82..88ea715 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -1,6 +1,7 @@ package scenarios_test import ( + "context" mrand "math/rand/v2" "testing" @@ -80,3 +81,23 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.NoError(t, err) require.Equal(t, rmwSelector, parsed.Methods["rmw"].ID) } + +// 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 88261d5..5e15df5 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -171,11 +171,24 @@ func (c *ContractScenarioBase[T]) AttachScenario(config *config.LoadConfig, addr // mines the transaction would hold the run open. const deployTimeout = 30 * time.Second -// DeployScenario implements ScenarioDeployer interface for contract scenarios +// 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) { - ctx, cancel := context.WithTimeout(ctx, deployTimeout) + deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) defer cancel() + address, err := c.deployWithin(deployCtx, config, deployer) + if err != nil && ctx.Err() == nil && deployCtx.Err() != nil { + return common.Address{}, fmt.Errorf("deployment exceeded its %s budget: %v", deployTimeout, 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 { return common.Address{}, fmt.Errorf("dial: %w", err) diff --git a/main.go b/main.go index 67cd810..602c7f9 100644 --- a/main.go +++ b/main.go @@ -405,15 +405,16 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // endedOnRunContext reports whether err is just the run finishing: its duration // elapsed, or the operator signalled it. Both are success. // -// The run context's own expiry is what qualifies. A deadline raised anywhere -// else carries the same sentinel — a deployment that never mined is the live -// example, since it bounds itself at 30s — and matching on the sentinel alone -// would report that failure as a clean exit, with the error text discarded and -// nothing on stdout to say the run did nothing. -func endedOnRunContext(ctx context.Context, err error) bool { - if ctx.Err() == nil { - return false - } +// 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) } diff --git a/main_test.go b/main_test.go index b395cbd..b77f415 100644 --- a/main_test.go +++ b/main_test.go @@ -5,45 +5,35 @@ import ( "errors" "fmt" "testing" - "time" "github.com/stretchr/testify/require" ) -// TestEndedOnRunContext: only the run context's own expiry turns a context error -// into a clean exit. A deployment that timed out carries the same sentinel and -// must still surface, or a run that deployed nothing reports success. +// 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() - expired, cancel := context.WithTimeout(context.Background(), -time.Second) - defer cancel() - canceled, cancelIt := context.WithCancel(context.Background()) - cancelIt() - - // How a deploy timeout reaches the caller: wrapped with %w through three - // layers, so errors.Is still matches the sentinel. - deployTimeout := fmt.Errorf("failed to create generator: %w", - fmt.Errorf("failed to deploy scenarios: %w", - fmt.Errorf("deploy StorageRW: %w", context.DeadlineExceeded))) + // 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 { - ctx context.Context err error want bool }{ - "deploy timed out, run still live": {live, deployTimeout, false}, - "run duration elapsed": {expired, context.DeadlineExceeded, true}, - "operator signalled": {canceled, context.Canceled, true}, - "real error, run still live": {live, errors.New("dial tcp: refused"), false}, - "real error while run was ending": {expired, errors.New("dial tcp: refused"), false}, - "no error at all": {live, nil, false}, - "deploy timed out as the run ended": {expired, deployTimeout, true}, + "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(tc.ctx, tc.err)) + require.Equal(t, tc.want, endedOnRunContext(live, tc.err)) }) } } From b912b1dc68b43a68634584240b520c87da2c07bb Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 20 Aug 2026 06:31:29 -0700 Subject: [PATCH 4/4] fix(funder): hide the funding budget behind the same boundary as the deploy one The previous commit stopped the deploy budget escaping as a context sentinel, because main reads those as a clean exit so a signalled or duration-bounded run reports success. It left the identical alias in the funder's own wait, which the same commit had only just given a timeout: a Disperse deploy or disperseEther batch that never mined would have exited 0 as a run that funded nothing. Two sites needing one contract, so the contract is now a helper. utils.WithinBudget runs work under a deadline of its own and reports an expiry of that deadline without a sentinel in the chain, while passing a sentinel from the parent context through untouched. Both the deploy and the funding wait route through it, so a third caller inherits the rule rather than rediscovering it. Tested at the helper: a budget expiry carries no sentinel and names the budget, a parent cancellation still matches context.Canceled, and any other error keeps its chain. Verified the first fails when the stripping is removed. Co-Authored-By: Claude Opus 5 (1M context) --- funder/funder.go | 22 +++++++------- generator/scenarios/base.go | 14 ++++----- utils/budget.go | 29 ++++++++++++++++++ utils/budget_test.go | 59 +++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 18 deletions(-) create mode 100644 utils/budget.go create mode 100644 utils/budget_test.go diff --git a/funder/funder.go b/funder/funder.go index c1be4d0..f016e16 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -19,6 +19,7 @@ import ( "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 @@ -170,15 +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 { - ctx, cancel := context.WithTimeout(ctx, waitTimeout) - defer cancel() - - 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/scenarios/base.go b/generator/scenarios/base.go index 5e15df5..58fbdb9 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -18,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. @@ -178,13 +179,12 @@ const deployTimeout = 30 * time.Second // 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) { - deployCtx, cancel := context.WithTimeout(ctx, deployTimeout) - defer cancel() - - address, err := c.deployWithin(deployCtx, config, deployer) - if err != nil && ctx.Err() == nil && deployCtx.Err() != nil { - return common.Address{}, fmt.Errorf("deployment exceeded its %s budget: %v", deployTimeout, err) - } + 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 } 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 })) +}