diff --git a/config/config.go b/config/config.go index 72ba289..b19cb4f 100644 --- a/config/config.go +++ b/config/config.go @@ -159,6 +159,18 @@ func (c *LoadConfig) GetChainID() *big.Int { return big.NewInt(c.ChainID) } +// 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..02e17d8 100644 --- a/config/settings.go +++ b/config/settings.go @@ -37,6 +37,21 @@ 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"` // 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 +71,9 @@ func (s Settings) Validate() error { if s.MaxInFlight <= 0 { return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight) } + 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 +98,7 @@ func DefaultSettings() Settings { PostSummaryFlushDelay: Duration(25 * time.Second), ArrivalModel: ArrivalModelClosedLoop, MaxInFlight: 10_000, + GasMargin: 1.20, } } @@ -133,6 +152,7 @@ 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) return nil } @@ -177,5 +197,6 @@ func ResolveSettings() *Settings { PostSummaryFlushDelay: Duration(viper.GetDuration("postSummaryFlushDelay")), ArrivalModel: viper.GetString("arrivalModel"), MaxInFlight: viper.GetInt("maxInFlight"), + GasMargin: viper.GetFloat64("gasMargin"), } } diff --git a/config/settings_test.go b/config/settings_test.go index 16c28b9..a5ec117 100644 --- a/config/settings_test.go +++ b/config/settings_test.go @@ -155,6 +155,7 @@ func TestDefaultSettings(t *testing.T) { PostSummaryFlushDelay: Duration(25 * time.Second), ArrivalModel: ArrivalModelClosedLoop, MaxInFlight: 10_000, + GasMargin: 1.20, } if defaults != expected { @@ -170,7 +171,7 @@ func TestSettingsValidate(t *testing.T) { }{ { name: "positive max-in-flight is valid", - settings: Settings{MaxInFlight: 1}, + settings: Settings{MaxInFlight: 1, GasMargin: 1}, }, { name: "default settings are valid", @@ -178,14 +179,19 @@ func TestSettingsValidate(t *testing.T) { }, { name: "zero max-in-flight is rejected", - settings: Settings{MaxInFlight: 0}, + settings: Settings{MaxInFlight: 0, GasMargin: 1}, wantErr: "MaxInFlight = 0, want > 0", }, { name: "negative max-in-flight is rejected", - settings: Settings{MaxInFlight: -1}, + settings: Settings{MaxInFlight: -1, GasMargin: 1}, wantErr: "MaxInFlight = -1, want > 0", }, + { + name: "a margin below one is rejected", + settings: Settings{MaxInFlight: 1, GasMargin: 0.9}, + wantErr: "GasMargin = 0.9, want >= 1", + }, } for _, tt := range tests { diff --git a/generator/gas.go b/generator/gas.go new file mode 100644 index 0000000..fbbf996 --- /dev/null +++ b/generator/gas.go @@ -0,0 +1,161 @@ +package generator + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "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 + +// 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) { + // The three fee fields stay unset. A call carrying one makes the node + // check the caller's balance, and this caller has none by design: it is a + // fresh address chosen so every slot the call writes is still zero. + required, err := client.EstimateGas(ctx, ethereum.CallMsg{ + From: types.NewAccount(false).Address, + To: &address, + Data: call.Data, + Value: call.Value, + }) + 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 + } +} + +// 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/mockchain_test.go b/generator/mockchain_test.go index 8e32a42..a5bd2c7 100644 --- a/generator/mockchain_test.go +++ b/generator/mockchain_test.go @@ -151,8 +151,44 @@ func (m *mockChain) codeReads() []common.Address { return reads } +// 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 + func (m *mockChain) EstimateGas(_ context.Context, _ json.RawMessage, _ *rpc.BlockNumberOrHash) (hexutil.Uint64, error) { - return hexutil.Uint64(1_000_000), nil + return hexutil.Uint64(mockQuotedGas), 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..4a0cac2 100644 --- a/generator/prepare.go +++ b/generator/prepare.go @@ -120,7 +120,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 @@ -379,7 +384,10 @@ func (g *generatorBuilder) mockPrepareAll() error { 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..1ffc6be 100644 --- a/generator/scenarios/AMM_test.go +++ b/generator/scenarios/AMM_test.go @@ -12,17 +12,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 @@ -34,6 +23,7 @@ func newAttachedAMM(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *t 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 +127,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/StorageRW.go b/generator/scenarios/StorageRW.go index c6ddb06..5f2c1c4 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -2,6 +2,7 @@ package scenarios import ( "fmt" + "github.com/ethereum/go-ethereum/accounts/abi" "math/big" mrand "math/rand/v2" @@ -18,32 +19,9 @@ 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. - // - // 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 ) // storageRWDefaultSlot is the single slot every tx targets when no key @@ -55,12 +33,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 @@ -102,6 +89,30 @@ func (s *StorageRWScenario) SetContract(contract *bindings.StorageRWv1) { // The draws run in a fixed order: slot, then pad, then operation. That order // must stay stable — all three share the run's single PRNG, so reordering them // shifts every subsequent draw and diverges a replay at the same seed. +// 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, which the margin absorbs. +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{})}, + } +} + func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { slot, err := s.pickSlot(rng) if err != nil { @@ -112,14 +123,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 + 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..5ca47d8 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -50,6 +50,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] @@ -102,6 +103,7 @@ func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerat 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.StorageRW, Nonce: 0, @@ -246,7 +248,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()) + requireGasMatchesModel(t, tx) requireGasCoversFloor(t, tx) } diff --git a/generator/scenarios/base.go b/generator/scenarios/base.go index 071ef4e..aad7b9b 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,12 @@ func (s *ScenarioBase) GetConfig() *config.LoadConfig { type ContractScenarioBase[T any] struct { *ScenarioBase deployer ContractDeployer[T] + + // gasModels holds what the chain quoted for each operation. 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 } // NewContractScenarioBase creates a new base scenario with the given contract deployer @@ -161,6 +192,80 @@ 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)) + for _, call := range calls { + model, err := estimate(ctx, call) + if err != nil { + return fmt.Errorf("operation %q: %w", call.Operation, err) + } + models[call.Operation] = model + } + c.gasModels = models + 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) { + model, ok := c.gasModels[operation] + if !ok { + return 0, false + } + limit, err := model.Limit(c.gasCallData(operation)) + if err != nil { + return 0, false + } + return limit, true +} + +// 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) +} + +// gasCallData returns the calldata the named operation was priced against. +func (c *ContractScenarioBase[T]) gasCallData(operation string) []byte { + for _, call := range c.deployer.GasEstimateCalls() { + if call.Operation == operation { + return call.Data + } + } + return nil +} + func dial(config *config.LoadConfig) (*ethclient.Client, error) { if len(config.Endpoints) == 0 { return ethclient.NewClient(nil), nil 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/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..019efd4 --- /dev/null +++ b/generator/scenarios/gasestimate_internal_test.go @@ -0,0 +1,258 @@ +package scenarios + +import ( + "bytes" + "context" + 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"}} + 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)) +} diff --git a/generator/scenarios/gasestimate_test_helper_test.go b/generator/scenarios/gasestimate_test_helper_test.go new file mode 100644 index 0000000..5e9d612 --- /dev/null +++ b/generator/scenarios/gasestimate_test_helper_test.go @@ -0,0 +1,47 @@ +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") +}