diff --git a/config/config.go b/config/config.go index 72ba289..9370f7e 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,14 @@ type LoadConfig struct { Funding *FundingConfig `json:"funding,omitempty"` // Path to write a JSON report of the load test. ReportPath string `json:"reportPath,omitempty"` + // gasFeeCapWei is the fee cap every transaction this run sends declares, + // resolved once from the chain at startup. + // + // It is deliberately not a profile field. A fee cap written down is a number + // that goes stale the next time the chain reprices, and a cap under the base + // fee is rejected before the transaction reaches the EVM. Sei's live networks + // have moved past two of the three caps this repo used to hard-code. + gasFeeCapWei *big.Int // Seed roots the PRNG behind every workload draw: key and size // distributions, gas pickers, operation mixes, and account selection. The // same seed and the same config reproduce the same draw sequence. @@ -159,6 +167,44 @@ func (c *LoadConfig) GetChainID() *big.Int { return big.NewInt(c.ChainID) } +// SetGasFeeCap records the cap resolved from the chain. The preparation step +// calls it once, before anything is signed. +func (c *LoadConfig) SetGasFeeCap(wei *big.Int) { + c.gasFeeCapWei = new(big.Int).Set(wei) +} + +// GetGasFeeCap returns the cap resolved from the chain, and whether one was +// resolved. +// +// It reports absence rather than a default, because a default is what the three +// hard-coded caps were. A caller that cannot proceed without one says so. +func (c *LoadConfig) GetGasFeeCap() (*big.Int, bool) { + if c.gasFeeCapWei == nil { + return nil, false + } + return new(big.Int).Set(c.gasFeeCapWei), true +} + +// GetGasFeeCapMultiplier returns what to scale the chain's reported gas price by. +func (c *LoadConfig) GetGasFeeCapMultiplier() float64 { + if c.Settings == nil || c.Settings.GasFeeCapMultiplier < 1 { + return DefaultSettings().GasFeeCapMultiplier + } + return c.Settings.GasFeeCapMultiplier +} + +// GetGasMargin returns the margin to apply to what the chain quotes for a call. +// +// It falls back to the default when a config carries no settings, which is how a +// config assembled in code rather than parsed from a profile arrives. A margin +// of zero would declare no gas at all. +func (c *LoadConfig) GetGasMargin() float64 { + if c.Settings == nil || c.Settings.GasMargin < 1 { + return DefaultSettings().GasMargin + } + return c.Settings.GasMargin +} + // AccountConfig stores the configuration for account generation. type AccountConfig struct { NewAccountRate float64 `json:"newAccountRate,omitempty"` diff --git a/config/settings.go b/config/settings.go index 0d47431..3f1614e 100644 --- a/config/settings.go +++ b/config/settings.go @@ -37,6 +37,34 @@ type Settings struct { // coordinated-omission fix), "closed_loop" (default) keeps the legacy // generate-then-send lockstep as the regression baseline. ArrivalModel string `json:"arrivalModel,omitempty"` + // GasMargin multiplies what the chain quotes for a call, to give the limit + // room the quote does not carry. + // + // It is a margin on execution, not on calldata: the calldata part is a closed + // form over the exact bytes on the wire and needs none. The quote itself + // already carries about 1.5%, because the node stops its search once the + // bracket is that tight. + // + // Erring high is close to free and erring low is not. Sei fills a block + // against two budgets: one charged at the declared limit and one charged at + // what the transaction spends, and the declared one binds only past four + // times the spend. Below that, margin costs no block space, only the balance + // each in-flight transaction locks. A limit under what a call needs, by + // contrast, lands in a block, burns the whole limit, and reports as sent. + GasMargin float64 `json:"gasMargin,omitempty"` + // GasFeeCapMultiplier scales what the chain reports gas costs into the fee + // cap every transaction declares. + // + // The cap is a ceiling, not a price: a transaction pays the base fee and the + // cap only says how high it will follow one. So a generous multiple costs + // nothing per transaction. What it does cost is balance, because the chain + // locks the cap times the gas limit while a transaction is in flight. + // + // It needs to be generous because the base fee moves. Sei raises it by up to + // about 1.9% per block while blocks are full, which is the state a load run + // is trying to produce, and a transaction whose cap the base fee has passed + // is rejected before it reaches the EVM. + GasFeeCapMultiplier float64 `json:"gasFeeCapMultiplier,omitempty"` // MaxInFlight bounds concurrent in-flight sends in the open-loop model; // txs that would exceed it at their scheduled instant are dropped and // counted rather than throttling the arrival clock. @@ -56,6 +84,12 @@ func (s Settings) Validate() error { if s.MaxInFlight <= 0 { return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight) } + if s.GasFeeCapMultiplier < 1 { + return fmt.Errorf("GasFeeCapMultiplier = %v, want >= 1: a cap under what the chain charges is rejected before the transaction reaches the EVM", s.GasFeeCapMultiplier) + } + if s.GasMargin < 1 { + return fmt.Errorf("GasMargin = %v, want >= 1: a margin below 1 declares less gas than the chain quoted, so every transaction burns its limit", s.GasMargin) + } return nil } @@ -80,6 +114,8 @@ func DefaultSettings() Settings { PostSummaryFlushDelay: Duration(25 * time.Second), ArrivalModel: ArrivalModelClosedLoop, MaxInFlight: 10_000, + GasMargin: 1.20, + GasFeeCapMultiplier: 5, } } @@ -133,6 +169,8 @@ func InitializeViper(cmd *cobra.Command) error { viper.SetDefault("postSummaryFlushDelay", defaults.PostSummaryFlushDelay.ToDuration()) viper.SetDefault("arrivalModel", defaults.ArrivalModel) viper.SetDefault("maxInFlight", defaults.MaxInFlight) + viper.SetDefault("gasMargin", defaults.GasMargin) + viper.SetDefault("gasFeeCapMultiplier", defaults.GasFeeCapMultiplier) return nil } @@ -177,5 +215,7 @@ func ResolveSettings() *Settings { PostSummaryFlushDelay: Duration(viper.GetDuration("postSummaryFlushDelay")), ArrivalModel: viper.GetString("arrivalModel"), MaxInFlight: viper.GetInt("maxInFlight"), + GasMargin: viper.GetFloat64("gasMargin"), + GasFeeCapMultiplier: viper.GetFloat64("gasFeeCapMultiplier"), } } diff --git a/config/settings_test.go b/config/settings_test.go index 16c28b9..81a7ae2 100644 --- a/config/settings_test.go +++ b/config/settings_test.go @@ -155,6 +155,8 @@ func TestDefaultSettings(t *testing.T) { PostSummaryFlushDelay: Duration(25 * time.Second), ArrivalModel: ArrivalModelClosedLoop, MaxInFlight: 10_000, + GasMargin: 1.20, + GasFeeCapMultiplier: 5, } if defaults != expected { @@ -170,7 +172,7 @@ func TestSettingsValidate(t *testing.T) { }{ { name: "positive max-in-flight is valid", - settings: Settings{MaxInFlight: 1}, + settings: Settings{MaxInFlight: 1, GasMargin: 1, GasFeeCapMultiplier: 1}, }, { name: "default settings are valid", @@ -178,14 +180,24 @@ func TestSettingsValidate(t *testing.T) { }, { name: "zero max-in-flight is rejected", - settings: Settings{MaxInFlight: 0}, + settings: Settings{MaxInFlight: 0, GasMargin: 1, GasFeeCapMultiplier: 1}, wantErr: "MaxInFlight = 0, want > 0", }, { name: "negative max-in-flight is rejected", - settings: Settings{MaxInFlight: -1}, + settings: Settings{MaxInFlight: -1, GasMargin: 1, GasFeeCapMultiplier: 1}, wantErr: "MaxInFlight = -1, want > 0", }, + { + name: "a fee cap multiplier below one is rejected", + settings: Settings{MaxInFlight: 1, GasMargin: 1, GasFeeCapMultiplier: 0.5}, + wantErr: "GasFeeCapMultiplier = 0.5, want >= 1", + }, + { + name: "a margin below one is rejected", + settings: Settings{MaxInFlight: 1, GasMargin: 0.9, GasFeeCapMultiplier: 1}, + wantErr: "GasMargin = 0.9, want >= 1", + }, } for _, tt := range tests { diff --git a/funder/funder.go b/funder/funder.go index f016e16..7c513ad 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -75,8 +75,12 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, root types.Accoun return fmt.Errorf("funder: transactor: %w", err) } auth.Context = ctx - auth.GasTipCap = big.NewInt(1_000_000_000) // 1 gwei (chain min fee) - auth.GasFeeCap = big.NewInt(100_000_000_000) // 100 gwei + feeCap, ok := cfg.GetGasFeeCap() + if !ok { + return fmt.Errorf("funder: no fee cap resolved from the chain") + } + auth.GasTipCap = big.NewInt(1_000_000_000) // 1 gwei (chain min fee) + auth.GasFeeCap = feeCap disperse, err := deployDisperse(ctx, client, auth) if err != nil { diff --git a/generator/fee.go b/generator/fee.go new file mode 100644 index 0000000..9db6834 --- /dev/null +++ b/generator/fee.go @@ -0,0 +1,62 @@ +package generator + +import ( + "context" + "fmt" + "log" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + + loadutils "github.com/sei-protocol/sei-load/utils" +) + +// feeCapTimeout bounds the one call that resolves the fee cap. ethclient over +// HTTP sets no deadline of its own. +const feeCapTimeout = 30 * time.Second + +// resolveGasFeeCap asks the chain what gas costs and records the cap every +// transaction this run declares. +// +// It runs before anything is signed, because a contract deployment carries a cap +// too and a deployment priced under the base fee is rejected the same way a load +// transaction is. +// +// The alternative was a constant, and this repo carried three of them: 20 gwei +// for a contract call, 100 for a deployment, 200 for a native transfer. Sei's +// live base fee is 50, so one of the three was already rejecting every +// transaction it priced and the other two were guesses that happened to clear. +func (g *generatorBuilder) resolveGasFeeCap(ctx context.Context, client *ethclient.Client) error { + return loadutils.WithinBudget(ctx, feeCapTimeout, "fee cap", func(ctx context.Context) error { + suggested, err := client.SuggestGasPrice(ctx) + if err != nil { + return fmt.Errorf("ask the chain what gas costs: %w", err) + } + if suggested.Sign() <= 0 { + return fmt.Errorf("the chain reported a gas price of %s, so no cap can be derived from it", suggested) + } + cap := scaleWei(suggested, g.config.GetGasFeeCapMultiplier()) + g.config.SetGasFeeCap(cap) + log.Printf("⛽ gas price %s wei, fee cap %s wei (x%.1f)", suggested, cap, g.config.GetGasFeeCapMultiplier()) + return nil + }) +} + +// scaleWei multiplies a wei amount by a fractional factor without leaving the +// integer domain, so a large price cannot lose precision through float64. +func scaleWei(wei *big.Int, factor float64) *big.Int { + const scale = 1000 + num := big.NewInt(int64(factor * scale)) + out := new(big.Int).Mul(wei, num) + return out.Div(out, big.NewInt(scale)) +} + +// mockGasFeeCap records a placeholder cap for a run that reaches no chain. +func (g *generatorBuilder) mockGasFeeCap() { + g.config.SetGasFeeCap(big.NewInt(mockGasFeeCapWei)) +} + +// mockGasFeeCapWei is what a dry run declares. A dry run sends nothing, so this +// is a placeholder rather than a measurement. +const mockGasFeeCapWei = 100_000_000_000 diff --git a/generator/fee_internal_test.go b/generator/fee_internal_test.go new file mode 100644 index 0000000..ccdb982 --- /dev/null +++ b/generator/fee_internal_test.go @@ -0,0 +1,70 @@ +package generator + +import ( + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +// seiBaseFeeGrowthPerBlock is the most Sei raises the base fee in one block +// while blocks are full, which is the state a load run exists to produce. +const seiBaseFeeGrowthPerBlock = 1.019 + +// blocksOfHeadroom returns how many consecutive full blocks the base fee can +// climb through before it passes cap. +func blocksOfHeadroom(cap *big.Int, baseFee int64) float64 { + ratio, _ := new(big.Float).Quo(new(big.Float).SetInt(cap), big.NewFloat(float64(baseFee))).Float64() + return math.Log(ratio) / math.Log(seiBaseFeeGrowthPerBlock) +} + +// TestTheFeeCapSurvivesBaseFeeDrift is the property the whole change exists for. +// +// A cap under the base fee is rejected by the fee ante before the transaction +// reaches the EVM, after the nonce is consumed, so it produces a failed receipt +// rather than no receipt at all. Clearing the base fee at the instant of the +// read is not enough: the run then fills blocks, which is what makes the base +// fee climb, so the cap has to clear it by enough to outlive the climb. +// +// The fixtures are what the three live networks reported: arctic-1 at 10 gwei +// base and 11 suggested, pacific-1 and atlantic-2 at 50 and 55. The constant +// this change removed declared 20 gwei, which cleared the first and not the +// other two. +func TestTheFeeCapSurvivesBaseFeeDrift(t *testing.T) { + // Enough blocks that a run notices the climb and can be restarted, rather + // than starting to fail seconds after it reaches full blocks. + const wantBlocks = 30 + + for _, tc := range []struct { + name string + baseFee int64 + suggested int64 + }{ + {"arctic-1", 10_000_000_000, 11_000_000_000}, + {"pacific-1", 50_000_000_000, 55_000_000_000}, + {"atlantic-2", 50_000_000_000, 55_000_000_000}, + } { + t.Run(tc.name, func(t *testing.T) { + cap := scaleWei(big.NewInt(tc.suggested), 5) + require.Positive(t, cap.Cmp(big.NewInt(tc.baseFee)), + "the cap is at or under the base fee, so the ante rejects every transaction before it runs") + require.GreaterOrEqual(t, blocksOfHeadroom(cap, tc.baseFee), float64(wantBlocks), + "the cap clears the base fee by only %.0f blocks of growth, so it lapses shortly after the run fills blocks", + blocksOfHeadroom(cap, tc.baseFee)) + }) + } +} + +// TestScalingKeepsPrecisionAtChainScale guards the arithmetic. A price is wei, +// which passes what a float64 holds exactly, so scaling through one would move +// the cap by an amount nothing else in the run would explain. +func TestScalingKeepsPrecisionAtChainScale(t *testing.T) { + huge, ok := new(big.Int).SetString("123456789012345678901234567890", 10) + require.True(t, ok) + + require.Equal(t, "246913578024691357802469135780", scaleWei(huge, 2).String(), + "doubling a chain-scale price did not double it, so the cap is derived through a lossy conversion") + require.Equal(t, "55000000000", scaleWei(big.NewInt(11_000_000_000), 5).String()) + require.Equal(t, "16500000000", scaleWei(big.NewInt(11_000_000_000), 1.5).String()) +} diff --git a/generator/fee_test.go b/generator/fee_test.go new file mode 100644 index 0000000..42d0c29 --- /dev/null +++ b/generator/fee_test.go @@ -0,0 +1,41 @@ +package generator_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// TestStartupResolvesTheFeeCapFromTheChain drives the real startup path against +// a chain and asserts the cap it came away with. +// +// The arithmetic has its own test. This one asserts startup actually applies it: +// a resolver that read the price and forgot to scale it would leave the run +// declaring what the chain charges right now, with no room for the base fee to +// climb once the run starts filling blocks. +func TestStartupResolvesTheFeeCapFromTheChain(t *testing.T) { + chain := newMockChain(t, mockChainConfig{}) + cfg := &config.LoadConfig{ + ChainID: 7777, + Endpoints: []string{chain.url}, + Accounts: &config.AccountConfig{Accounts: 2}, + Scenarios: []config.Scenario{{Name: scenarios.ERC20, Weight: 1}}, + Settings: &config.Settings{GasFeeCapMultiplier: 5, GasMargin: 1.2, MaxInFlight: 10}, + } + + _, err := generator.NewGenerator(t.Context(), newTestRng(1), cfg, types.NewAccount(false)) + require.NoError(t, err) + + cap, ok := cfg.GetGasFeeCap() + require.True(t, ok, "startup finished without resolving a fee cap, so every transaction is priced by nothing") + + want := new(big.Int).Mul(big.NewInt(mockGasPriceWei), big.NewInt(5)) + require.Equal(t, want.String(), cap.String(), + "the cap is not the chain's price scaled by the configured multiplier, so it carries no room for the base fee to climb") +} diff --git a/generator/gas.go b/generator/gas.go new file mode 100644 index 0000000..3d0a55d --- /dev/null +++ b/generator/gas.go @@ -0,0 +1,220 @@ +package generator + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" + loadutils "github.com/sei-protocol/sei-load/utils" +) + +// gasMeasureTimeout bounds the whole pricing step. ethclient over HTTP sets no +// deadline of its own, so an endpoint that accepts the connection and never +// answers would hold startup open with nothing logged. +const gasMeasureTimeout = 60 * time.Second + +// gasEstimateCallTimeout bounds one quote inside the collective budget, so a +// single endpoint that accepts a request and never answers cannot spend the +// whole step's ceiling and starve every scenario behind it. +const gasEstimateCallTimeout = 10 * time.Second + +// gasEstimateCap is the largest limit a node will estimate. Sei caps +// eth_estimateGas at its simulation gas limit, so a shape needing more than this +// cannot be priced at all and the error names the allowance rather than the +// shape. +const gasEstimateCap = 10_000_000 + +// measureGasLimits asks the chain what each scenario's calls cost, and stores +// the answer for the run. +// +// It runs after every contract is bound, because a call is priced against the +// deployment the run will actually drive. It runs before funding, because +// pricing needs no funded account: the estimate carries no fee cap, so the node +// does not check the caller's balance. +// +// A failure here stops the run. The alternative is a hard-coded limit, and a +// limit below what a call needs does not fail visibly: the transaction reaches a +// block, burns the whole limit, and is reported as sent. Refusing to start says +// so once, at startup, instead of publishing a throughput number that is a +// fabrication. +func (g *generatorBuilder) measureGasLimits(ctx context.Context, client *ethclient.Client, bindings []*binding) error { + type priced struct { + name string + address common.Address + price scenarios.GasEstimateCaller + } + var work []priced + for _, b := range bindings { + for _, instance := range b.instances { + if price := instance.Scenario.GasEstimateCaller(); price != nil { + work = append(work, priced{instance.Name, b.address, price}) + } + } + } + // A profile of native transfers alone prices nothing, so it reads no header + // and issues no estimate. Startup costs what the profile asks for. + if len(work) == 0 { + return nil + } + + return loadutils.WithinBudget(ctx, gasMeasureTimeout, "gas measurement", func(ctx context.Context) error { + blockGasLimit, err := blockGasLimit(ctx, client) + if err != nil { + return err + } + margin := g.config.GetGasMargin() + for _, w := range work { + estimate := gasEstimator(client, w.address, w.name, margin, blockGasLimit) + if err := w.price(ctx, estimate); err != nil { + return fmt.Errorf("price %s: %w", w.name, err) + } + } + return nil + }) +} + +// gasEstimator returns the estimator one scenario's calls are priced through. +func gasEstimator(client *ethclient.Client, address common.Address, name string, + margin float64, blockGasLimit uint64) scenarios.GasEstimator { + return func(ctx context.Context, call scenarios.GasEstimateCall) (scenarios.GasModel, error) { + // Bound each quote inside the step's collective budget, so one endpoint + // that accepts a request and never answers cannot spend the ceiling for + // every scenario behind it. + // + // Through WithinBudget rather than a bare context, because main reads a + // context sentinel in the error chain as a clean shutdown. A raw timeout + // here would expire while the collective budget was still healthy, so + // nothing above would strip it, and a run that never priced a call would + // exit reporting success. + var required uint64 + err := loadutils.WithinBudget(ctx, gasEstimateCallTimeout, "a gas quote", + func(ctx context.Context) error { + var err error + required, err = estimate(ctx, client, address, call) + return err + }) + if err != nil { + return scenarios.GasModel{}, err + } + + intrinsic, err := core.IntrinsicGas(call.Data, nil, nil, false, true, true, true) + if err != nil { + return scenarios.GasModel{}, fmt.Errorf("intrinsic gas: %w", err) + } + if required <= intrinsic { + return scenarios.GasModel{}, fmt.Errorf( + "the chain quoted %d, at or under this call's own intrinsic cost of %d", required, intrinsic) + } + + model := scenarios.GasModel{Exec: required - intrinsic, Margin: margin} + limit, err := model.Limit(call.Data) + if err != nil { + return scenarios.GasModel{}, err + } + // Against the priced call's own bytes. A scenario whose calldata varies + // recomposes per transaction and can exceed this without the check seeing + // it: StorageRW at its largest pad wants several times what its empty-pad + // probe does. Covering that needs the size distribution's maximum visible + // here, which it is not. + if limit > blockGasLimit { + return scenarios.GasModel{}, fmt.Errorf( + "needs %d gas for the call priced here, past the %d this run will admit, so no limit carries it", + limit, blockGasLimit) + } + log.Printf("⛽ %s/%s: chain quoted %d, limit %d (margin %.2f)", name, call.Operation, required, limit, margin) + return model, nil + } +} + +// estimate asks the chain what one call costs. +// +// The caller is an address this run mints and never uses again, so every slot +// the call writes is still zero and the quote is the expensive shape. +// +// The three fee fields stay unset, because a call carrying one makes the node +// recap its search by the caller's balance and this caller has none. That is +// enough for a call carrying no value. It is not enough for one that does: the +// state transition checks that the caller can cover msg.value whatever the fee +// fields say, so a value-carrying call from an unfunded address fails with +// insufficient funds before it runs. Verified against arctic-1, where the same +// call estimates at 180,067 with no value and fails outright with 100 wei of it. +// +// So a value-carrying call is estimated through the raw endpoint with a state +// override that gives the caller exactly what the call sends. The override +// touches the caller's balance and nothing else, so the contract's slots stay +// cold and the quote stays the expensive shape. +// +// Only that path needs the override. A call sending no value goes through +// ethclient as before, so an endpoint serving no state overrides still prices +// every scenario that does not need one. +func estimate(ctx context.Context, client *ethclient.Client, to common.Address, + call scenarios.GasEstimateCall) (uint64, error) { + from := types.NewAccount(false).Address + + if call.Value == nil || call.Value.Sign() == 0 { + return client.EstimateGas(ctx, ethereum.CallMsg{From: from, To: &to, Data: call.Data}) + } + + arg := map[string]any{ + "from": from, + "to": &to, + "input": hexutil.Bytes(call.Data), + "value": (*hexutil.Big)(call.Value), + } + overrides := map[string]any{ + from.Hex(): map[string]any{"balance": (*hexutil.Big)(call.Value)}, + } + + var required hexutil.Uint64 + if err := client.Client().CallContext(ctx, &required, "eth_estimateGas", arg, "latest", overrides); err != nil { + return 0, fmt.Errorf("estimate a call carrying %s wei: %w", call.Value, err) + } + return uint64(required), nil +} + +// blockGasLimit reads what one block admits, so a priced call that cannot fit in +// any block fails at startup rather than on every send. +func blockGasLimit(ctx context.Context, client *ethclient.Client) (uint64, error) { + header, err := client.HeaderByNumber(ctx, nil) + if err != nil { + return 0, fmt.Errorf("read the latest header for the block gas limit: %w", err) + } + // The smaller of what a block admits and what a node will estimate, so the + // error above names the bound that actually applies. + return min(header.GasLimit, gasEstimateCap), nil +} + +// mockGasLimitExec is what a dry run reports as the execution term. A dry run +// reaches no chain, so nothing here is a measurement. One shared value keeps +// that obvious: a per-scenario number would read like one. +const mockGasLimitExec = 200_000 + +// mockGasLimits gives every scenario a limit without asking a chain, so a dry +// run can preview a profile. It is not a measurement and the log says so. +func (g *generatorBuilder) mockGasLimits(ctx context.Context, bindings []*binding) error { + log.Printf("⛽ dry run: gas limits are not measured") + for _, b := range bindings { + for _, instance := range b.instances { + price := instance.Scenario.GasEstimateCaller() + if price == nil { + continue + } + estimate := func(context.Context, scenarios.GasEstimateCall) (scenarios.GasModel, error) { + return scenarios.GasModel{Exec: mockGasLimitExec, Margin: g.config.GetGasMargin()}, nil + } + if err := price(ctx, estimate); err != nil { + return fmt.Errorf("price %s: %w", instance.Name, err) + } + } + } + return nil +} diff --git a/generator/gas_budget_test.go b/generator/gas_budget_test.go new file mode 100644 index 0000000..7b339d1 --- /dev/null +++ b/generator/gas_budget_test.go @@ -0,0 +1,43 @@ +package generator_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// TestAHungQuoteIsNotACleanShutdown guards the shape of the error, not only its +// presence. +// +// main reads a context sentinel in the error chain as a clean shutdown, so a +// bare per-quote timeout would expire while the collective budget was still +// healthy, nothing above would strip it, and a run that never priced a single +// call would exit reporting success. That is the failure this whole change +// exists to remove, arriving through the guard added to bound it. +func TestAHungQuoteIsNotACleanShutdown(t *testing.T) { + chain := newMockChain(t, mockChainConfig{hangEstimates: true}) + cfg := &config.LoadConfig{ + ChainID: 7777, + Endpoints: []string{chain.url}, + Accounts: &config.AccountConfig{Accounts: 2}, + Scenarios: []config.Scenario{{Name: scenarios.ERC20, Weight: 1}}, + } + + _, err := generator.NewGenerator(t.Context(), newTestRng(1), cfg, types.NewAccount(false)) + require.Error(t, err, "a chain that never answers a quote let startup finish") + + require.False(t, errors.Is(err, context.DeadlineExceeded), + "the error carries a deadline sentinel, which main reads as a clean shutdown: "+ + "a run that priced nothing would exit reporting success. got: %v", err) + require.True(t, strings.Contains(err.Error(), "budget"), + "the error does not say a budget was exceeded, so an operator cannot tell "+ + "a hung endpoint from a refused one. got: %v", err) +} diff --git a/generator/gas_wire_test.go b/generator/gas_wire_test.go new file mode 100644 index 0000000..f1ad9d0 --- /dev/null +++ b/generator/gas_wire_test.go @@ -0,0 +1,67 @@ +package generator_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// TestAPricedCallReachesTheNodeInTheRightShape asserts what the estimate looks +// like on the wire, which is where both of this step's decisions live. +// +// The caller is an address this run mints and never uses, so the call prices the +// expensive shape. It carries no fee field, because a call carrying one makes +// the node recap its search by the caller's balance and that caller has none. +// And a call whose method checks msg.value carries that value — with a balance +// override, because the value check is not gated on the fee fields and an +// unfunded caller fails it outright. +// +// Nothing else exercises this path: every other test stubs the estimator. The +// defect this catches shipped once and was found by review rather than here. +func TestAPricedCallReachesTheNodeInTheRightShape(t *testing.T) { + chain := newMockChain(t, mockChainConfig{}) + cfg := &config.LoadConfig{ + ChainID: 7777, + Endpoints: []string{chain.url}, + Accounts: &config.AccountConfig{Accounts: 2}, + Scenarios: []config.Scenario{ + {Name: scenarios.ERC20, Weight: 1}, + {Name: scenarios.Disperse, Weight: 1}, + }, + } + + _, err := generator.NewGenerator(t.Context(), newTestRng(1), cfg, types.NewAccount(false)) + require.NoError(t, err) + + calls := chain.estimates() + require.NotEmpty(t, calls, "startup priced nothing, so this asserts about no request at all") + + var valueCarrying int + for _, call := range calls { + require.Nil(t, call.GasPrice, "a priced call carried a gas price, so the node recaps by the caller's balance and the caller has none") + require.Nil(t, call.FeeCap, "a priced call carried a fee cap, for the same reason") + require.Nil(t, call.TipCap, "a priced call carried a tip cap, for the same reason") + require.NotEmpty(t, call.Input, "a priced call carried no calldata, so the node quoted a plain transfer") + + if call.Value == nil || call.Value.ToInt().Sign() == 0 { + continue + } + valueCarrying++ + // The value check is not gated on the fee fields, so a value-carrying + // call from an unfunded address fails before it runs. + funded, ok := call.Overrides[call.From.Hex()] + require.True(t, ok, + "a call carrying %s wei reached the node with no balance override for its caller, so it fails with insufficient funds and the run refuses to start", + call.Value.ToInt()) + require.NotNil(t, funded.Balance) + require.GreaterOrEqual(t, funded.Balance.ToInt().Cmp(call.Value.ToInt()), 0, + "the override funds the caller with less than the call sends") + } + require.NotZero(t, valueCarrying, + "no priced call carried a value, so this test asserts nothing about the path it exists for") +} diff --git a/generator/generator_test.go b/generator/generator_test.go index ba47838..4c1544d 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -109,6 +109,9 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { }, }, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) rng := newTestRng(1) gen, err := generator.NewGenerator(t.Context(), rng, cfg, types.NewAccount(false)) @@ -147,9 +150,12 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { // scenario's name, and it once stamped IntendedSendTime, which defeated the // inclusion tracker's not-scheduled guard. func TestPrewarmLabelsEveryTransaction(t *testing.T) { + // A real chain, because startup asks it what gas costs before it signs + // anything, and a transfer declares a fee cap like every other transaction. + chain := newMockChain(t, mockChainConfig{}) cfg := &config.LoadConfig{ ChainID: 7777, - Endpoints: []string{"http://localhost:8545"}, + Endpoints: []string{chain.url}, Accounts: &config.AccountConfig{Accounts: 4}, Scenarios: []config.Scenario{{Name: scenarios.EVMTransfer, Weight: 1}}, } diff --git a/generator/mockchain_test.go b/generator/mockchain_test.go index 8e32a42..fe3da75 100644 --- a/generator/mockchain_test.go +++ b/generator/mockchain_test.go @@ -29,6 +29,9 @@ type mockChainConfig struct { // address hold nothing or hold the wrong contract. Nil serves defaultCode // everywhere, which is what a deployment test wants. code map[common.Address][]byte + // hangEstimates makes eth_estimateGas accept the request and never answer, + // which is the failure a per-quote budget exists to bound. + hangEstimates bool } // defaultCode is what GetCode serves when a test sets no per-address code. @@ -50,6 +53,9 @@ type mockChainState struct { // codeReads records every address GetCode was asked for, in order, so a test // can assert the startup read count and which contracts it covered. codeReads []common.Address + // estimates records every eth_estimateGas request, so a test can assert the + // shape of the call rather than only its answer. + estimates []estimateCall } // minedTx is one transaction the chain accepted. contract is the created @@ -151,8 +157,95 @@ func (m *mockChain) codeReads() []common.Address { return reads } -func (m *mockChain) EstimateGas(_ context.Context, _ json.RawMessage, _ *rpc.BlockNumberOrHash) (hexutil.Uint64, error) { - return hexutil.Uint64(1_000_000), nil +// mockQuotedGas is what the chain quotes for every call. It is well under +// mockBlockGasLimit, so the startup guard that rejects a call too large for any +// block does not fire on a shape a test never meant to be oversized. +const mockQuotedGas = 200_000 + +// mockBlockGasLimit is what one block admits. Gas sizing reads it to reject a +// call no block could carry. +const mockBlockGasLimit = 12_500_000 + +// estimateCall is one eth_estimateGas request as it reached the node. The wire +// shape is where both of this run's estimator decisions live — that a priced +// call carries the value its method requires, and that it carries no fee field — +// so the mock records it rather than discarding it. +type estimateCall struct { + From common.Address `json:"from"` + To *common.Address `json:"to"` + Input hexutil.Bytes `json:"input"` + Value *hexutil.Big `json:"value"` + GasPrice *hexutil.Big `json:"gasPrice"` + FeeCap *hexutil.Big `json:"maxFeePerGas"` + TipCap *hexutil.Big `json:"maxPriorityFeePerGas"` + Overrides map[string]struct { + Balance *hexutil.Big `json:"balance"` + } `json:"-"` +} + +func (m *mockChain) EstimateGas(ctx context.Context, arg json.RawMessage, _ *rpc.BlockNumberOrHash, overrides *json.RawMessage) (hexutil.Uint64, error) { + if m.cfg.hangEstimates { + <-ctx.Done() + return 0, ctx.Err() + } + var call estimateCall + if err := json.Unmarshal(arg, &call); err != nil { + return 0, err + } + if overrides != nil { + if err := json.Unmarshal(*overrides, &call.Overrides); err != nil { + return 0, err + } + } + for state := range m.state.Lock() { + state.estimates = append(state.estimates, call) + } + return hexutil.Uint64(mockQuotedGas), nil +} + +// estimates returns every eth_estimateGas request the chain received, in order. +func (m *mockChain) estimates() []estimateCall { + for state := range m.state.Lock() { + return slices.Clone(state.estimates) + } + panic("unreachable") +} + +// mockGasPriceWei is what the chain reports gas costs. Fee-cap resolution reads +// it once at startup. +const mockGasPriceWei = 10_000_000_000 + +// GasPrice serves what gas costs, which the run scales into the fee cap every +// transaction declares. +func (m *mockChain) GasPrice(_ context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(mockGasPriceWei)), nil +} + +// GetBlockByNumber serves a header carrying the block gas limit. Gas sizing +// reads it once at startup. +func (m *mockChain) GetBlockByNumber(_ context.Context, _ rpc.BlockNumber, _ bool) (map[string]any, error) { + return map[string]any{ + "number": hexutil.Uint64(1), + "hash": common.Hash{}, + "parentHash": common.Hash{}, + "sha3Uncles": common.Hash{}, + "stateRoot": common.Hash{}, + "transactionsRoot": common.Hash{}, + "receiptsRoot": common.Hash{}, + "logsBloom": hexutil.Bytes(make([]byte, 256)), + "difficulty": (*hexutil.Big)(big.NewInt(0)), + "gasLimit": hexutil.Uint64(mockBlockGasLimit), + "gasUsed": hexutil.Uint64(0), + "timestamp": hexutil.Uint64(1), + "extraData": hexutil.Bytes{}, + "miner": common.Address{}, + "nonce": ethtypes.BlockNonce{}, + "mixHash": common.Hash{}, + "size": hexutil.Uint64(0), + "totalDifficulty": (*hexutil.Big)(big.NewInt(0)), + "transactions": []common.Hash{}, + "uncles": []common.Hash{}, + }, nil } // txCount returns how many transactions the chain has accepted. diff --git a/generator/prepare.go b/generator/prepare.go index be747e6..62f8a35 100644 --- a/generator/prepare.go +++ b/generator/prepare.go @@ -107,6 +107,11 @@ func (g *generatorBuilder) prepareAll(ctx context.Context, deployer types.Accoun } defer client.Close() + // Before anything is signed: a deployment declares a fee cap too. + if err := g.resolveGasFeeCap(ctx, client); err != nil { + return err + } + bindings, err := g.planAll(ctx, reg, client) if err != nil { return err @@ -120,7 +125,12 @@ func (g *generatorBuilder) prepareAll(ctx context.Context, deployer types.Accoun if err := g.bindAll(client, bindings); err != nil { return err } - return g.recordDeployments(bindings, reg) + if err := g.recordDeployments(bindings, reg); err != nil { + return err + } + // Last, so a pricing failure does not discard the record of deployments this + // run already paid for. + return g.measureGasLimits(ctx, client, bindings) } // planAll decides one address per contract the profile drives. It deploys @@ -373,13 +383,17 @@ func (g *generatorBuilder) mockPrepareAll() error { if err != nil { return err } + g.mockGasFeeCap() if err := g.readyAll(); err != nil { return err } for _, b := range bindings { b.address = types.NewAccount(false).Address } - return g.bindAll(nil, bindings) + if err := g.bindAll(nil, bindings); err != nil { + return err + } + return g.mockGasLimits(context.Background(), bindings) } // recordDeployments writes a chain file describing this chain, for an operator to diff --git a/generator/scenarios/AMM.go b/generator/scenarios/AMM.go index b45c673..0c6be2a 100644 --- a/generator/scenarios/AMM.go +++ b/generator/scenarios/AMM.go @@ -17,40 +17,6 @@ import ( const AMM = "amm" -// ammSwapGas bounds one swap. -// -// The number is a required gas limit read from eth_estimateGas, not a receipt's -// GasUsed. GasUsed is what the chain charges after the refund lands at the end -// of execution; a transaction still has to be provisioned for the peak before -// it. Sizing this constant from a receipt once put it 20% under the limit the -// same swap needed, which fails every transaction and burns the whole limit. -// -// Two shapes, measured over six accounts against the deployed binding on a -// chain running the default storage gas costs: -// -// - 79,988, an account's first swap. It writes the account's balance in both -// tokens from zero, and a zero to non-zero storage write costs four times -// one that changes a slot already holding a value. -// - 45,177, every later swap by that account. The balances wrap rather than -// return to zero, so the slots stay non-zero for the rest of the run. -// -// One limit has to cover the higher shape, so a run in steady state declares -// about 44% more gas than it spends. A chain that admits transactions against -// their declared limit reserves that difference for gas no swap uses, which -// costs the throughput a profile can reach. Priming both slots during prewarm -// would let this drop near the lower shape; that needs a transaction class the -// prewarm path does not have yet, and PLT-1093 carries it. -// -// The calibration assumes the chain charges the default 20,000 for a zero to -// non-zero storage write. Sei sets that as a chain parameter, and pacific-1 and -// atlantic-2 charge about 74,700, which puts the first shape near 185,000 -// there. Every hard-coded limit in this package has the same exposure, so -// PLT-1092 covers the package rather than this constant. -// -// Estimating per transaction would put an eth_estimateGas on the send path, -// which is the load this tool exists to avoid adding. -const ammSwapGas = 85_000 - // ammSwapAmount is the input every swap sends. // // It is fixed rather than drawn, because drawing it would buy no gas coverage. @@ -110,11 +76,19 @@ func (s *AMMScenario) SetContract(contract *bindings.AMM) { s.contract = contract } +// GasEstimateCalls prices both legs. They are symmetric, but the run stamps the +// operation it drew onto the metric, so each is priced under its own name rather +// than one standing in for the other. +func (s *AMMScenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpSwapAToB, Data: mustPack(bindings.AMMMetaData, "swapAToB", ammSwapAmount)}, + {Operation: config.OpSwapBToA, Data: mustPack(bindings.AMMMetaData, "swapBToA", ammSwapAmount)}, + } +} + // CreateContractTransaction implements ContractDeployer - builds one swap in the // direction the operation mix drew. func (s *AMMScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = ammSwapGas - // The draw is part of the replay contract. One draw today, so there is no // order to get wrong; a second axis must land after this one, because every // scenario shares the run's PRNG and a reordered draw shifts every later @@ -122,6 +96,12 @@ func (s *AMMScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.Tran op := s.operations.Select(rng) scenario.Operation = op + limit, ok := s.GasLimitFor(op) + if !ok { + return nil, fmt.Errorf("amm: no measured gas limit for operation %q", op) + } + auth.GasLimit = limit + switch op { case config.OpSwapAToB: return s.contract.SwapAToB(auth, ammSwapAmount) diff --git a/generator/scenarios/AMM_test.go b/generator/scenarios/AMM_test.go index bca6023..377c5be 100644 --- a/generator/scenarios/AMM_test.go +++ b/generator/scenarios/AMM_test.go @@ -1,6 +1,7 @@ package scenarios_test import ( + "math/big" mrand "math/rand/v2" "testing" @@ -12,17 +13,6 @@ import ( "github.com/sei-protocol/sei-load/types" ) -// ammColdSwapGas is the largest gas limit one swap required when measured -// against the deployed binding over six accounts, on a chain running the -// default storage gas costs. It is a required limit read from eth_estimateGas, -// not a receipt's GasUsed, because a transaction is provisioned for the peak -// before the refund lands. The steady-state shape needed 45,177. -// -// It is written down so the limit is checked against a measurement rather than -// against itself. An assertion comparing the limit to its own constant passes -// at any value. -const ammColdSwapGas = 79_988 - func newAttachedAMM(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { t.Helper() sc.Name = scenarios.AMM @@ -31,9 +21,13 @@ func newAttachedAMM(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *t MockDeploy: true, Endpoints: []string{"http://localhost:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(sc) require.NoError(t, gen.Ready(cfg)) require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) + priceGasCalls(t, gen) return gen, &types.TxScenario{ Name: scenarios.AMM, Nonce: 0, @@ -137,16 +131,11 @@ func TestAMMLegsCallDifferentMethods(t *testing.T) { // // The bounds are measured, not the constant restated: an assertion against the // constant itself passes at any value. -func TestAMMGasCoversAMeasuredSwap(t *testing.T) { +func TestAMMGasComesFromTheMeasurement(t *testing.T) { gen, txs := newAttachedAMM(t, config.Scenario{}) tx, err := gen.Generate(newTestRng(1), txs) require.NoError(t, err) - - require.Greater(t, tx.Gas(), uint64(ammColdSwapGas), - "the limit is below the most a swap cost when measured, so an account's "+ - "first swap lands with a failed status and burns the whole limit") - require.Less(t, tx.Gas(), uint64(ammColdSwapGas*13/10), - "the limit is far above a measured swap, so it reserves block space nothing spends") + requireGasMatchesModel(t, tx) } // TestAMMDefaultPathDrawsNoRandomness asserts a profile with no operation mix diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index d29dcca..40bc5bb 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -1,6 +1,8 @@ package scenarios import ( + "fmt" + "math/big" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -55,12 +57,58 @@ func (s *DisperseScenario) SetContract(contract *bindings.Disperse) { s.contract = contract } +const ( + // disperseRecipients is how many accounts one disperse pays. The priced call + // and the sent call read the same constant, so they cannot drift apart. + disperseRecipients = 100 + // disperseFixedEtherAmountWei is what the contract pays each recipient, and + // what DeployContract constructs it with. disperseEtherFixed opens with + // require(msg.value == fixedEtherAmount * recipients.length), so every call + // has to carry exactly the product. + // + // A contract this run deployed holds this value by construction. One bound + // from a registry entry was deployed by something else and could hold + // another, which would revert every call; reading it back off the contract + // is the fix and it needs GasEstimateCalls to be able to report a failure. + disperseFixedEtherAmountWei = 1 +) + +// disperseValue is what one disperse must carry. +func disperseValue() *big.Int { + return big.NewInt(disperseFixedEtherAmountWei * disperseRecipients) +} + +// GasEstimateCalls prices one disperse to the same number of recipients the send +// path uses, all of them fresh, because each one the contract pays creates an +// account. +func (s *DisperseScenario) GasEstimateCalls() []GasEstimateCall { + targets := make([]common.Address, 0, disperseRecipients) + for range disperseRecipients { + targets = append(targets, gasProbeAddress()) + } + return []GasEstimateCall{ + { + Operation: config.OpDisperseEther, + Data: mustPack(bindings.DisperseMetaData, "disperseEtherFixed", targets), + Value: disperseValue(), + }, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates Disperse transaction func (s *DisperseScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { // create new accounts so that it auto-creates the accounts. - targets := make([]common.Address, 0, 100) - for range 100 { + targets := make([]common.Address, 0, disperseRecipients) + for range disperseRecipients { targets = append(targets, s.pool.NextAccount(rng).Address) } + limit, ok := s.GasLimitFor(config.OpDisperseEther) + if !ok { + return nil, fmt.Errorf("disperse: no measured gas limit") + } + auth.GasLimit = limit + // Without this the contract's own require rejects the call, so every disperse + // reverts on entry and burns whatever limit it declared. + auth.Value = disperseValue() return s.contract.DisperseEtherFixed(auth, targets) } diff --git a/generator/scenarios/ERC20.go b/generator/scenarios/ERC20.go index 2482fda..ba8f311 100644 --- a/generator/scenarios/ERC20.go +++ b/generator/scenarios/ERC20.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -54,8 +55,22 @@ func (s *ERC20Scenario) DeployContract(opts *bind.TransactOpts, client *ethclien return address, tx, err } +// GasEstimateCalls prices one transfer to a recipient that holds none of the +// token, which is what a run mostly sends: receivers are drawn from the pool and +// the sender's own balance oscillates, so the slot-from-zero write is the +// common shape rather than a warm-up. +func (s *ERC20Scenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC20Transfer, Data: mustPack(bindings.ERC20MetaData, "transfer", gasProbeAddress(), bigOne)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC20 transaction func (s *ERC20Scenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 72156 + limit, ok := s.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, fmt.Errorf("erc20: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC20Conflict.go b/generator/scenarios/ERC20Conflict.go index 99d5fea..9226e8b 100644 --- a/generator/scenarios/ERC20Conflict.go +++ b/generator/scenarios/ERC20Conflict.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -54,8 +55,22 @@ func (s *ERC20ConflictScenario) SetContract(contract *bindings.ERC20Conflict) { s.contract = contract } +// GasEstimateCalls prices one transfer to a recipient that holds none of the +// token, which is what a run mostly sends: receivers are drawn from the pool and +// the sender's own balance oscillates, so the slot-from-zero write is the +// common shape rather than a warm-up. +func (s *ERC20ConflictScenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC20Transfer, Data: mustPack(bindings.ERC20ConflictMetaData, "transfer", gasProbeAddress(), bigOne)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC20Conflict transaction func (s *ERC20ConflictScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 22460 + limit, ok := s.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, fmt.Errorf("erc20conflict: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC20Noop.go b/generator/scenarios/ERC20Noop.go index cd72612..fe5b1d7 100644 --- a/generator/scenarios/ERC20Noop.go +++ b/generator/scenarios/ERC20Noop.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -54,8 +55,22 @@ func (s *ERC20NoopScenario) SetContract(contract *bindings.ERC20Noop) { s.contract = contract } +// GasEstimateCalls prices one transfer to a recipient that holds none of the +// token, which is what a run mostly sends: receivers are drawn from the pool and +// the sender's own balance oscillates, so the slot-from-zero write is the +// common shape rather than a warm-up. +func (s *ERC20NoopScenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC20Transfer, Data: mustPack(bindings.ERC20NoopMetaData, "transfer", gasProbeAddress(), bigOne)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC20Noop transaction func (s *ERC20NoopScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 22460 + limit, ok := s.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, fmt.Errorf("erc20noop: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC721.go b/generator/scenarios/ERC721.go index 7390879..e059a59 100644 --- a/generator/scenarios/ERC721.go +++ b/generator/scenarios/ERC721.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" "math/big" mrand "math/rand/v2" "sync/atomic" @@ -47,22 +48,6 @@ func (s *ERC721Scenario) DeployContract(opts *bind.TransactOpts, client *ethclie return address, tx, err } -// erc721MintGas bounds one mint. -// -// Measured against the deployed binding as a required gas limit, which is what -// eth_estimateGas returns and what a transaction has to carry before its refund -// lands at the end of execution. A receipt's GasUsed is the post-refund charge -// and runs lower, so it is the wrong number to size from. -// -// 69,319 to a receiver holding none of the token, and 51,757 to one that already -// holds some. A run draws its receivers from the account pool, so most mints pay -// the higher shape and the limit covers it. -// -// This constant read 22460 until it was measured. At that value every mint -// landed in a block with a failed status and burned the whole limit, and a run -// with trackReceipts off reported each one as a success. -const erc721MintGas = 75_000 - // GetBindFunc implements ContractDeployer interface - returns the binding function func (s *ERC721Scenario) GetBindFunc() ContractBindFunc[bindings.ERC721] { return bindings.NewERC721 @@ -73,8 +58,32 @@ func (s *ERC721Scenario) SetContract(contract *bindings.ERC721) { s.contract = contract } +// gasProbeTokenID is the token this scenario prices a mint against. It sits far +// above anything the run's counter reaches, so the owner slot it writes is +// certainly unminted and the price is the expensive shape. +// +// Pricing at a low id would read whatever a previous run against a recorded +// contract already minted. That returns the cheap shape, and every mint past +// the previous run's high-water mark would then be short. +// Every byte is non-zero, for the reason gasProbeAddress forces its own: a +// calldata word of zeros prices cheaper than one a run actually sends, and the +// limit would come out under what that transaction needs. The maximum uint256 is +// as certainly unminted as any other id this size. +var gasProbeTokenID = new(big.Int).Sub(new(big.Int).Lsh(big.NewInt(1), 256), big.NewInt(1)) + +// GasEstimateCalls prices one mint to a receiver that holds none of the token. +func (s *ERC721Scenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC721Mint, Data: mustPack(bindings.ERC721MetaData, "mint", gasProbeAddress(), gasProbeTokenID)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC721 transaction func (s *ERC721Scenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = erc721MintGas + limit, ok := s.GasLimitFor(config.OpERC721Mint) + if !ok { + return nil, fmt.Errorf("erc721: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Mint(auth, scenario.Receiver, big.NewInt(atomic.AddInt64(&s.id, 1))) } diff --git a/generator/scenarios/ERC721_test.go b/generator/scenarios/ERC721_test.go deleted file mode 100644 index e008372..0000000 --- a/generator/scenarios/ERC721_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package scenarios_test - -import ( - mrand "math/rand/v2" - "testing" - - "github.com/stretchr/testify/require" - - "github.com/sei-protocol/sei-load/config" - "github.com/sei-protocol/sei-load/generator/scenarios" - "github.com/sei-protocol/sei-load/types" -) - -// erc721ColdMintGas is the gas limit one mint required when measured against -// the deployed binding, sending to a receiver that holds none of the token. A -// receiver that already holds some needed 51,757. A run draws its receivers -// from the account pool, so the higher shape is the common one. -// -// It is a required limit read from eth_estimateGas, not a receipt's GasUsed, -// because a transaction is provisioned for the peak before its refund lands. -// -// It is written down so the limit is checked against a measurement rather than -// against itself. An assertion comparing the limit to its own constant passes -// at any value. -const erc721ColdMintGas = 69_319 - -// TestERC721GasCoversAMeasuredMint guards the failure this constant shipped -// with: a limit under what a mint needs still reaches a block, with a failed -// status, having burned the whole limit. A run with trackReceipts off counts -// that as a success, so no other test in this package can see it. -func TestERC721GasCoversAMeasuredMint(t *testing.T) { - cfg := &config.LoadConfig{ - ChainID: 7777, - MockDeploy: true, - Endpoints: []string{"http://localhost:8545"}, - } - gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.ERC721}) - require.NoError(t, gen.Ready(cfg)) - require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) - - tx, err := gen.Generate(mrand.New(mrand.NewPCG(1, 2)), &types.TxScenario{ - Name: scenarios.ERC721, - Nonce: 0, - Sender: types.GenerateAccounts(1, true)[0], - Receiver: types.GenerateAccounts(1, false)[0].Address, - }) - require.NoError(t, err) - - require.GreaterOrEqual(t, tx.Gas(), uint64(erc721ColdMintGas), - "a mint needs %d gas and the limit is %d, so every mint lands with a failed status and burns the limit while the run reports it as sent", - erc721ColdMintGas, tx.Gas()) - require.LessOrEqual(t, tx.Gas(), uint64(erc721ColdMintGas*13/10), - "the limit is %d against a measured %d, so every mint reserves block space nothing spends", - tx.Gas(), erc721ColdMintGas) -} diff --git a/generator/scenarios/EVMTransfer.go b/generator/scenarios/EVMTransfer.go index 5700762..0426a09 100644 --- a/generator/scenarios/EVMTransfer.go +++ b/generator/scenarios/EVMTransfer.go @@ -2,6 +2,7 @@ package scenarios import ( "context" + "fmt" "math/big" mrand "math/rand/v2" "time" @@ -10,6 +11,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/utils" types2 "github.com/sei-protocol/sei-load/types" ) @@ -52,15 +54,20 @@ func (s *EVMTransferScenario) DeployScenario(ctx context.Context, config *config // CreateTransaction implements ScenarioDeployer interface - creates ETH transfer transaction func (s *EVMTransferScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { + feeCap, ok := config.GetGasFeeCap() + if !ok { + return nil, fmt.Errorf("evmtransfer: no fee cap resolved from the chain") + } + // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Nonce, To: &scenario.Receiver, Value: big.NewInt(time.Now().Unix()), - Gas: 21000, // Standard gas limit for ETH transfer - GasTipCap: big.NewInt(2000000000), // 2 gwei - GasFeeCap: big.NewInt(200000000000), // 200 gwei - Data: nil, // No data for simple transfer + Gas: 21000, // Standard gas limit for ETH transfer + GasTipCap: big.NewInt(utils.GasTipCapWei), + GasFeeCap: feeCap, + Data: nil, // No data for simple transfer } if s.scenarioConfig.GasPicker != nil { diff --git a/generator/scenarios/EVMTransferFast.go b/generator/scenarios/EVMTransferFast.go index 83aa504..a41b5b5 100644 --- a/generator/scenarios/EVMTransferFast.go +++ b/generator/scenarios/EVMTransferFast.go @@ -2,6 +2,7 @@ package scenarios import ( "context" + "fmt" "math/big" mrand "math/rand/v2" @@ -45,15 +46,22 @@ func (s *EVMTransferFastScenario) DeployScenario(ctx context.Context, config *co // CreateTransaction EVMTransferFastScenario ScenarioDeployer interface - creates ETH transfer transaction func (s *EVMTransferFastScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { + feeCap, ok := config.GetGasFeeCap() + if !ok { + return nil, fmt.Errorf("evmtransferfast: no fee cap resolved from the chain") + } + // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ - Nonce: scenario.Nonce, - To: &scenario.Receiver, - Value: big.NewInt(1_000_000_000_000), - Gas: 21000, // Standard gas limit for ETH transfer - GasTipCap: big.NewInt(0), // 2 gwei - GasFeeCap: big.NewInt(200000000000), // 200 gwei - Data: nil, // No data for simple transfer + Nonce: scenario.Nonce, + To: &scenario.Receiver, + Value: big.NewInt(1_000_000_000_000), + Gas: 21000, // Standard gas limit for ETH transfer + // A zero tip is deliberate: this scenario measures the path with no + // priority fee attached. + GasTipCap: big.NewInt(0), + GasFeeCap: feeCap, + Data: nil, // No data for simple transfer } if s.scenarioConfig.GasPicker != nil { diff --git a/generator/scenarios/EVMTransferNoop.go b/generator/scenarios/EVMTransferNoop.go index 4b5b026..98bd604 100644 --- a/generator/scenarios/EVMTransferNoop.go +++ b/generator/scenarios/EVMTransferNoop.go @@ -2,6 +2,7 @@ package scenarios import ( "context" + "fmt" "math/big" mrand "math/rand/v2" @@ -9,6 +10,7 @@ import ( ethtypes "github.com/ethereum/go-ethereum/core/types" "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/utils" types2 "github.com/sei-protocol/sei-load/types" ) @@ -44,15 +46,20 @@ func (s *EVMTransferNoopScenario) DeployScenario(ctx context.Context, config *co // CreateTransaction implements ScenarioDeployer interface - creates ETH transfer transaction func (s *EVMTransferNoopScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { + feeCap, ok := config.GetGasFeeCap() + if !ok { + return nil, fmt.Errorf("evmtransfernoop: no fee cap resolved from the chain") + } + // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Nonce, To: &scenario.Sender.Address, Value: big.NewInt(0), - Gas: 21000, // Standard gas limit for ETH transfer - GasTipCap: big.NewInt(2000000000), // 2 gwei - GasFeeCap: big.NewInt(20000000000), // 20 gwei - Data: nil, // No data for simple transfer + Gas: 21000, // Standard gas limit for ETH transfer + GasTipCap: big.NewInt(utils.GasTipCapWei), + GasFeeCap: feeCap, + Data: nil, // No data for simple transfer } if s.scenarioConfig.GasPicker != nil { diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index c6ddb06..b58cf52 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -5,6 +5,7 @@ import ( "math/big" mrand "math/rand/v2" + "github.com/ethereum/go-ethereum/accounts/abi" "github.com/ethereum/go-ethereum/accounts/abi/bind" "github.com/ethereum/go-ethereum/common" ethtypes "github.com/ethereum/go-ethereum/core/types" @@ -18,32 +19,30 @@ import ( const StorageRW = "storagerw" const ( - // storageRWBaseGas covers execution plus the fixed calldata head. Measured - // worst case is a read that first writes readAccumulator, at 46,269 including - // intrinsic; rmw and write cold-first-touch sit near 44k. 50k clears all - // three, but only by ~3.7k — and SSTORE_SET is a Sei governance parameter - // (SeiSstoreSetGasEip2200, default 20,000), so a raise past ~23.7k would put - // read out of gas. See package doc for why the limit is kept tight anyway. - storageRWBaseGas = 50000 // storageRWWriteValue is the constant value write stores. The load contract // never asserts on it. storageRWWriteValue = 1 - // abiWord is the 32-byte unit the ABI right-pads a dynamic argument up to, - // so the pad reaches the wire as a whole number of words. - abiWord = 32 - // calldataFloorGasPerByte is what a zero calldata byte costs under EIP-7623, - // which is live on Sei (PragueTime is 0). The floor is 21000 + 10 per token - // and a zero byte is one token, so charging 10 per padded pad byte on top of - // the base always clears it: the base exceeds 21000 by more than the head's - // worst-case token cost. + // storageRWReadHeadroom covers the shape no priced call can reach. // - // The pre-Prague rate of 4 would be short above roughly 4.5 KiB of pad, and - // Sei's ante checks only the intrinsic cost, not the floor — so such a tx is - // admitted, reserves its full declared limit, then fails in execution with - // GasUsed equal to the limit. It lands in a block as an included failure and - // inflates the very gas-used metric the run reports. - calldataFloorGasPerByte = 10 + // read costs most when its target slot already holds a value and the + // accumulator does not: it pays a cold read and then a write from zero. A + // probe against an untouched slot reads zero and leaves the accumulator + // unchanged, which is the cheap shape, so nothing this scenario prices pays + // the expensive one. write and rmw both carry the write from zero that + // dominates it. What they miss is the cold read, which EIP-2929 prices at + // 2,100. + // + // That cost is not one Sei moves. The fork's chain config carries a single + // Sei-specific gas field and it is the zero-to-value store cost, so this + // stays stock wherever a run points. Doubling it leaves room for the + // accounting to shift without making the limit meaningfully looser. + // + // It is a constant rather than margin because gasMargin is configurable and + // Validate accepts 1. At 1 nothing would absorb this, and the first read of + // a written slot would land in a block having burned its whole limit, which + // is the failure this scenario's sizing exists to remove. + storageRWReadHeadroom = 4_200 ) // storageRWDefaultSlot is the single slot every tx targets when no key @@ -55,12 +54,21 @@ type StorageRWScenario struct { *ContractScenarioBase[bindings.StorageRWv1] contract *bindings.StorageRWv1 operations *config.OperationPicker + // abi is parsed once, because the send path packs the calldata it is about to + // send in order to price it. Parsing per transaction would put a JSON decode + // on that path. + abi *abi.ABI } // NewStorageRWScenario creates a new StorageRW scenario func NewStorageRWScenario(cfg config.Scenario) TxGenerator { + parsed, err := bindings.StorageRWv1MetaData.GetAbi() + if err != nil { + panic(fmt.Sprintf("storagerw: parse abi: %v", err)) + } scenario := &StorageRWScenario{ operations: config.StorageRWOperations.Picker(cfg.Operations), + abi: parsed, } scenario.ContractScenarioBase = NewContractScenarioBase[bindings.StorageRWv1](scenario, cfg) return scenario @@ -93,6 +101,30 @@ func (s *StorageRWScenario) SetContract(contract *bindings.StorageRWv1) { s.contract = contract } +// gasProbeSlot is the slot this scenario prices against. It sits outside any +// keyspace a profile can configure, so the slot is untouched whatever a previous +// run wrote, and write and rmw price their slot-from-zero shape. +// +// read is the exception, and the reason the send path takes the largest of the +// three. Its expensive shape needs the target slot already written and the +// accumulator still zero, which a single call against an untouched slot cannot +// produce: reading a zero slot leaves the accumulator unchanged, which is the +// cheap shape. write and rmw both carry the slot-from-zero write that dominates +// it, so the largest of the three covers read to within one cold read of its +// own peak. storageRWReadHeadroom covers the rest. +var gasProbeSlot = new(big.Int).Lsh(big.NewInt(1), 200) + +// GasEstimateCalls prices all three operations with an empty pad. The pad is +// calldata, and the send path recomposes the measurement against whatever pad it +// drew rather than pricing each size. +func (s *StorageRWScenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpRead, Data: mustPack(bindings.StorageRWv1MetaData, "read", gasProbeSlot, []byte{})}, + {Operation: config.OpWrite, Data: mustPack(bindings.StorageRWv1MetaData, "write", gasProbeSlot, big.NewInt(storageRWWriteValue), []byte{})}, + {Operation: config.OpRmw, Data: mustPack(bindings.StorageRWv1MetaData, "rmw", gasProbeSlot, []byte{})}, + } +} + // CreateContractTransaction implements ContractDeployer interface - builds one // StorageRWv1 transaction whose slot (key contention), calldata pad (tx size), // and operation are drawn from the scenario config. With none of the three @@ -112,14 +144,35 @@ func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bin return nil, err } - // Charge the pad at the EIP-7623 floor rate over its on-wire length, which - // the ABI rounds up to a whole word. - paddedPad := (uint64(len(pad)) + abiWord - 1) / abiWord * abiWord - auth.GasLimit = storageRWBaseGas + paddedPad*calldataFloorGasPerByte - op := s.operations.Select(rng) scenario.Operation = op + // The pad is calldata, and the chain charges calldata by the byte. Packing + // the call the send path is about to make gives the measurement the exact + // bytes rather than a per-byte constant that has to guess the rate. + var ( + data []byte + err2 error + ) + switch op { + case config.OpRmw: + data, err2 = s.abi.Pack("rmw", slot, pad) + case config.OpRead: + data, err2 = s.abi.Pack("read", slot, pad) + case config.OpWrite: + data, err2 = s.abi.Pack("write", slot, big.NewInt(storageRWWriteValue), pad) + default: + return nil, fmt.Errorf("storagerw: no contract method for operation %q", op) + } + if err2 != nil { + return nil, fmt.Errorf("storagerw: pack %q: %w", op, err2) + } + limit, err := s.MaxGasLimitForData(data) + if err != nil { + return nil, fmt.Errorf("storagerw: %w", err) + } + auth.GasLimit = limit + storageRWReadHeadroom + switch op { case config.OpRmw: return s.contract.Rmw(auth, slot, pad) diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 8cdb8e0..7ccd727 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -43,6 +43,9 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { MockDeploy: true, Endpoints: []string{"http://localhost:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) @@ -50,6 +53,7 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { contractAddr := types.GenerateAccounts(1, false)[0].Address require.NoError(t, gen.Ready(cfg)) require.NoError(t, gen.Binder()(nil, contractAddr)) + priceGasCalls(t, gen) // Build the tx scenario the way the weighted generator does: a funded sender. sender := types.GenerateAccounts(1, true)[0] @@ -92,6 +96,15 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { // known address under mock deploy, mirroring generator.mockPrepareAll. It returns // the generator and a tx scenario carrying a funded sender. func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { + t.Helper() + gen, txs := newAttachedStorageRWUnpriced(t, sc) + priceGasCalls(t, gen) + return gen, txs +} + +// newAttachedStorageRWUnpriced leaves the scenario unpriced, for a test that +// needs to choose the margin its calls are priced at. +func newAttachedStorageRWUnpriced(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { t.Helper() sc.Name = scenarios.StorageRW cfg := &config.LoadConfig{ @@ -99,6 +112,9 @@ func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerat MockDeploy: true, Endpoints: []string{"http://localhost:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(sc) require.NoError(t, gen.Ready(cfg)) require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) @@ -246,7 +262,7 @@ func TestStorageRWDefaultPathUnchanged(t *testing.T) { require.Equal(t, "rmw", method) require.Zero(t, slot) require.Zero(t, padLen) - require.Equal(t, uint64(50000), tx.Gas()) + requireGasCoversModelPlusColdRead(t, tx, 1.2) requireGasCoversFloor(t, tx) } @@ -386,6 +402,9 @@ func TestDeployTimeoutIsNotAContextSentinel(t *testing.T) { // the budget expires there rather than at dial. Endpoints: []string{"http://198.51.100.1:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) _, err := gen.Deploy(t.Context(), cfg, types.GenerateAccounts(1, true)[0]) @@ -453,3 +472,19 @@ func TestStorageRWDefaultStampsItsDefaultOperation(t *testing.T) { require.Equal(t, config.OpRmw, txs.Operation) } } + +// TestStorageRWClearsReadsPeakAtTheLowestMargin guards the one shape this +// scenario cannot price, at the margin that gives it no help. +// +// read costs most against a slot that already holds a value, and every priced +// call reads an untouched one, so the largest measured model is short by a cold +// read. The scenario adds that back as a constant rather than leaning on +// gasMargin, because Validate accepts a margin of 1 and at 1 nothing absorbs it. +func TestStorageRWClearsReadsPeakAtTheLowestMargin(t *testing.T) { + gen, txs := newAttachedStorageRWUnpriced(t, config.Scenario{}) + priceGasCallsAtMargin(t, gen, 1) + + tx, err := gen.Generate(newTestRng(7), txs) + require.NoError(t, err) + requireGasCoversModelPlusColdRead(t, tx, 1) +} diff --git a/generator/scenarios/base.go b/generator/scenarios/base.go index 071ef4e..f6a0623 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -44,6 +44,11 @@ type TxGenerator interface { // scenario its contract, or nil for a scenario that drives none. The step // supplies the backend and the address, so no scenario opens a connection. Binder() ContractBinder + // GasEstimateCaller returns the hand-off a preparation step drives to price + // this scenario's calls against the chain, or nil for a scenario that drives + // no contract. A native transfer costs the protocol's own 21,000 whatever the + // chain charges for storage, so those scenarios have nothing to price. + GasEstimateCaller() GasEstimateCaller Deploy(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) } @@ -87,6 +92,19 @@ type ContractDeployer[T any] interface { // CreateContractTransaction creates a contract interaction transaction CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) + + // GasEstimateCalls returns one call per operation this scenario issues, built + // the way CreateContractTransaction builds one, so the chain prices the same + // work the run will send. + // + // Build each from constants and never from a draw: pricing runs before the + // dispatcher, and a draw here would come from the run's single PRNG and shift + // every later one at the same seed. + // + // ContractScenarioBase does not implement this. Every contract scenario + // declares its own, so adding one without saying what to price does not + // compile, and no transaction is sent under a limit nobody measured. + GasEstimateCalls() []GasEstimateCall } // ScenarioBase holds no contract address. CDR-021 keeps an address out of a @@ -143,6 +161,13 @@ func (s *ScenarioBase) Generate(rng *mrand.Rand, scenario *types.TxScenario) (*e return s.deployer.CreateTransaction(rng, s.config, scenario) } +// GasEstimateCaller reports that this scenario prices nothing. A scenario +// without a contract sends a native transfer, whose 21,000 is a protocol +// constant rather than a chain parameter. +func (s *ScenarioBase) GasEstimateCaller() GasEstimateCaller { + return nil +} + // GetConfig returns the configuration func (s *ScenarioBase) GetConfig() *config.LoadConfig { return s.config @@ -152,6 +177,22 @@ func (s *ScenarioBase) GetConfig() *config.LoadConfig { type ContractScenarioBase[T any] struct { *ScenarioBase deployer ContractDeployer[T] + + // gasModels holds what the chain quoted for each operation, for a scenario + // whose calldata varies per transaction and has to recompose it. + // + // The preparation step writes it once, before the dispatcher goroutine + // exists, and the send path only reads it. That is the same lifecycle + // ScenarioBase.config has, so it needs no lock: starting the goroutine is + // the happens-before edge. + gasModels map[string]GasModel + // gasLimits holds the limit already resolved against each priced call's own + // calldata, so the send path reads a number rather than deriving one. + // + // Deriving it per transaction would mean rebuilding the priced call, and + // building one mints a fresh address. That is a keypair generated per + // transaction, on the path this whole change exists to keep free of work. + gasLimits map[string]uint64 } // NewContractScenarioBase creates a new base scenario with the given contract deployer @@ -161,6 +202,69 @@ func NewContractScenarioBase[T any](deployer ContractDeployer[T], cfg config.Sce return base } +// GasEstimateCaller prices every call this scenario declares and stores the +// result. A scenario that declares none fails here rather than sending +// transactions under a limit nobody measured. +func (c *ContractScenarioBase[T]) GasEstimateCaller() GasEstimateCaller { + return func(ctx context.Context, estimate GasEstimator) error { + calls := c.deployer.GasEstimateCalls() + if len(calls) == 0 { + return fmt.Errorf("declares no gas estimate calls") + } + models := make(map[string]GasModel, len(calls)) + limits := make(map[string]uint64, len(calls)) + for _, call := range calls { + model, err := estimate(ctx, call) + if err != nil { + return fmt.Errorf("operation %q: %w", call.Operation, err) + } + limit, err := model.Limit(call.Data) + if err != nil { + return fmt.Errorf("operation %q: %w", call.Operation, err) + } + models[call.Operation] = model + limits[call.Operation] = limit + } + c.gasModels, c.gasLimits = models, limits + return nil + } +} + +// GasLimitFor returns the limit measured for one operation, and whether one was +// measured. A scenario whose calldata is the same every time reads this. +// +// It reports absence rather than returning zero, because bind reads a zero +// GasLimit as "estimate this one", which would put an eth_estimateGas on the +// send path against a backend that may be nil. +func (c *ContractScenarioBase[T]) GasLimitFor(operation string) (uint64, bool) { + limit, ok := c.gasLimits[operation] + return limit, ok +} + +// MaxGasLimitForData returns the limit for data under the most expensive +// operation this scenario priced. +// +// It is for a scenario whose cheapest priced call does not bound its own worst +// transaction. StorageRW is the case: a read against an untouched slot leaves +// its accumulator unchanged, which is cheaper than the write it cannot price +// directly, and the write and rmw calls both carry the slot-from-zero cost that +// dominates it. +func (c *ContractScenarioBase[T]) MaxGasLimitForData(data []byte) (uint64, error) { + if len(c.gasModels) == 0 { + return 0, fmt.Errorf("no measured gas limits") + } + // Widest by comparison, but presence decided above: a model whose execution + // term came back as zero is still a measurement, and keying the found flag on + // the comparison would have reported it missing. + var widest GasModel + for _, model := range c.gasModels { + if model.Exec > widest.Exec { + widest = model + } + } + return widest.Limit(data) +} + func dial(config *config.LoadConfig) (*ethclient.Client, error) { if len(config.Endpoints) == 0 { return ethclient.NewClient(nil), nil @@ -209,7 +313,11 @@ func (c *ContractScenarioBase[T]) deployWithin(ctx context.Context, config *conf return common.Address{}, fmt.Errorf("dial: %w", err) } - auth, err := utils.CreateDeploymentOpts(ctx, config.GetChainID(), deployer) + feeCap, ok := config.GetGasFeeCap() + if !ok { + return common.Address{}, fmt.Errorf("no fee cap resolved from the chain") + } + auth, err := utils.CreateDeploymentOpts(ctx, config.GetChainID(), feeCap, deployer) if err != nil { return common.Address{}, fmt.Errorf("deployment options for %s: %w", deployer.Address.Hex(), err) } @@ -315,6 +423,10 @@ func fetchTransactionErrorByHash(ctx context.Context, client *ethclient.Client, // CreateTransaction implements ScenarioDeployer interface for contract scenarios func (c *ContractScenarioBase[T]) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth := utils.CreateTransactionOpts(config.GetChainID(), scenario) + feeCap, ok := config.GetGasFeeCap() + if !ok { + return nil, fmt.Errorf("no fee cap resolved from the chain") + } + auth := utils.CreateTransactionOpts(config.GetChainID(), feeCap, scenario) return c.deployer.CreateContractTransaction(rng, auth, scenario) } diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index e4e4771..e41e2fb 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -95,27 +95,42 @@ // empty and there is no warm-up phase — the same threshold, showing up in gas // rather than in contention. // -// Gas sizing. All three operations share one base GasLimit of 50k. The measured -// worst case is a read that first writes readAccumulator, at 46,269 including -// intrinsic cost; rmw and write cold-first-touch sit near 44k. So 50k clears all -// three, but by only ~3.7k — and SSTORE_SET is a Sei governance parameter -// (SeiSstoreSetGasEip2200, default 20,000), so a raise past roughly 23.7k would -// put read out of gas. Widening the keyspace also makes cold first touches the -// normal case rather than the exception, which is the regime this headroom has to -// survive. -// -// One limit for all three trades slack on the cheaper operations for a single -// number to reason about. Density is why the number is tight at all: it packs -// roughly 4x denser than the 200k default in CreateTransactionOpts, and on a -// gas-limit-admission chain a block admits transactions up to their declared -// limit regardless of gas actually used, so an oversized limit reserves block -// space the transaction never spends and throttles achievable throughput. -// -// The drawn pad is charged at 10 gas per on-wire byte on top of the base. That is -// the EIP-7623 floor rate, which is live on Sei, and it is the binding cost above -// roughly 4.6 KiB of pad. Sei's ante checks only the intrinsic cost, so a limit -// sized to the older 4-gas rate is admitted, reserves its full limit, then fails -// in execution with GasUsed equal to the limit — an included failure that -// inflates the gas-used metric the run reports. An empty pad leaves the limit at -// exactly 50k. +// Gas sizing. A scenario does not declare a gas limit. It declares the calls it +// issues, and the run asks the chain what each costs before it sends any of +// them. GasEstimateCalls is where a scenario says what to price, and adding a +// scenario without one does not compile. +// +// A constant cannot be right on more than one chain. SSTORE_SET is a Sei +// governance parameter: the EVM default is 20,000 and Sei's live networks charge +// 72,000, so a limit calibrated against one is short by a factor on the other. A +// limit that is short does not fail visibly. The transaction reaches a block, +// burns the whole limit, and a run without receipt tracking reports it as sent. +// Every constant this package used to carry was wrong on Sei, one of them by +// eight gas and one of them by a factor of two. +// +// The priced call is the expensive shape. Cost is bimodal per account: the first +// transaction from an address writes slots that hold zero, and a write from zero +// costs several times one that changes a value already there. Pricing from a +// freshly generated address makes every such slot cold by construction, so the +// measurement bounds what a run will send rather than describing its cheap case. +// The call carries no fee cap, because a call that carries one makes the node +// check the caller's balance, and this caller has none. +// +// Calldata is recomposed, not measured. GasModel keeps what the chain quoted for +// execution separately from what it charged for the priced call's own bytes, so +// a scenario whose calldata varies reuses one measurement across every size it +// draws. StorageRW is that scenario. The recomposition runs the same two +// computations the chain runs, so it is exact rather than fitted, and it covers +// the EIP-7623 floor, which is live on Sei and which Sei's ante does not check. +// +// StorageRW takes the largest of its three priced calls. read's expensive shape +// needs its target slot already written and its accumulator still zero, which a +// single call against an untouched slot cannot produce. write and rmw both carry +// the slot-from-zero cost that dominates it, so the largest covers read to within +// one cold read of its own peak. +// +// Margin is small on purpose. Sei fills a block against two budgets, one charged +// at the declared limit and one at what the transaction spends, and the declared +// one binds only past four times the spend. Below that, margin costs no block +// space, only the balance each in-flight transaction locks. package scenarios diff --git a/generator/scenarios/feecap_test.go b/generator/scenarios/feecap_test.go new file mode 100644 index 0000000..b6ff88c --- /dev/null +++ b/generator/scenarios/feecap_test.go @@ -0,0 +1,74 @@ +package scenarios_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// TestEveryRawScenarioTakesTheResolvedFeeCap covers the scenarios that build a +// DynamicFeeTx by hand rather than through a bound contract. +// +// A scenario that writes its own cap as a literal is admitted wherever that +// literal happens to clear the base fee and rejected at the ante everywhere +// else, which reads as a silent throughput loss rather than an error. Two of +// these three carried one: 20 gwei, under the 50 gwei that pacific-1 and +// atlantic-2 charge. +// +// The chosen cap is deliberately far from any plausible literal, so a scenario +// that ignores the resolved value fails here rather than passing by coincidence. +func TestEveryRawScenarioTakesTheResolvedFeeCap(t *testing.T) { + resolved := big.NewInt(137_000_000_001) + + for name, build := range map[string]func(config.Scenario) scenarios.TxGenerator{ + scenarios.EVMTransfer: scenarios.NewEVMTransferScenario, + scenarios.EVMTransferNoop: scenarios.NewEVMTransferNoopScenario, + scenarios.EVMTransferFast: scenarios.NewEVMTransferFastScenario, + } { + t.Run(name, func(t *testing.T) { + cfg := &config.LoadConfig{ChainID: 7777} + cfg.SetGasFeeCap(resolved) + + scenario := build(config.Scenario{Name: name, Weight: 1}) + require.NoError(t, scenario.Ready(cfg)) + + tx, err := scenario.Generate(newTestRng(1), + &types.TxScenario{Name: name, Operation: scenario.Operation(), Sender: types.NewAccount(false)}) + require.NoError(t, err) + require.Zero(t, tx.GasFeeCap().Cmp(resolved), + "%s writes its own fee cap instead of the one the run resolved from the chain: "+ + "it would be rejected at the ante on any chain whose base fee passes that literal", name) + }) + } +} + +// TestARawScenarioWithNoResolvedCapFails guards the other half. Defaulting to a +// literal when nothing resolved is how the hard-coded caps survived: the run +// starts, sends, and reports a rejection rate rather than a startup error. +// +// The contract path has its own cover in TestNoResolvedFeeCapRefusesToGenerate. +// The two look alike and are not: that one drives a scenario through a bound +// contract, this one drives the three that build a DynamicFeeTx by hand. +func TestARawScenarioWithNoResolvedCapFails(t *testing.T) { + for name, build := range map[string]func(config.Scenario) scenarios.TxGenerator{ + scenarios.EVMTransfer: scenarios.NewEVMTransferScenario, + scenarios.EVMTransferNoop: scenarios.NewEVMTransferNoopScenario, + scenarios.EVMTransferFast: scenarios.NewEVMTransferFastScenario, + } { + t.Run(name, func(t *testing.T) { + scenario := build(config.Scenario{Name: name, Weight: 1}) + if err := scenario.Ready(&config.LoadConfig{ChainID: 7777}); err != nil { + return // Refusing at Ready is the better failure, and is also correct. + } + + _, err := scenario.Generate(newTestRng(1), + &types.TxScenario{Name: name, Operation: scenario.Operation(), Sender: types.NewAccount(false)}) + require.Error(t, err, "%s built a transaction with no cap resolved from the chain", name) + }) + } +} diff --git a/generator/scenarios/gasestimate.go b/generator/scenarios/gasestimate.go new file mode 100644 index 0000000..0170010 --- /dev/null +++ b/generator/scenarios/gasestimate.go @@ -0,0 +1,144 @@ +package scenarios + +import ( + "context" + "crypto/rand" + "fmt" + "math/big" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" +) + +// GasEstimateCall is one call a scenario asks the chain to price before the run +// sends any of them. It is calldata, not a transaction: nothing is signed and +// nothing is sent, so pricing consumes no nonce. +// +// Data must be built from constants. A draw here would come from the run's +// single PRNG and shift every later draw, so the same seed and config would stop +// replaying. +type GasEstimateCall struct { + // Operation names the call, from the frozen vocabulary in config. It is the + // key CreateContractTransaction reads the measured limit back under. + Operation string + // Data is the calldata eth_estimateGas prices. + Data []byte + // Value is what the call must carry, for a method that checks msg.value + // before it does anything. Nil means none. + // + // A method that rejects the wrong value rejects a priced call carrying none, + // so without this the estimate reports a revert and the run refuses to start. + Value *big.Int +} + +// GasEstimator prices one call against the chain and returns the model to hold +// for the run. The preparation step supplies it, so a scenario neither dials nor +// decides how much headroom to carry. +type GasEstimator func(ctx context.Context, call GasEstimateCall) (GasModel, error) + +// GasEstimateCaller is the hand-off a preparation step drives to price a +// scenario's calls and store the results, or nil for a scenario that drives no +// contract. +type GasEstimateCaller func(ctx context.Context, estimate GasEstimator) error + +// GasModel is what one call cost, with the calldata part taken back out. +// +// Splitting it that way is what lets a scenario whose calldata varies reuse one +// measurement. The chain charges the execution and the calldata separately, so +// recomposing them against the bytes a transaction actually carries reproduces +// what that transaction needs, rather than approximating it with a per-byte +// constant. +// +// Reusing one Exec across calldata sizes holds only while the varying bytes are +// ones the contract never reads. StorageRWv1 takes its pad as bytes calldata and +// no function body touches it, so nothing copies it into memory and execution is +// genuinely independent of its length. A method taking bytes memory would have +// the decoder copy the argument, and the run would pay memory expansion that +// grows with the square of the length — none of it in an Exec measured at an +// empty pad, and short by most for the largest draws. +// +// Exec also absorbs the EIP-7623 floor whenever the chain's quote is +// floor-dominated, because Limit adds the floor back. That overstates execution, +// which is the safe direction, and it is why the max below is not redundant. +type GasModel struct { + // Exec is what the chain quoted for the priced call, less the intrinsic cost + // of that call's own calldata. + Exec uint64 + // Margin multiplies the execution term. It does not touch the calldata floor, + // which is a closed form over the exact bytes on the wire. + Margin float64 +} + +// Limit returns the gas limit a transaction carrying data needs under this +// model. It runs the same two computations the chain runs, so it is exact rather +// than fitted. +func (m GasModel) Limit(data []byte) (uint64, error) { + intrinsic, err := core.IntrinsicGas(data, nil, nil, false, true, true, true) + if err != nil { + return 0, fmt.Errorf("intrinsic gas: %w", err) + } + floor, err := core.FloorDataGas(data) + if err != nil { + return 0, fmt.Errorf("calldata floor gas: %w", err) + } + // The margin scales execution alone. The calldata terms are closed forms over + // the exact bytes on the wire, so there is nothing about them to be uncertain + // of, and scaling them declares gas no byte can consume — at a 32 KiB pad + // that was thirty thousand of it. + return max(intrinsic+uint64(float64(m.Exec)*m.Margin), floor), nil +} + +// gasProbeAddress returns an address this run mints and never uses again. +// +// It is what makes a priced call the expensive shape. Every mapping slot a +// contract derives from an address it has never seen still holds zero, and a +// write that takes a slot from zero to a value is the costly one: Sei charges +// 72,000 for it against the EVM default of 20,000. Pricing against an address +// the run has already used would return the cheap shape and under-provision +// every account's first transaction. +// +// Every byte is forced non-zero, which makes the priced call the more expensive +// one on calldata too. A transaction pays 16 gas for a non-zero calldata byte +// and 4 for a zero one, so a probe address carrying zero bytes prices a cheaper +// word than the address a run actually sends, and the limit comes out under what +// that transaction needs. About one address in thirteen carries a zero byte, so +// left random it is a per-run coin flip rather than a per-transaction one: on the +// runs where it lands, nearly every transaction is short. +// +// Forcing the bytes costs nothing that matters. The address is still one this +// run mints and never uses again, which is what makes every slot it touches +// cold. +// +// It draws from crypto/rand, like the account pool itself, so it consumes +// nothing from the run's PRNG. It does not mint a key: the address is only ever +// an ABI argument here, never a signer and never the estimate's From, so a +// secp256k1 derivation would buy nothing. Disperse asks for a hundred of these +// in one call. +func gasProbeAddress() common.Address { + var addr common.Address + if _, err := rand.Read(addr[:]); err != nil { + panic(fmt.Sprintf("gas estimate call: read random bytes: %v", err)) + } + for i, b := range addr { + if b == 0 { + addr[i] = 0xff + } + } + return addr +} + +// mustPack builds calldata for one method. A failure is a mismatch between the +// binding and the arguments written beside it, which is a programmer error the +// compiler cannot see and no run can recover from. +func mustPack(meta *bind.MetaData, method string, args ...any) []byte { + parsed, err := meta.GetAbi() + if err != nil { + panic(fmt.Sprintf("gas estimate call: parse abi: %v", err)) + } + data, err := parsed.Pack(method, args...) + if err != nil { + panic(fmt.Sprintf("gas estimate call: pack %s: %v", method, err)) + } + return data +} diff --git a/generator/scenarios/gasestimate_internal_test.go b/generator/scenarios/gasestimate_internal_test.go new file mode 100644 index 0000000..d99f176 --- /dev/null +++ b/generator/scenarios/gasestimate_internal_test.go @@ -0,0 +1,341 @@ +package scenarios + +import ( + "bytes" + "context" + "fmt" + "math/big" + mrand "math/rand/v2" + "testing" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/types" +) + +// TestEveryDrawableOperationIsPriced closes the seam between the operations a +// scenario can draw and the calls it asks the chain to price. +// +// An operation with no priced call fails at the point of sending, once per +// transaction, after the run has started and reported itself ready. Failing here +// instead makes it a build-time fact. +func TestEveryDrawableOperationIsPriced(t *testing.T) { + for name, factory := range scenarioFactories { + t.Run(name, func(t *testing.T) { + deployer, ok := factory(config.Scenario{Name: name}).(interface { + GasEstimateCalls() []GasEstimateCall + }) + if !ok { + // A scenario without a contract sends a native transfer, whose cost + // is a protocol constant rather than a chain parameter. + return + } + + priced := map[string]bool{} + for _, call := range deployer.GasEstimateCalls() { + require.NotEmpty(t, call.Data, + "operation %q is priced against empty calldata, so the chain would quote a plain transfer", call.Operation) + priced[call.Operation] = true + } + + drawable := config.OperationNamesFor(name) + if len(drawable) == 0 { + // A scenario that draws no basket issues one shape, under its default. + drawable = []string{factory(config.Scenario{Name: name}).Operation()} + } + for _, op := range drawable { + require.True(t, priced[op], + "scenario %q can draw %q but never asks the chain to price it, so every transaction of that shape is sent under a limit measured for a different call", + name, op) + } + }) + } +} + +// TestPricedCallsCarryDistinctCalldata guards the copy-paste failure: two +// operations priced against the same calldata means one of them is measuring the +// other's cost. +func TestPricedCallsCarryDistinctCalldata(t *testing.T) { + for name, factory := range scenarioFactories { + t.Run(name, func(t *testing.T) { + deployer, ok := factory(config.Scenario{Name: name}).(interface { + GasEstimateCalls() []GasEstimateCall + }) + if !ok { + return + } + seen := map[string]string{} + for _, call := range deployer.GasEstimateCalls() { + selector := string(call.Data[:4]) + if prior, clash := seen[selector]; clash { + require.Failf(t, "two operations priced against one method", + "%q and %q both price the same method, so one is measured as the other", prior, call.Operation) + } + seen[selector] = call.Operation + } + }) + } +} + +// emptyDeployer declares no calls to price. It stands in for a scenario added +// later whose GasEstimateCalls returns nothing, which the registered-scenario +// tests above cannot reach. +type emptyDeployer struct { + *ContractScenarioBase[struct{}] +} + +func (d *emptyDeployer) GasEstimateCalls() []GasEstimateCall { return nil } +func (d *emptyDeployer) DeployContract(*bind.TransactOpts, *ethclient.Client) (common.Address, *ethtypes.Transaction, error) { + return common.Address{}, nil, nil +} +func (d *emptyDeployer) GetBindFunc() ContractBindFunc[struct{}] { return nil } +func (d *emptyDeployer) SetContract(*struct{}) {} +func (d *emptyDeployer) CreateContractTransaction(*mrand.Rand, *bind.TransactOpts, *types.TxScenario) (*ethtypes.Transaction, error) { + return nil, nil +} + +// TestAScenarioThatPricesNothingRefusesToStart covers the fail-closed path +// directly. Letting it through would leave the send path with no limit for any +// operation, and bind reads an unset limit as "estimate this one", which puts an +// eth_estimateGas on every send. +func TestAScenarioThatPricesNothingRefusesToStart(t *testing.T) { + scenario := &emptyDeployer{} + scenario.ContractScenarioBase = NewContractScenarioBase[struct{}](scenario, config.Scenario{Name: "empty"}) + + err := scenario.GasEstimateCaller()(t.Context(), + func(context.Context, GasEstimateCall) (GasModel, error) { + t.Fatal("the estimator ran for a scenario that declared no calls") + return GasModel{}, nil + }) + require.Error(t, err, + "a scenario that prices nothing was allowed to start, so every transaction it sends carries no measured limit") +} + +// TestTheModelRoundTripsWhatTheChainQuoted pins the exactness the decomposition +// claims. Taking the calldata cost out of a quote and putting it back must +// return the quote, or every limit the run derives is off by whatever the two +// computations disagree about. +func TestTheModelRoundTripsWhatTheChainQuoted(t *testing.T) { + for _, data := range [][]byte{ + {0x38, 0x72, 0x0f, 0x72}, + append([]byte{0xa9, 0x05, 0x9c, 0xbb}, make([]byte, 64)...), + append([]byte{0x01, 0x02, 0x03, 0x04}, bytes.Repeat([]byte{0xff}, 512)...), + } { + intrinsic, err := core.IntrinsicGas(data, nil, nil, false, true, true, true) + require.NoError(t, err) + + // Execution terms spanning what the chain charges for one storage write, + // on the default schedule and on Sei's. + for _, exec := range []uint64{1_000, 22_100, 74_100, 160_000} { + quoted := intrinsic + exec + floor, err := core.FloorDataGas(data) + require.NoError(t, err) + + limit, err := GasModel{Exec: exec, Margin: 1}.Limit(data) + require.NoError(t, err) + // The chain floors its own quote, so a real quote is never under it. + // This asserts the model reaches the same place from either side. + require.Equal(t, max(quoted, floor), limit, + "the model did not return the quote it was built from, so every derived limit carries that error") + } + } +} + +// TestTheModelNeverDeclaresLessThanTheCalldataFloor guards the shape Sei's ante +// does not check. A limit under the EIP-7623 floor is admitted, reserves its +// whole declared limit, then fails in execution with the limit burned. +func TestTheModelNeverDeclaresLessThanTheCalldataFloor(t *testing.T) { + // A large zero pad is where the floor overtakes execution. + data := append([]byte{0x01, 0x02, 0x03, 0x04}, make([]byte, 32*1024)...) + floor, err := core.FloorDataGas(data) + require.NoError(t, err) + + limit, err := GasModel{Exec: 1, Margin: 1}.Limit(data) + require.NoError(t, err) + require.GreaterOrEqual(t, limit, floor, + "the limit is under the calldata floor, so the chain admits the transaction and then burns the whole limit in execution") +} + +// TestEveryTransactionCarriesEnoughGasForItsOwnCalldata closes the seam between +// what a scenario prices and what it sends. +// +// The assertion is on the limit rather than on the calldata cost behind it, +// because two different failures land here and only one is about calldata. A +// probe that prices a cheaper call than the run makes produces a short limit; so +// does a scenario that prices correctly and then never reads the measurement +// back, which is what Disperse did and what took a reviewer to find rather than +// this suite. +// +// It also holds for a scenario whose calldata varies, which the calldata-cost +// form would not: StorageRW recomposes against the bytes it is about to send, so +// its probe has no obligation to bound them and would fail an assertion that +// said it must. +// +// The mechanism that makes it hold for the rest is that a probe's calldata is +// maximal by construction. A transaction pays 16 gas for a non-zero calldata +// byte and 4 for a zero one, and the EIP-7623 floor is a fixed 2.5x of that +// variable part at any composition, so a probe with no zero bytes bounds both +// terms of Limit for every call of the same shape. +func TestEveryTransactionCarriesEnoughGasForItsOwnCalldata(t *testing.T) { + const probeExec = 200_000 + const probeMargin = 1.0 + + for name, factory := range scenarioFactories { + t.Run(name, func(t *testing.T) { + gen := factory(config.Scenario{Name: name}) + if _, ok := gen.(interface { + GasEstimateCalls() []GasEstimateCall + }); !ok { + return + } + + cfg := &config.LoadConfig{ChainID: 7777, MockDeploy: true, Endpoints: []string{"http://localhost:8545"}} + // The preparation step resolves this from the chain; a test that + // builds a config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.NewAccount(false).Address)) + require.NoError(t, gen.GasEstimateCaller()(t.Context(), + func(context.Context, GasEstimateCall) (GasModel, error) { + return GasModel{Exec: probeExec, Margin: probeMargin}, nil + })) + + // Enough draws to pass 255, because ERC721 numbers its tokens from 1 + // and its probe only binds once an id needs a second non-zero byte. + // Also enough that drawn receivers vary in how many zero bytes they + // carry, which is what binds for the token scenarios. + rng := mrand.New(mrand.NewPCG(11, 22)) + for i := range 400 { + tx, err := gen.Generate(rng, &types.TxScenario{ + Name: name, + Nonce: uint64(i), + Sender: types.NewAccount(true), + Receiver: types.NewAccount(false).Address, + }) + require.NoError(t, err) + + want, err := GasModel{Exec: probeExec, Margin: probeMargin}.Limit(tx.Data()) + require.NoError(t, err) + require.GreaterOrEqual(t, tx.Gas(), want, + "this transaction declares %d gas and its own calldata and execution "+ + "need %d, so it lands in a block having burned the whole limit", + tx.Gas(), want) + } + }) + } +} + +// TestTheMarginScalesExecutionAlone pins where the margin applies. +// +// Both GasModel.Margin and Settings.GasMargin say the margin is on execution and +// not on calldata, because the calldata terms are closed forms over the exact +// bytes on the wire and there is nothing about them to be uncertain of. Scaling +// them declares gas no byte can consume, and it lands hardest on the largest +// transactions a size distribution produces. +func TestTheMarginScalesExecutionAlone(t *testing.T) { + // A pad large enough that the calldata term is most of the limit, and small + // enough that the EIP-7623 floor has not overtaken it. Past the crossover the + // floor is the answer and this assertion would be about the wrong thing. + data := append([]byte{0x01, 0x02, 0x03, 0x04}, make([]byte, 4*1024)...) + intrinsic, err := core.IntrinsicGas(data, nil, nil, false, true, true, true) + require.NoError(t, err) + floor, err := core.FloorDataGas(data) + require.NoError(t, err) + + const exec = 75_000 + const margin = 1.2 + + want := intrinsic + uint64(float64(exec)*margin) + require.Greater(t, want, floor, "fixture is past the floor crossover, so it tests the floor rather than the margin") + + limit, err := GasModel{Exec: exec, Margin: margin}.Limit(data) + require.NoError(t, err) + require.Equal(t, want, limit, + "the margin scaled the calldata intrinsic as well as execution, which "+ + "declares %d gas no byte of this transaction can consume", + int64(limit)-int64(want)) +} + +// TestNoResolvedFeeCapRefusesToGenerate covers the other half of the fail-closed +// posture. A run that never asked the chain what gas costs has no cap to +// declare, and the constant it used to fall back to was rejected outright on two +// of Sei's three live networks. +func TestNoResolvedFeeCapRefusesToGenerate(t *testing.T) { + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + // Deliberately no SetGasFeeCap. + gen := CreateScenario(config.Scenario{Name: AMM}) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.NewAccount(false).Address)) + + _, err := gen.Generate(mrand.New(mrand.NewPCG(1, 2)), &types.TxScenario{ + Name: AMM, + Sender: types.NewAccount(true), + }) + require.ErrorContains(t, err, "fee cap", + "a scenario generated a transaction with no cap resolved from the chain, so it would be priced by a constant again") +} + +// countingDeployer records how often its priced calls are built. +type countingDeployer struct { + *ContractScenarioBase[struct{}] + built int +} + +func (d *countingDeployer) GasEstimateCalls() []GasEstimateCall { + d.built++ + return []GasEstimateCall{{Operation: config.OpERC20Transfer, Data: []byte{0xa9, 0x05, 0x9c, 0xbb}}} +} + +func (d *countingDeployer) DeployContract(*bind.TransactOpts, *ethclient.Client) (common.Address, *ethtypes.Transaction, error) { + return common.Address{}, nil, nil +} +func (d *countingDeployer) GetBindFunc() ContractBindFunc[struct{}] { return nil } +func (d *countingDeployer) SetContract(*struct{}) {} +func (d *countingDeployer) CreateContractTransaction(_ *mrand.Rand, auth *bind.TransactOpts, _ *types.TxScenario) (*ethtypes.Transaction, error) { + limit, ok := d.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, errNoLimit + } + auth.GasLimit = limit + return nil, nil +} + +var errNoLimit = fmt.Errorf("no measured gas limit") + +// TestTheSendPathNeverRebuildsAPricedCall pins where the priced call is built. +// +// Building one mints a fresh address, which is a secp256k1 keypair. Deriving the +// limit from the call rather than storing it puts that keygen on the send path +// of a load generator, once per transaction, which is the work this whole change +// exists to keep off it. +func TestTheSendPathNeverRebuildsAPricedCall(t *testing.T) { + scenario := &countingDeployer{} + scenario.ContractScenarioBase = NewContractScenarioBase[struct{}](scenario, config.Scenario{Name: "counting"}) + + require.NoError(t, scenario.GasEstimateCaller()(t.Context(), + func(context.Context, GasEstimateCall) (GasModel, error) { + return GasModel{Exec: 100_000, Margin: 1.2}, nil + })) + afterPricing := scenario.built + + for range 50 { + auth := &bind.TransactOpts{} + _, err := scenario.CreateContractTransaction(nil, auth, nil) + require.NoError(t, err) + require.NotZero(t, auth.GasLimit) + } + + require.Equal(t, afterPricing, scenario.built, + "the send path rebuilt the priced call %d times over 50 transactions, so it mints a keypair per transaction", + scenario.built-afterPricing) +} diff --git a/generator/scenarios/gasestimate_test_helper_test.go b/generator/scenarios/gasestimate_test_helper_test.go new file mode 100644 index 0000000..def9d52 --- /dev/null +++ b/generator/scenarios/gasestimate_test_helper_test.go @@ -0,0 +1,83 @@ +package scenarios_test + +import ( + "context" + "testing" + + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/generator/scenarios" +) + +// priceGasCalls drives a scenario's pricing hand-off with a fixed quote, the way +// the preparation step drives it against a chain. A scenario refuses to generate +// until its calls are priced, so a test that skips this fails the same way a run +// against an unreachable endpoint does. +func priceGasCalls(t *testing.T, gen scenarios.TxGenerator) { + t.Helper() + price := gen.GasEstimateCaller() + if price == nil { + return + } + require.NoError(t, price(context.Background(), + func(context.Context, scenarios.GasEstimateCall) (scenarios.GasModel, error) { + return scenarios.GasModel{Exec: testGasExec, Margin: 1.2}, nil + })) +} + +// testGasExec stands in for what a chain would quote, less the call's own +// calldata. It is large enough that a limit derived from it clears every +// scenario's real cost, so a test asserting on a limit is asserting on the +// arithmetic rather than on a chain. +const testGasExec = 200_000 + +// requireGasMatchesModel asserts the limit a transaction carries is the one the +// model derives from the bytes that transaction actually sends. +// +// This is the invariant the old per-scenario constants were standing in for. A +// scenario that adds an operation, or changes its calldata, and forgets to price +// the new shape fails here rather than on chain. +func requireGasMatchesModel(t *testing.T, tx *ethtypes.Transaction) { + t.Helper() + want, err := scenarios.GasModel{Exec: testGasExec, Margin: 1.2}.Limit(tx.Data()) + require.NoError(t, err) + require.Equal(t, want, tx.Gas(), + "the limit does not match what the model derives from this transaction's own calldata, so the send path and the priced call have drifted apart") +} + +// priceGasCallsAtMargin drives the pricing hand-off with a chosen margin, so a +// test can exercise the lowest one Settings.Validate accepts. +func priceGasCallsAtMargin(t *testing.T, gen scenarios.TxGenerator, margin float64) { + t.Helper() + price := gen.GasEstimateCaller() + if price == nil { + return + } + require.NoError(t, price(context.Background(), + func(context.Context, scenarios.GasEstimateCall) (scenarios.GasModel, error) { + return scenarios.GasModel{Exec: testGasExec, Margin: margin}, nil + })) +} + +// requireGasCoversModelPlusColdRead is requireGasMatchesModel for a scenario that +// adds fixed headroom on top of its measured model. +// +// StorageRW is the case: no call it prices reaches read's expensive shape, so it +// carries the difference as a constant. The limit must therefore exceed the +// model by at least a cold slot read, and not by so much that it stops +// describing the work. +func requireGasCoversModelPlusColdRead(t *testing.T, tx *ethtypes.Transaction, margin float64) { + t.Helper() + // EIP-2929's cold slot read, the gap between the priced shape and read's peak. + const coldSload = 2100 + + priced, err := scenarios.GasModel{Exec: testGasExec, Margin: margin}.Limit(tx.Data()) + require.NoError(t, err) + require.GreaterOrEqual(t, tx.Gas(), priced+coldSload, + "the limit does not clear read's expensive shape, so the first read of a "+ + "written slot burns its whole limit and reports as sent") + require.LessOrEqual(t, tx.Gas(), priced+4*coldSload, + "the limit is far past what any priced call needs, so it reserves block "+ + "space nothing spends") +} diff --git a/generator/utils/utils.go b/generator/utils/utils.go index ac0e811..9ff8e9d 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -30,40 +30,38 @@ const ( // 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 + // GasTipCapWei is the priority fee (2 gwei). It is a tip rather than a + // ceiling, so unlike the fee cap it does not have to track what the chain + // charges; a transaction is admitted on its cap. Exported so a scenario that + // builds its own transaction pays the same tip as one built here. + GasTipCapWei = 2_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) { +func CreateDeploymentOpts(ctx context.Context, chainID *big.Int, feeCap *big.Int, account loadtypes.Account) (*bind.TransactOpts, error) { auth, err := bind.NewKeyedTransactorWithChainID(account.PrivKey, chainID) if err != nil { return nil, err } auth.Context = ctx auth.GasLimit = deployGasLimit - auth.GasTipCap = big.NewInt(gasTipCapWei) + 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) + // funding is configured that stream belongs to the root key. A cap the base + // fee has passed puts the stream's weakest-priced transaction at its head and + // blocks every later root transaction until someone replaces the nonce by + // hand. Every path takes the one cap the run resolved from the chain, so no + // two of them can drift apart. + auth.GasFeeCap = new(big.Int).Set(feeCap) return auth, nil } // 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 { +func CreateTransactionOpts(chainID *big.Int, feeCap *big.Int, scenario *loadtypes.TxScenario) *bind.TransactOpts { auth, err := bind.NewKeyedTransactorWithChainID(scenario.Sender.PrivKey, chainID) if err != nil { panic("Failed to create transaction options: " + err.Error()) @@ -71,8 +69,8 @@ func CreateTransactionOpts(chainID *big.Int, scenario *loadtypes.TxScenario) *bi 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.GasTipCap = big.NewInt(GasTipCapWei) + auth.GasFeeCap = new(big.Int).Set(feeCap) auth.Signer = func(address common.Address, tx *ethtypes.Transaction) (*ethtypes.Transaction, error) { if address != scenario.Sender.Address { return nil, bind.ErrNotAuthorized diff --git a/profiles/arctic-1.json b/profiles/arctic-1.json index 60e8aef..3d9197c 100644 --- a/profiles/arctic-1.json +++ b/profiles/arctic-1.json @@ -1,5 +1,6 @@ { "chainId": 713715, + "genesisHash": "8ef5b0c01c1cde65be22a0f501d1663c55b3a900b46ecb72a5ccd823040bf035", "seiChainID": "arctic-1", "endpoints": ["http://REPLACE-rpc-internal.arctic-1.svc.cluster.local:8545"], "accounts": { diff --git a/registry/chains/README.md b/registry/chains/README.md index 40ddf05..85dd0d2 100644 --- a/registry/chains/README.md +++ b/registry/chains/README.md @@ -63,25 +63,39 @@ recorded nowhere. ## Which chains belong here -Split by how long the chain lives, not by convenience. - -**Commit here: pacific-1 and atlantic-2.** They are never re-genesised, so a -recorded address stays true. Committing puts the address in a reviewed pull -request and in a signed image, next to the bindings that encode against it. - -**Supply by `--chain-file` instead: arctic-1, and every devnet.** arctic-1 is -re-genesised on a devnet cadence, and each re-genesis changes its genesis hash -and invalidates a committed entry. Recovering from that here means a sei-load -pull request, a CI build, and a hand-edited image pin in each cell — where the -three canary cells are pinned independently on purpose. A file the deployment -supplies recovers with one reconcile. - -The cost of supplying it is real and worth stating: anyone who can edit that -source can point a run at a contract of their choosing, and the code-hash check -cannot catch it — the check proves the address holds the code the *file* -recorded, not that the code is ours. The canary mounts a funded key, so the loss -is bounded by that key's balance. That bound is the reason this is acceptable for -arctic-1 and not for pacific-1. +Split by whether a recorded address stays true, not by what we call the chain. + +**Commit here: pacific-1, atlantic-2 and arctic-1.** A recorded address stays +true for as long as the chain does. Committing puts it in a reviewed pull request +and in a signed image, beside the bindings that encode against it. + +arctic-1 differs from the other two in one way. It is ours, so we can +re-genesise it. Committing it anyway is a decision rather than an oversight. We +control that re-genesis and intend not to do it, and the alternative costs more +than the risk. + +That alternative is supplying the file from a deployment. Anyone who can edit +that source then points a run at a contract of their choosing. The code-hash +check cannot catch it: the check proves the address holds the code the *file* +recorded, not that the code is ours. Committing removes that exposure. It also +puts every address change through review. + +A re-genesis changes the chain's genesis hash, and the committed entries stop +matching. A run then deploys its own contracts, which is what every run did +before this directory held a file. Recovery is therefore a pull request here, at +whatever pace suits, rather than an outage. + +One case does not degrade quietly. A recorded address whose code has changed +fails Verify, and the run stops at startup rather than binding something else. + +A profile that reads a committed entry has to name the chain's `genesisHash`. The +registry keys on it, so a profile that omits it matches nothing. A run that omits +it, on a chain the registry describes, fails at startup and says why. It does not +redeploy on every restart in silence. + +**Supply by `--chain-file` instead: a chain that outlives a run but not a +release.** The flag stays for that case, and for bootstrapping a chain before +anyone commits its file here. **Never commit an ephemeral chain's file.** It disappears after the run, so the entry could never verify again. diff --git a/registry/chains/arctic-1.json b/registry/chains/arctic-1.json new file mode 100644 index 0000000..36bf890 --- /dev/null +++ b/registry/chains/arctic-1.json @@ -0,0 +1,22 @@ +{ + "chainId": 713715, + "chainName": "arctic-1", + "genesisHash": "8ef5b0c01c1cde65be22a0f501d1663c55b3a900b46ecb72a5ccd823040bf035", + "contracts": [ + { + "name": "defi-amm", + "address": "0x225af59603bb554686adfbb2869af4cec12488a1", + "codeHash": "0xd3b745d66f41b203732768f63c3f58a08be56a4c007d4ed88686d5230d7d54cd" + }, + { + "name": "tokenops-erc20", + "address": "0xe66344c8ed6dbde610725cd7e3359b1fe4d7ff26", + "codeHash": "0xa18365677f78d1ced93a0e2c4fa1bcbcb48ea2cf14e3c0482535bef1cbeebdfc" + }, + { + "name": "tokenops-erc721", + "address": "0x815299db5f8e3c6c42356655429cf2701e502bea", + "codeHash": "0xb6533beb6bc3c23f03769c83e191dcabd55a5f5ba454165fd7530320ab0799a5" + } + ] +} diff --git a/registry/registry_test.go b/registry/registry_test.go index c299697..158273f 100644 --- a/registry/registry_test.go +++ b/registry/registry_test.go @@ -27,18 +27,71 @@ const ( // TestLoadWithNoPathsReadsOnlyTheBinary asserts CDR-018: Load with no paths // returns the compiled-in registry, and reaches for nothing else. -// -// chains/ ships with no chain files, so the registry is empty today. The -// assertion is that Load succeeds and finds nothing, not that it finds nothing -// forever. func TestLoadWithNoPathsReadsOnlyTheBinary(t *testing.T) { r, err := registry.Load() if err != nil { t.Fatalf("Load(): %v", err) } - if got := len(r.Sources()); got != 0 { - t.Errorf("compiled-in registry holds %d chains, want 0. A chain file "+ - "in registry/chains/ needs its own test naming it.", got) + if got := len(r.Sources()); got != 1 { + t.Errorf("compiled-in registry holds %d chains, want 1. A chain file "+ + "added to or removed from registry/chains/ needs this test and "+ + "TestTheCompiledInRegistryNamesArctic1 to move with it.", got) + } +} + +// TestTheCompiledInRegistryNamesArctic1 names what the binary ships, so a chain +// file cannot change without a test changing with it. +// +// The addresses are the contracts a run binds instead of deploying, and the code +// hashes are what Verify checks them against. A wrong entry here is not a failed +// test in production: it is every cell reading this image failing at startup, or +// binding an address that holds something else. +func TestTheCompiledInRegistryNamesArctic1(t *testing.T) { + r, err := registry.Load() + if err != nil { + t.Fatalf("Load(): %v", err) + } + + const genesisHash = "8ef5b0c01c1cde65be22a0f501d1663c55b3a900b46ecb72a5ccd823040bf035" + chain, ok := r.Chain(713715, genesisHash) + if !ok { + t.Fatalf("no entry for arctic-1 at chain 713715 and its genesis hash. " + + "A run naming that hash would deploy its own contracts instead of " + + "binding the recorded ones.") + } + if chain.ChainName != "arctic-1" { + t.Errorf("chainName is %q, want arctic-1", chain.ChainName) + } + + // A chain id alone does not identify a chain instance. arctic-1 keeps its id + // across a re-genesis, so an entry that matched on the id alone would name + // addresses that no longer hold their contracts. + if _, ok := r.Chain(713715, "0000000000000000000000000000000000000000000000000000000000000000"); ok { + t.Error("a wrong genesis hash matched arctic-1, so the entry does not key on it") + } + + want := map[string]string{ + "defi-amm": "0x225af59603bb554686adfbb2869af4cec12488a1", + "tokenops-erc20": "0xe66344c8ed6dbde610725cd7e3359b1fe4d7ff26", + "tokenops-erc721": "0x815299db5f8e3c6c42356655429cf2701e502bea", + } + if len(chain.Contracts) != len(want) { + t.Fatalf("arctic-1 holds %d contracts, want %d", len(chain.Contracts), len(want)) + } + for name, address := range want { + contract, ok := chain.Contract(name) + if !ok { + t.Errorf("arctic-1 names no contract %q, so a profile using that "+ + "contractKey would deploy its own", name) + continue + } + if got := strings.ToLower(contract.Address.Hex()); got != address { + t.Errorf("%s is recorded at %s, want %s", name, got, address) + } + if contract.CodeHash == (common.Hash{}) { + t.Errorf("%s has no code hash, so Verify would have nothing to check "+ + "the address against", name) + } } }