From 33d158410548298bb1b66bf7e93b4083cc6ad5f1 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 27 Aug 2026 20:25:16 -0700 Subject: [PATCH 1/7] fix(scenarios): size the ERC721 mint limit from a measurement The scenario declared 22460 gas for a mint. Measured against the deployed binding, a mint to a receiver holding none of the token needs 69319, and one to a receiver that already holds some needs 51757. Every mint the scenario sent landed in a block with a failed status, having burned the whole limit, and trackReceipts defaults to false so the run reported each one as sent. 22460 is ERC20Noop's constant, copied. PLT-1091 covers the two scenarios that still carry it. The limit is now 75000, and the test pins it against the measurement rather than against itself. Broke the constant back to 22460 and to 200000 on purpose; the test caught both. Co-Authored-By: Claude Opus 5 (1M context) --- generator/scenarios/ERC721.go | 18 +++++++++- generator/scenarios/ERC721_test.go | 55 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 generator/scenarios/ERC721_test.go diff --git a/generator/scenarios/ERC721.go b/generator/scenarios/ERC721.go index 764987a..7390879 100644 --- a/generator/scenarios/ERC721.go +++ b/generator/scenarios/ERC721.go @@ -47,6 +47,22 @@ 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 @@ -59,6 +75,6 @@ func (s *ERC721Scenario) SetContract(contract *bindings.ERC721) { // 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 = 22460 + auth.GasLimit = erc721MintGas 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 new file mode 100644 index 0000000..e008372 --- /dev/null +++ b/generator/scenarios/ERC721_test.go @@ -0,0 +1,55 @@ +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) +} From 6c7e41d6400c83321c94e6c7c37196d3480b7466 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 27 Aug 2026 20:25:48 -0700 Subject: [PATCH 2/7] feat(scenarios): add an AMM swap scenario A DeFi profile had no contract to drive. This adds a constant-product pair with the storage and gas shape of a UniswapV2 swap: both reserves, the caller's balance in each token, and an event. The contract never reverts on bookkeeping, which is the choice StorageRWv1 already makes. The balances wrap rather than check, because nothing reads them back and a load generator that fails on its own accounting stops measuring the chain. A short caller is not credited: crediting exactly what is then debited returns the slot to zero, and a zero to non-zero storage write costs four times one that changes a slot already holding a value. Under the default mix, which draws one direction, that write would land on every swap rather than the first. The reserves sit between a floor and a ceiling. Without the ceiling the input side grows without bound and the output halves every 100000 swaps, so a long run prices nothing like its start. The ceiling is also what keeps one oversized call from ending the pair: a swap of 1e49 leaves the input reserve at 1e49, and the contract has no owner and no reset. Measured, the next ordinary swap instead resets that side to the floor and pays out in full. The gas limit is 85000, read from eth_estimateGas rather than from a receipt. GasUsed is the post-refund charge and a transaction carries the pre-refund peak; sizing from a receipt put an earlier draft 20% under what its own swap needed. An account's first swap needs 79988 and every later one needs 45177, so a run in steady state declares about 44% more gas than it spends. PLT-1093 carries the prewarm change that would close that. PLT-1092 carries the chain-parameter exposure, which is the whole package rather than this constant. Every guard here was broken on purpose before it was believed. Co-Authored-By: Claude Opus 5 (1M context) --- config/operation.go | 23 + generator/bindings/AMM.go | 586 ++++++++++++++++++ generator/contracts/AMM.sol | 118 ++++ generator/scenarios/AMM.go | 136 ++++ generator/scenarios/AMM_test.go | 181 ++++++ generator/scenarios/factory.go | 3 +- .../scenarios/operation_internal_test.go | 12 + 7 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 generator/bindings/AMM.go create mode 100644 generator/contracts/AMM.sol create mode 100644 generator/scenarios/AMM.go create mode 100644 generator/scenarios/AMM_test.go diff --git a/config/operation.go b/config/operation.go index eeb3d8a..7f2629c 100644 --- a/config/operation.go +++ b/config/operation.go @@ -38,15 +38,38 @@ const ( OpERC721Mint = "erc721_mint" // OpDisperseEther is Disperse disperseEtherFixed(address[]). OpDisperseEther = "disperse_ether" + // OpSwapAToB is AMM swapAToB(uint256), the A-for-B leg of a pair. + OpSwapAToB = "swap_a_to_b" + // OpSwapBToA is AMM swapBToA(uint256), the B-for-A leg. + OpSwapBToA = "swap_b_to_a" ) // StorageRWOperations is the operation set the storagerw scenario draws from. var StorageRWOperations = NewOperationSet(OpRmw, OpRead, OpWrite) +// AMMOperations is the operation set the amm scenario draws from: both legs of +// one pair, so a profile can weight the direction. A profile that sets no mix +// draws the first name for every transaction, which is one direction — the +// contract holds its reserves between a floor and a ceiling, so that run prices +// the same at its end as at its start. +var AMMOperations = NewOperationSet(OpSwapAToB, OpSwapBToA) + // scenarioOperations maps a scenario's wire name, lowercased, to the operations // it supports. A scenario absent from the table supports none. var scenarioOperations = map[string]*OperationSet{ "storagerw": StorageRWOperations, + "amm": AMMOperations, +} + +// OperationNamesFor returns every operation a scenario can draw, or nil if it +// draws none. It is exported so a test can check the whole vocabulary a +// scenario reaches the metrics with, rather than only its default. +func OperationNamesFor(scenario string) []string { + set := operationsFor(scenario) + if set == nil { + return nil + } + return set.Names() } // operationsFor returns the operations a scenario supports, or nil if it diff --git a/generator/bindings/AMM.go b/generator/bindings/AMM.go new file mode 100644 index 0000000..2d01591 --- /dev/null +++ b/generator/bindings/AMM.go @@ -0,0 +1,586 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package bindings + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// AMMMetaData contains all meta data concerning the AMM contract. +var AMMMetaData = &bind.MetaData{ + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"sender\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"bool\",\"name\":\"aToB\",\"type\":\"bool\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amountIn\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amountOut\",\"type\":\"uint256\"}],\"name\":\"Swap\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"RESERVE_CEIL\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"RESERVE_FLOOR\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOfA\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOfB\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reserveA\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reserveB\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"amountIn\",\"type\":\"uint256\"}],\"name\":\"swapAToB\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"amountIn\",\"type\":\"uint256\"}],\"name\":\"swapBToA\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + Bin: "0x608060405234801561001057600080fd5b5069d3c21bcecceda10000006000819055600155610405806100336000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c806338720f721161005b57806338720f72146100ea57806385949788146100fd578063dc5fa6c514610110578063ddc4838d1461011957600080fd5b80630563e8671461008d57806319e36f3b146100b057806325d33d2f146100b95780632a6622e6146100c1575b600080fd5b61009e69d3c21bcecceda100000081565b60405190815260200160405180910390f35b61009e60015481565b61009e610142565b61009e6100cf36600461033a565b6001600160a01b031660009081526003602052604090205490565b61009e6100f836600461036a565b61015a565b61009e61010b36600461036a565b61016d565b61009e60005481565b61009e61012736600461033a565b6001600160a01b031660009081526002602052604090205490565b61015769d3c21bcecceda10000006002610399565b81565b6000610167600183610176565b92915050565b60006101676000835b600080836101865760015461018a565b6000545b905060008461019b5760005461019f565b6001545b905069d3c21bcecceda10000008210806101cc57506101c969d3c21bcecceda10000006002610399565b82115b156101df5769d3c21bcecceda100000091505b69d3c21bcecceda100000081108061020a575061020769d3c21bcecceda10000006002610399565b81115b1561021c575069d3c21bcecceda10000005b600061022a856103e5610399565b905060006102388383610399565b9050600082610249866103e8610399565b61025391906103b0565b9050600061026182846103c3565b9050600089610271576003610274565b60025b905060008a610284576002610287565b60035b3360009081526020848152604080832080548f9003905590839052902080548501905590508a156102d1576102bc8a896103b0565b6000556102c983886103e5565b6001556102ec565b6102db8a896103b0565b6001556102e883886103e5565b6000555b604080518b8152602081018590528c15159133917fbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd910160405180910390a350909998505050505050505050565b60006020828403121561034c57600080fd5b81356001600160a01b038116811461036357600080fd5b9392505050565b60006020828403121561037c57600080fd5b5035919050565b634e487b7160e01b600052601160045260246000fd5b808202811582820484141761016757610167610383565b8082018082111561016757610167610383565b6000826103e057634e487b7160e01b600052601260045260246000fd5b500490565b818103818111156101675761016761038356fea164736f6c6343000813000a", +} + +// AMMABI is the input ABI used to generate the binding from. +// Deprecated: Use AMMMetaData.ABI instead. +var AMMABI = AMMMetaData.ABI + +// AMMBin is the compiled bytecode used for deploying new contracts. +// Deprecated: Use AMMMetaData.Bin instead. +var AMMBin = AMMMetaData.Bin + +// DeployAMM deploys a new Ethereum contract, binding an instance of AMM to it. +func DeployAMM(auth *bind.TransactOpts, backend bind.ContractBackend) (common.Address, *types.Transaction, *AMM, error) { + parsed, err := AMMMetaData.GetAbi() + if err != nil { + return common.Address{}, nil, nil, err + } + if parsed == nil { + return common.Address{}, nil, nil, errors.New("GetABI returned nil") + } + + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(AMMBin), backend) + if err != nil { + return common.Address{}, nil, nil, err + } + return address, tx, &AMM{AMMCaller: AMMCaller{contract: contract}, AMMTransactor: AMMTransactor{contract: contract}, AMMFilterer: AMMFilterer{contract: contract}}, nil +} + +// AMM is an auto generated Go binding around an Ethereum contract. +type AMM struct { + AMMCaller // Read-only binding to the contract + AMMTransactor // Write-only binding to the contract + AMMFilterer // Log filterer for contract events +} + +// AMMCaller is an auto generated read-only Go binding around an Ethereum contract. +type AMMCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AMMTransactor is an auto generated write-only Go binding around an Ethereum contract. +type AMMTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AMMFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type AMMFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AMMSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type AMMSession struct { + Contract *AMM // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// AMMCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type AMMCallerSession struct { + Contract *AMMCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// AMMTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type AMMTransactorSession struct { + Contract *AMMTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// AMMRaw is an auto generated low-level Go binding around an Ethereum contract. +type AMMRaw struct { + Contract *AMM // Generic contract binding to access the raw methods on +} + +// AMMCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type AMMCallerRaw struct { + Contract *AMMCaller // Generic read-only contract binding to access the raw methods on +} + +// AMMTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type AMMTransactorRaw struct { + Contract *AMMTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewAMM creates a new instance of AMM, bound to a specific deployed contract. +func NewAMM(address common.Address, backend bind.ContractBackend) (*AMM, error) { + contract, err := bindAMM(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &AMM{AMMCaller: AMMCaller{contract: contract}, AMMTransactor: AMMTransactor{contract: contract}, AMMFilterer: AMMFilterer{contract: contract}}, nil +} + +// NewAMMCaller creates a new read-only instance of AMM, bound to a specific deployed contract. +func NewAMMCaller(address common.Address, caller bind.ContractCaller) (*AMMCaller, error) { + contract, err := bindAMM(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &AMMCaller{contract: contract}, nil +} + +// NewAMMTransactor creates a new write-only instance of AMM, bound to a specific deployed contract. +func NewAMMTransactor(address common.Address, transactor bind.ContractTransactor) (*AMMTransactor, error) { + contract, err := bindAMM(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &AMMTransactor{contract: contract}, nil +} + +// NewAMMFilterer creates a new log filterer instance of AMM, bound to a specific deployed contract. +func NewAMMFilterer(address common.Address, filterer bind.ContractFilterer) (*AMMFilterer, error) { + contract, err := bindAMM(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &AMMFilterer{contract: contract}, nil +} + +// bindAMM binds a generic wrapper to an already deployed contract. +func bindAMM(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := AMMMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_AMM *AMMRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _AMM.Contract.AMMCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_AMM *AMMRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _AMM.Contract.AMMTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_AMM *AMMRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _AMM.Contract.AMMTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_AMM *AMMCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _AMM.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_AMM *AMMTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _AMM.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_AMM *AMMTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _AMM.Contract.contract.Transact(opts, method, params...) +} + +// RESERVECEIL is a free data retrieval call binding the contract method 0x25d33d2f. +// +// Solidity: function RESERVE_CEIL() view returns(uint256) +func (_AMM *AMMCaller) RESERVECEIL(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "RESERVE_CEIL") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// RESERVECEIL is a free data retrieval call binding the contract method 0x25d33d2f. +// +// Solidity: function RESERVE_CEIL() view returns(uint256) +func (_AMM *AMMSession) RESERVECEIL() (*big.Int, error) { + return _AMM.Contract.RESERVECEIL(&_AMM.CallOpts) +} + +// RESERVECEIL is a free data retrieval call binding the contract method 0x25d33d2f. +// +// Solidity: function RESERVE_CEIL() view returns(uint256) +func (_AMM *AMMCallerSession) RESERVECEIL() (*big.Int, error) { + return _AMM.Contract.RESERVECEIL(&_AMM.CallOpts) +} + +// RESERVEFLOOR is a free data retrieval call binding the contract method 0x0563e867. +// +// Solidity: function RESERVE_FLOOR() view returns(uint256) +func (_AMM *AMMCaller) RESERVEFLOOR(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "RESERVE_FLOOR") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// RESERVEFLOOR is a free data retrieval call binding the contract method 0x0563e867. +// +// Solidity: function RESERVE_FLOOR() view returns(uint256) +func (_AMM *AMMSession) RESERVEFLOOR() (*big.Int, error) { + return _AMM.Contract.RESERVEFLOOR(&_AMM.CallOpts) +} + +// RESERVEFLOOR is a free data retrieval call binding the contract method 0x0563e867. +// +// Solidity: function RESERVE_FLOOR() view returns(uint256) +func (_AMM *AMMCallerSession) RESERVEFLOOR() (*big.Int, error) { + return _AMM.Contract.RESERVEFLOOR(&_AMM.CallOpts) +} + +// BalanceOfA is a free data retrieval call binding the contract method 0xddc4838d. +// +// Solidity: function balanceOfA(address account) view returns(uint256) +func (_AMM *AMMCaller) BalanceOfA(opts *bind.CallOpts, account common.Address) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "balanceOfA", account) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOfA is a free data retrieval call binding the contract method 0xddc4838d. +// +// Solidity: function balanceOfA(address account) view returns(uint256) +func (_AMM *AMMSession) BalanceOfA(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfA(&_AMM.CallOpts, account) +} + +// BalanceOfA is a free data retrieval call binding the contract method 0xddc4838d. +// +// Solidity: function balanceOfA(address account) view returns(uint256) +func (_AMM *AMMCallerSession) BalanceOfA(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfA(&_AMM.CallOpts, account) +} + +// BalanceOfB is a free data retrieval call binding the contract method 0x2a6622e6. +// +// Solidity: function balanceOfB(address account) view returns(uint256) +func (_AMM *AMMCaller) BalanceOfB(opts *bind.CallOpts, account common.Address) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "balanceOfB", account) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOfB is a free data retrieval call binding the contract method 0x2a6622e6. +// +// Solidity: function balanceOfB(address account) view returns(uint256) +func (_AMM *AMMSession) BalanceOfB(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfB(&_AMM.CallOpts, account) +} + +// BalanceOfB is a free data retrieval call binding the contract method 0x2a6622e6. +// +// Solidity: function balanceOfB(address account) view returns(uint256) +func (_AMM *AMMCallerSession) BalanceOfB(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfB(&_AMM.CallOpts, account) +} + +// ReserveA is a free data retrieval call binding the contract method 0xdc5fa6c5. +// +// Solidity: function reserveA() view returns(uint256) +func (_AMM *AMMCaller) ReserveA(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "reserveA") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ReserveA is a free data retrieval call binding the contract method 0xdc5fa6c5. +// +// Solidity: function reserveA() view returns(uint256) +func (_AMM *AMMSession) ReserveA() (*big.Int, error) { + return _AMM.Contract.ReserveA(&_AMM.CallOpts) +} + +// ReserveA is a free data retrieval call binding the contract method 0xdc5fa6c5. +// +// Solidity: function reserveA() view returns(uint256) +func (_AMM *AMMCallerSession) ReserveA() (*big.Int, error) { + return _AMM.Contract.ReserveA(&_AMM.CallOpts) +} + +// ReserveB is a free data retrieval call binding the contract method 0x19e36f3b. +// +// Solidity: function reserveB() view returns(uint256) +func (_AMM *AMMCaller) ReserveB(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "reserveB") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ReserveB is a free data retrieval call binding the contract method 0x19e36f3b. +// +// Solidity: function reserveB() view returns(uint256) +func (_AMM *AMMSession) ReserveB() (*big.Int, error) { + return _AMM.Contract.ReserveB(&_AMM.CallOpts) +} + +// ReserveB is a free data retrieval call binding the contract method 0x19e36f3b. +// +// Solidity: function reserveB() view returns(uint256) +func (_AMM *AMMCallerSession) ReserveB() (*big.Int, error) { + return _AMM.Contract.ReserveB(&_AMM.CallOpts) +} + +// SwapAToB is a paid mutator transaction binding the contract method 0x38720f72. +// +// Solidity: function swapAToB(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactor) SwapAToB(opts *bind.TransactOpts, amountIn *big.Int) (*types.Transaction, error) { + return _AMM.contract.Transact(opts, "swapAToB", amountIn) +} + +// SwapAToB is a paid mutator transaction binding the contract method 0x38720f72. +// +// Solidity: function swapAToB(uint256 amountIn) returns(uint256) +func (_AMM *AMMSession) SwapAToB(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapAToB(&_AMM.TransactOpts, amountIn) +} + +// SwapAToB is a paid mutator transaction binding the contract method 0x38720f72. +// +// Solidity: function swapAToB(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactorSession) SwapAToB(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapAToB(&_AMM.TransactOpts, amountIn) +} + +// SwapBToA is a paid mutator transaction binding the contract method 0x85949788. +// +// Solidity: function swapBToA(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactor) SwapBToA(opts *bind.TransactOpts, amountIn *big.Int) (*types.Transaction, error) { + return _AMM.contract.Transact(opts, "swapBToA", amountIn) +} + +// SwapBToA is a paid mutator transaction binding the contract method 0x85949788. +// +// Solidity: function swapBToA(uint256 amountIn) returns(uint256) +func (_AMM *AMMSession) SwapBToA(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapBToA(&_AMM.TransactOpts, amountIn) +} + +// SwapBToA is a paid mutator transaction binding the contract method 0x85949788. +// +// Solidity: function swapBToA(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactorSession) SwapBToA(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapBToA(&_AMM.TransactOpts, amountIn) +} + +// AMMSwapIterator is returned from FilterSwap and is used to iterate over the raw logs and unpacked data for Swap events raised by the AMM contract. +type AMMSwapIterator struct { + Event *AMMSwap // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AMMSwapIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AMMSwap) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AMMSwap) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AMMSwapIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AMMSwapIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AMMSwap represents a Swap event raised by the AMM contract. +type AMMSwap struct { + Sender common.Address + AToB bool + AmountIn *big.Int + AmountOut *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSwap is a free log retrieval operation binding the contract event 0xbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd. +// +// Solidity: event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut) +func (_AMM *AMMFilterer) FilterSwap(opts *bind.FilterOpts, sender []common.Address, aToB []bool) (*AMMSwapIterator, error) { + + var senderRule []interface{} + for _, senderItem := range sender { + senderRule = append(senderRule, senderItem) + } + var aToBRule []interface{} + for _, aToBItem := range aToB { + aToBRule = append(aToBRule, aToBItem) + } + + logs, sub, err := _AMM.contract.FilterLogs(opts, "Swap", senderRule, aToBRule) + if err != nil { + return nil, err + } + return &AMMSwapIterator{contract: _AMM.contract, event: "Swap", logs: logs, sub: sub}, nil +} + +// WatchSwap is a free log subscription operation binding the contract event 0xbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd. +// +// Solidity: event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut) +func (_AMM *AMMFilterer) WatchSwap(opts *bind.WatchOpts, sink chan<- *AMMSwap, sender []common.Address, aToB []bool) (event.Subscription, error) { + + var senderRule []interface{} + for _, senderItem := range sender { + senderRule = append(senderRule, senderItem) + } + var aToBRule []interface{} + for _, aToBItem := range aToB { + aToBRule = append(aToBRule, aToBItem) + } + + logs, sub, err := _AMM.contract.WatchLogs(opts, "Swap", senderRule, aToBRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AMMSwap) + if err := _AMM.contract.UnpackLog(event, "Swap", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSwap is a log parse operation binding the contract event 0xbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd. +// +// Solidity: event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut) +func (_AMM *AMMFilterer) ParseSwap(log types.Log) (*AMMSwap, error) { + event := new(AMMSwap) + if err := _AMM.contract.UnpackLog(event, "Swap", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/generator/contracts/AMM.sol b/generator/contracts/AMM.sol new file mode 100644 index 0000000..c0469ac --- /dev/null +++ b/generator/contracts/AMM.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title AMM +/// @notice A constant-product swap with the storage and gas shape of a +/// UniswapV2 pair, sized for load generation rather than for correctness. +/// +/// A swap here touches what a real pair touches: both reserves, the caller's +/// balance in each token, and an event. That is the cost this contract exists +/// to reproduce. What it does not reproduce is the economics — there is no +/// router, no fee split to an LP, no price oracle and no minimum-output check. +/// +/// Nothing reverts on bookkeeping, which is the same choice StorageRWv1.sol +/// makes. The balances wrap instead of checking, because nothing reads them +/// back and a load generator that fails on its own accounting stops measuring +/// the chain. The arithmetic on lines outside the unchecked block stays +/// checked: it reverts only on an input the scenario never sends, and a silent +/// wrap there would price a swap from garbage. +/// +/// Every swap writes the same four slots, so after a caller's first swap the +/// cost of its thousandth is the cost of its second. +contract AMM { + uint256 public reserveA; + uint256 public reserveB; + + /// @dev The reserve a swap restores an exhausted side to. + uint256 public constant RESERVE_FLOOR = 1_000_000 * 10**18; + + /// @dev The reserve above which a swap restores a side to the floor. A run + /// drives one pair for its whole length and only ever adds to the input + /// side, so without a ceiling the price walks away from where it started: + /// measured, the output halves every 100,000 swaps. With it the reserves + /// saw-tooth and the price holds for any run length. + /// + /// A swap clamps a reserve as it reads it and writes the result after, so a + /// side settles within one swap's size of each bound rather than exactly on + /// it. Measured over 40 swaps of a tenth of the floor, the input side stayed + /// in [1.1x, 2.1x] of the floor. + /// + /// The ceiling is also what keeps one oversized call from ending the pair. + /// A swap of 1e49 leaves the input reserve at 1e49, which without a ceiling + /// prices every later swap at nothing; measured, the next ordinary swap + /// instead resets that side to the floor and pays out in full. + uint256 public constant RESERVE_CEIL = 2 * RESERVE_FLOOR; + + mapping(address => uint256) private _balanceA; + mapping(address => uint256) private _balanceB; + + event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut); + + constructor() { + reserveA = RESERVE_FLOOR; + reserveB = RESERVE_FLOOR; + } + + function balanceOfA(address account) public view returns (uint256) { + return _balanceA[account]; + } + + function balanceOfB(address account) public view returns (uint256) { + return _balanceB[account]; + } + + /// @notice Swap amountIn of token A for token B. + function swapAToB(uint256 amountIn) public returns (uint256) { + return _swap(true, amountIn); + } + + /// @notice Swap amountIn of token B for token A. + function swapBToA(uint256 amountIn) public returns (uint256) { + return _swap(false, amountIn); + } + + function _swap(bool aToB, uint256 amountIn) private returns (uint256) { + uint256 reserveIn = aToB ? reserveA : reserveB; + uint256 reserveOut = aToB ? reserveB : reserveA; + + // Hold both sides between the floor and the ceiling. See RESERVE_CEIL. + if (reserveIn < RESERVE_FLOOR || reserveIn > RESERVE_CEIL) { + reserveIn = RESERVE_FLOOR; + } + if (reserveOut < RESERVE_FLOOR || reserveOut > RESERVE_CEIL) { + reserveOut = RESERVE_FLOOR; + } + + // x*y=k with the 0.3% fee UniswapV2 charges, so the arithmetic is the + // same width and the same number of operations. + uint256 amountInWithFee = amountIn * 997; + uint256 numerator = amountInWithFee * reserveOut; + uint256 denominator = reserveIn * 1000 + amountInWithFee; + uint256 amountOut = numerator / denominator; + + mapping(address => uint256) storage balIn = aToB ? _balanceA : _balanceB; + mapping(address => uint256) storage balOut = aToB ? _balanceB : _balanceA; + + // Wrap rather than revert, and rather than credit a short caller. A + // credit that restores the slot to what it held leaves it at zero for a + // caller that started there, and a zero-to-non-zero write costs four + // times one that changes a slot already holding a value. Under the + // default mix, which draws one direction, that cold write would land on + // every swap the run makes rather than on the first. + unchecked { + balIn[msg.sender] = balIn[msg.sender] - amountIn; + balOut[msg.sender] = balOut[msg.sender] + amountOut; + } + + if (aToB) { + reserveA = reserveIn + amountIn; + reserveB = reserveOut - amountOut; + } else { + reserveB = reserveIn + amountIn; + reserveA = reserveOut - amountOut; + } + + emit Swap(msg.sender, aToB, amountIn, amountOut); + return amountOut; + } +} diff --git a/generator/scenarios/AMM.go b/generator/scenarios/AMM.go new file mode 100644 index 0000000..b45c673 --- /dev/null +++ b/generator/scenarios/AMM.go @@ -0,0 +1,136 @@ +package scenarios + +import ( + "fmt" + "math/big" + mrand "math/rand/v2" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/bindings" + "github.com/sei-protocol/sei-load/types" +) + +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. +// Measured across the steady shape, a swap needed 45,141 gas at 1e6 and 45,201 +// at 1e23: sixty gas over seventeen orders of magnitude, and all of it the +// calldata bytes of the larger number rather than execution. +// +// The one amount that does change the shape is zero, which makes the output zero +// and turns the payout write into a write of the value already there. The +// scenario never sends it. +// +// A draw would also cost a draw from the run's single PRNG, which every other +// scenario's replay at the same seed depends on. +var ammSwapAmount = new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)) + +// AMMScenario drives a constant-product pair, which is the shape most of a DeFi +// workload's transactions have. +type AMMScenario struct { + *ContractScenarioBase[bindings.AMM] + contract *bindings.AMM + operations *config.OperationPicker +} + +// NewAMMScenario creates a new AMM scenario. +func NewAMMScenario(cfg config.Scenario) TxGenerator { + scenario := &AMMScenario{ + operations: config.AMMOperations.Picker(cfg.Operations), + } + scenario.ContractScenarioBase = NewContractScenarioBase[bindings.AMM](scenario, cfg) + return scenario +} + +// Name returns the name of the scenario. +func (s *AMMScenario) Name() string { + return AMM +} + +// Operation returns the scenario's default operation. +func (s *AMMScenario) Operation() string { + return config.OpSwapAToB +} + +// DeployContract implements ContractDeployer. AMM seeds both reserves in its +// constructor and takes no arguments. +func (s *AMMScenario) DeployContract(opts *bind.TransactOpts, client *ethclient.Client) (common.Address, *ethtypes.Transaction, error) { + address, tx, _, err := bindings.DeployAMM(opts, client) + return address, tx, err +} + +// GetBindFunc implements ContractDeployer. +func (s *AMMScenario) GetBindFunc() ContractBindFunc[bindings.AMM] { + return bindings.NewAMM +} + +// SetContract implements ContractDeployer. +func (s *AMMScenario) SetContract(contract *bindings.AMM) { + s.contract = contract +} + +// 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 + // one at the same seed. + op := s.operations.Select(rng) + scenario.Operation = op + + switch op { + case config.OpSwapAToB: + return s.contract.SwapAToB(auth, ammSwapAmount) + case config.OpSwapBToA: + return s.contract.SwapBToA(auth, ammSwapAmount) + default: + // A name added to the set with no call here would otherwise be stamped + // into the metric and sent as the other leg, so the dimension would say + // one thing while the chain saw another. + return nil, fmt.Errorf("amm: no call for operation %q", op) + } +} diff --git a/generator/scenarios/AMM_test.go b/generator/scenarios/AMM_test.go new file mode 100644 index 0000000..bca6023 --- /dev/null +++ b/generator/scenarios/AMM_test.go @@ -0,0 +1,181 @@ +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/bindings" + "github.com/sei-protocol/sei-load/generator/scenarios" + "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 + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + gen := scenarios.CreateScenario(sc) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) + return gen, &types.TxScenario{ + Name: scenarios.AMM, + Nonce: 0, + Sender: types.GenerateAccounts(1, true)[0], + } +} + +// decodeAMM names the method AMM calldata calls, through the binding's own ABI +// so the test cannot drift from the contract. +func decodeAMM(t *testing.T, data []byte) string { + t.Helper() + parsed, err := bindings.AMMMetaData.GetAbi() + require.NoError(t, err) + method, err := parsed.MethodById(data[:4]) + require.NoError(t, err) + return method.Name +} + +// ammMethodFor maps an operation name to the contract method that name promises. +var ammMethodFor = map[string]string{ + config.OpSwapAToB: "swapAToB", + config.OpSwapBToA: "swapBToA", +} + +// TestAMMCoversItsDeclaredOperations closes the seam between the names config +// declares for this scenario and the methods the scenario calls: weighting one +// name alone must produce calldata for that name's method. A name added to the +// set with no call behind it fails here rather than mislabelling every +// transaction of a run. +func TestAMMCoversItsDeclaredOperations(t *testing.T) { + names := config.OperationNamesFor(scenarios.AMM) + require.NotEmpty(t, names) + for _, name := range names { + t.Run(name, func(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{name: 1}, + }) + tx, err := gen.Generate(newTestRng(1), txs) + require.NoError(t, err) + want, known := ammMethodFor[name] + require.True(t, known, "no expected method recorded for %q", name) + require.Equal(t, want, decodeAMM(t, tx.Data())) + }) + } +} + +// TestAMMStampsTheDrawnOperation asserts the operation recorded on the +// TxScenario is the method the calldata actually calls, across a mix. +// +// The weights are deliberately lopsided and written with the later-declared +// name first. A balanced two-name mix in declared order passes for any picker +// that is not wholly degenerate, including one that walks the map in Go's +// randomised order, so it would assert nothing about the draw. +func TestAMMStampsTheDrawnOperation(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{ + config.OpSwapBToA: 1, + config.OpSwapAToB: 3, + }, + }) + rng := newTestRng(7) + seen := map[string]int{} + for range 200 { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + require.Equal(t, ammMethodFor[txs.Operation], decodeAMM(t, tx.Data()), + "the metric says %q and the chain sees a different method", txs.Operation) + seen[txs.Operation]++ + } + require.Len(t, seen, 2, "one leg never drew, so the mix is not being read") + require.Greater(t, seen[config.OpSwapAToB], seen[config.OpSwapBToA], + "the heavier weight did not draw more often, so the weights are ignored") +} + +// TestAMMLegsCallDifferentMethods fails when both operations produce the same +// calldata. Two names that reach the metrics as separate dimensions must reach +// the chain as separate calls, or the run reports a split it did not make. +func TestAMMLegsCallDifferentMethods(t *testing.T) { + sel := map[string]string{} + for _, name := range config.OperationNamesFor(scenarios.AMM) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{name: 1}, + }) + tx, err := gen.Generate(newTestRng(3), txs) + require.NoError(t, err) + sel[name] = string(tx.Data()[:4]) + } + require.Len(t, sel, 2) + require.NotEqual(t, sel[config.OpSwapAToB], sel[config.OpSwapBToA], + "both legs send the same selector, so the two dimensions are one workload") +} + +// TestAMMGasCoversAMeasuredSwap holds the declared limit between the cost a swap +// actually has and a ceiling above it. +// +// Both bounds matter and they fail differently. Below the real cost every +// transaction lands with a failed status and burns the whole limit, which a run +// without receipt tracking reports as success. Far above it, a chain that admits +// transactions against their declared limit reserves block space the swap never +// spends, and the profile's reachable throughput falls for no visible reason. +// +// The bounds are measured, not the constant restated: an assertion against the +// constant itself passes at any value. +func TestAMMGasCoversAMeasuredSwap(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") +} + +// TestAMMDefaultPathDrawsNoRandomness asserts a profile with no operation mix +// consumes no randomness, so adding this scenario to a run cannot shift the +// draw sequence every other scenario's replay depends on. +func TestAMMDefaultPathDrawsNoRandomness(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{}) + rng := newTestRng(11) + _, err := gen.Generate(rng, txs) + require.NoError(t, err) + require.Equal(t, newTestRng(11).Uint64(), rng.Uint64(), + "the default path drew from the run's PRNG, which shifts every later draw") +} + +// TestAMMMixDrawsExactlyOnce asserts a profile with a mix consumes one draw per +// transaction, for the same reason. +func TestAMMMixDrawsExactlyOnce(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{config.OpSwapAToB: 1, config.OpSwapBToA: 1}, + }) + rng := newTestRng(11) + _, err := gen.Generate(rng, txs) + require.NoError(t, err) + + want := newTestRng(11) + consumeOne(want) + require.Equal(t, want.Uint64(), rng.Uint64(), + "the mix path drew a different number of times than one") +} + +// consumeOne advances a PRNG by the one draw an operation pick costs. +func consumeOne(rng *mrand.Rand) { rng.Uint64N(2) } diff --git a/generator/scenarios/factory.go b/generator/scenarios/factory.go index 409ad5c..a6d9346 100644 --- a/generator/scenarios/factory.go +++ b/generator/scenarios/factory.go @@ -18,10 +18,11 @@ var scenarioFactories = map[string]ScenarioFactory{ // Auto-generated entries will be added below this line by make generate // DO NOT EDIT BELOW THIS LINE - AUTO-GENERATED CONTENT + AMM: NewAMMScenario, Disperse: NewDisperseScenario, + ERC20: NewERC20Scenario, ERC20Conflict: NewERC20ConflictScenario, ERC20Noop: NewERC20NoopScenario, - ERC20: NewERC20Scenario, ERC721: NewERC721Scenario, StorageRW: NewStorageRWScenario, diff --git a/generator/scenarios/operation_internal_test.go b/generator/scenarios/operation_internal_test.go index 70c2b27..cb2e447 100644 --- a/generator/scenarios/operation_internal_test.go +++ b/generator/scenarios/operation_internal_test.go @@ -19,6 +19,8 @@ var frozenOperations = map[string]struct{}{ config.OpERC20Transfer: {}, config.OpERC721Mint: {}, config.OpDisperseEther: {}, + config.OpSwapAToB: {}, + config.OpSwapBToA: {}, } // TestEveryScenarioNamesAFrozenOperation asserts that every registered scenario @@ -37,6 +39,16 @@ func TestEveryScenarioNamesAFrozenOperation(t *testing.T) { op := factory(config.Scenario{Name: name}).Operation() require.NotEmpty(t, op) require.Contains(t, frozenOperations, op) + + // Every name the scenario can draw, not only the one it defaults to. + // A scenario with an operation set reaches the metric dimension with + // any of them, so checking the default alone leaves the rest of the + // vocabulary unchecked while the test's name says otherwise. + for _, drawable := range config.OperationNamesFor(name) { + require.Contains(t, frozenOperations, drawable, + "scenario %q can draw %q, which is not in the frozen vocabulary", + name, drawable) + } }) } } From 137675a899672c04e494def0f67c132301b07657 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 11:59:08 -0700 Subject: [PATCH 3/7] feat(health): serve healthz and readyz A deployment has no way to tell a run that is starting from one that is stuck. The process serves /metrics and nothing else, so a probe set has nothing to gate on and a pod counts as available the moment its container starts. /healthz answers as soon as the server binds and never reads the startup sequence. /readyz refuses until the dispatcher is running. Keeping those separate is the whole point. Funding, deployment and prewarm take minutes against a cold chain. A liveness probe that reported the run dead for that window would restart the pod before it sent a transaction, then restart the next attempt at the same place, and the cause would read as a crash loop rather than a slow start. While /readyz refuses it names the phase, so a ten-minute startup shows the step it is on. Measured against the binary: healthz held 200 through a 21 second prewarm while readyz reported "prewarming accounts", then both answered once the dispatcher started. The flag and the phase are stored as one value rather than as two atomics. Two would leave a window where a reader sees the run serving while the body still names the step it left, so the status and the body would disagree about the same instant. Five mutations, five caught, including that one: split into two atomics, a reader observed a serving status carrying "funding accounts". Co-Authored-By: Claude Opus 5 (1M context) --- health/probes.go | 86 +++++++++++++++++++++++ health/probes_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++ main.go | 16 +++++ 3 files changed, 258 insertions(+) create mode 100644 health/probes.go create mode 100644 health/probes_test.go diff --git a/health/probes.go b/health/probes.go new file mode 100644 index 0000000..9858a8d --- /dev/null +++ b/health/probes.go @@ -0,0 +1,86 @@ +// Package health answers the liveness and readiness probes a deployment gates +// on. +package health + +import ( + "net/http" + "sync/atomic" +) + +// Probes reports whether a run still responds, and whether it has finished +// starting. +// +// The two answer different questions, and conflating them restarts a pod that +// is working. Liveness asks whether the process is still there. Readiness asks +// whether it has finished a startup that funds accounts, deploys contracts and +// prewarms, which takes minutes against a cold chain. A liveness probe that +// waited for all of that would kill the run before its first transaction, and +// kill the next attempt at the same point. +// +// So /healthz answers as soon as the server binds and never consults the +// startup sequence. Only /readyz gates on it. +type Probes struct { + state atomic.Pointer[state] +} + +// state is one consistent answer. The phase sits beside the flag in a single +// stored value, so a reader cannot pair a stale phase with a fresh flag. +type state struct { + ready bool + phase string +} + +// New returns probes reporting the given phase, not yet ready. +func New(phase string) *Probes { + probes := &Probes{} + probes.state.Store(&state{phase: phase}) + return probes +} + +// Enter records the phase a run is working through. It does not make the run +// ready. The phase is what /readyz reports while it is still refusing, so an +// operator watching a ten-minute startup reads the step rather than a bare 503. +func (p *Probes) Enter(phase string) { + p.state.Store(&state{phase: phase}) +} + +// Ready marks the run started and serving. +func (p *Probes) Ready() { + p.state.Store(&state{ready: true, phase: "running"}) +} + +// NotReady takes a run out of service without reporting it dead. A shutting-down +// run answers /healthz until its server stops, so the kubelet lets it finish +// rather than killing it as unresponsive. +func (p *Probes) NotReady(phase string) { + p.state.Store(&state{phase: phase}) +} + +// Register mounts both endpoints on mux. +func (p *Probes) Register(mux *http.ServeMux) { + mux.HandleFunc("/healthz", p.serveLive) + mux.HandleFunc("/readyz", p.serveReady) +} + +// serveLive answers for as long as the server runs. It reads no state on +// purpose: see the type's documentation. +func (p *Probes) serveLive(w http.ResponseWriter, _ *http.Request) { + writeText(w, http.StatusOK, "ok") +} + +func (p *Probes) serveReady(w http.ResponseWriter, _ *http.Request) { + current := p.state.Load() + if !current.ready { + writeText(w, http.StatusServiceUnavailable, current.phase) + return + } + writeText(w, http.StatusOK, current.phase) +} + +func writeText(w http.ResponseWriter, status int, body string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + // The probe reads the status line. A short body is for an operator running + // curl, so a write that fails changes nothing worth reporting. + _, _ = w.Write([]byte(body + "\n")) +} diff --git a/health/probes_test.go b/health/probes_test.go new file mode 100644 index 0000000..b1f17ed --- /dev/null +++ b/health/probes_test.go @@ -0,0 +1,156 @@ +package health_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/health" +) + +// get returns the status and body one endpoint answers with. +func get(t *testing.T, probes *health.Probes, path string) (int, string) { + t.Helper() + mux := http.NewServeMux() + probes.Register(mux) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + return recorder.Code, strings.TrimSpace(recorder.Body.String()) +} + +// TestLivenessDoesNotWaitForStartup guards the failure that makes these probes +// worse than none. Funding, deployment and prewarm take minutes against a cold +// chain. A liveness probe that reported the run dead for that whole window would +// restart the pod before it sent a transaction, and restart the next attempt at +// the same point, so the run never happens and the cause looks like a crash. +func TestLivenessDoesNotWaitForStartup(t *testing.T) { + probes := health.New("starting") + + for _, phase := range []string{"starting", "deploying contracts", "funding accounts", "prewarming accounts"} { + probes.Enter(phase) + status, _ := get(t, probes, "/healthz") + require.Equal(t, http.StatusOK, status, + "liveness failed during %q, so the kubelet restarts the pod mid-startup and the run never reaches its first transaction", phase) + } +} + +// TestReadinessWaitsForStartup is the other half: a run that has not deployed or +// funded anything must not be reported as serving. +func TestReadinessWaitsForStartup(t *testing.T) { + probes := health.New("starting") + + status, _ := get(t, probes, "/readyz") + require.Equal(t, http.StatusServiceUnavailable, status, + "readiness passed before the dispatcher started, so the probe cannot tell a run that is working from one still funding") + + probes.Ready() + status, _ = get(t, probes, "/readyz") + require.Equal(t, http.StatusOK, status, + "readiness still fails after the run started, so a startupProbe would exhaust its budget and kill a healthy run") +} + +// TestReadinessNamesThePhaseItIsWaitingOn keeps the body useful. A ten-minute +// startup that answers only "503" tells an operator nothing about which step is +// slow. +func TestReadinessNamesThePhaseItIsWaitingOn(t *testing.T) { + probes := health.New("starting") + probes.Enter("funding accounts") + + status, body := get(t, probes, "/readyz") + require.Equal(t, http.StatusServiceUnavailable, status) + require.Equal(t, "funding accounts", body, + "the body does not name the phase, so a slow startup reports no more than a bare failure") +} + +// TestShutdownLeavesServiceWithoutReportingDeath covers the window where a run +// holds the pod open for the post-summary scrape. Readiness must drop so nothing +// routes to it. Liveness must hold, or the kubelet reads the deliberate hold as +// a hang and kills the process before the scrape lands. +func TestShutdownLeavesServiceWithoutReportingDeath(t *testing.T) { + probes := health.New("starting") + probes.Ready() + probes.NotReady("shutting down") + + ready, body := get(t, probes, "/readyz") + require.Equal(t, http.StatusServiceUnavailable, ready, + "a shutting-down run still reports ready, so traffic routes to a process that is leaving") + require.Equal(t, "shutting down", body) + + live, _ := get(t, probes, "/healthz") + require.Equal(t, http.StatusOK, live, + "liveness failed during shutdown, so the kubelet kills the run before its final metrics are scraped") +} + +// TestAReadyStatusNeverCarriesAStartupPhase pins the reason the flag and the +// phase are stored as one value rather than as two atomics. Stored separately, +// a writer setting the flag and then the phase leaves a window where a reader +// sees the run serving while the body still names the step it was on. The +// status and the body would then disagree about the same instant, and an +// operator reading the body would act on a phase the run had left. +func TestAReadyStatusNeverCarriesAStartupPhase(t *testing.T) { + probes := health.New("starting") + phases := []string{"deploying contracts", "funding accounts", "prewarming accounts"} + + var group sync.WaitGroup + group.Add(2) + stop := make(chan struct{}) + go func() { + defer group.Done() + defer close(stop) + for i := 0; i < 20_000; i++ { + probes.Enter(phases[i%len(phases)]) + probes.Ready() + probes.NotReady("shutting down") + } + }() + go func() { + defer group.Done() + for { + select { + case <-stop: + return + default: + } + if status, body := get(t, probes, "/readyz"); status == http.StatusOK { + require.Equal(t, "running", body, + "a serving status carried the phase %q, so the flag and the phase are not stored as one value", body) + } + } + }() + group.Wait() +} + +// TestConcurrentPhasesAndProbesDoNotRace runs the real pairing: the run goroutine +// advances phases while the probe goroutine reads. Under -race this fails if the +// phase and the flag are ever stored separately. +func TestConcurrentPhasesAndProbesDoNotRace(t *testing.T) { + probes := health.New("starting") + phases := []string{"deploying contracts", "funding accounts", "prewarming accounts"} + + var group sync.WaitGroup + group.Add(2) + go func() { + defer group.Done() + for i := 0; i < 200; i++ { + probes.Enter(phases[i%len(phases)]) + } + probes.Ready() + }() + go func() { + defer group.Done() + for i := 0; i < 200; i++ { + status, body := get(t, probes, "/readyz") + // Never a ready status paired with a startup phase: the two are stored + // as one value, so a reader cannot see half of an update. + if status == http.StatusOK { + require.Equal(t, "running", body, + "a ready status carried a startup phase, so the flag and the phase are not stored together") + } + } + }() + group.Wait() +} diff --git a/main.go b/main.go index f22beb0..7779284 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/funder" "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/health" "github.com/sei-protocol/sei-load/observability" "github.com/sei-protocol/sei-load/sender" "github.com/sei-protocol/sei-load/stats" @@ -171,6 +172,10 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { listenAddr := cmd.Flag("metricsListenAddr").Value.String() log.Printf("serving metrics at %s/metrics", listenAddr) + // Built before the server so /readyz answers from the first scrape rather + // than from whenever the run reaches its first phase. + probes := health.New("starting") + obsShutdown, err := observability.Setup(ctx, observability.Config{ RunScope: observability.RunScopeFromEnv(), OTLPEndpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), @@ -189,6 +194,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // EnableOpenMetrics is load-bearing: the default promhttp.Handler() strips // exemplars regardless of the scraper's Accept header. mux := http.NewServeMux() + probes.Register(mux) mux.Handle("/metrics", promhttp.HandlerFor( prometheus.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}, @@ -237,6 +243,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { } // Create the generator from the config struct + probes.Enter("deploying contracts") gen, err := generator.NewGenerator(ctx, rng, cfg, deployer) if err != nil { return fmt.Errorf("failed to create generator: %w", err) @@ -328,6 +335,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { for _, a := range gen.Accounts() { addrs = append(addrs, a.Address) } + probes.Enter("funding accounts") if err := funder.FundAccounts(ctx, cfg, deployer, addrs); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } @@ -342,6 +350,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Set up prewarming if enabled if cfg.Settings.Prewarm { + probes.Enter("prewarming accounts") log.Printf("🔥 Creating prewarm generator...") if err := gen.Prewarm(ctx, rng, cfg, snd); err != nil { return fmt.Errorf("gen.Prewarm(): %w", err) @@ -357,6 +366,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { s.SpawnBgNamed("generator", func() error { return gen.Run(ctx, rng, snd) }) log.Printf("✅ Started dispatcher") + // Everything a run needs is up: contracts deployed, accounts funded and + // prewarmed, sender and dispatcher running. + probes.Ready() // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) @@ -385,6 +397,10 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return err } log.Print("\n🛑 Received shutdown signal, stopping gracefully...") + // Out of service, still alive. The run holds the pod open for the + // post-summary scrape window, and /healthz keeps answering through it so + // the kubelet does not read that hold as a hang. + probes.NotReady("shutting down") return nil }) // Print final statistics From 7ce00e6cc371a9e8f44241592ff1c17b54bb749e Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 17:42:27 -0700 Subject: [PATCH 4/7] feat(gas): ask the chain what a call costs instead of hard-coding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every contract scenario declared a gas limit as a constant. Those constants assume the EVM default of 20,000 for a storage write that takes a slot from zero to a value. Sei sets that as a governance parameter and its live networks charge 72,000, so every one of them is short on Sei by a factor. Measured against arctic-1: an AMM swap needs about 185,000 where the constant said 85,000, an ERC20 transfer 175,097 against 72,156, an ERC721 mint 174,782 against 75,000. A short limit does not fail visibly. The transaction reaches a block, burns the whole limit, and a run without receipt tracking reports it as sent. ERC20Noop was short by eight gas with no Sei parameter involved at all, which is the argument against hand-picked constants in one line. A scenario now declares GasEstimateCalls, one per operation it issues, and the preparation step asks the chain what each costs after the contracts are bound. ContractScenarioBase does not implement it, so a scenario added without one does not compile — the same gate that already forces Operation(). The priced call is the expensive shape. Cost is bimodal per account: the first transaction from an address writes slots holding zero. Pricing from a freshly generated address makes those slots cold by construction, so the measurement bounds what a run sends rather than describing its cheap case. The call carries no fee cap, because a call carrying one makes the node check the caller's balance and this caller has none; verified against arctic-1, where the same estimate succeeds without fee fields and fails with them. Calldata is recomposed rather than measured. GasModel keeps the execution term apart from the calldata term, so StorageRW reuses one measurement across every pad it draws. The recomposition calls the chain's own IntrinsicGas and FloorDataGas, so it is exact rather than fitted, and it covers the EIP-7623 floor that Sei's ante does not check. That deletes storageRWBaseGas, abiWord and calldataFloorGasPerByte along with the per-scenario constants. Pricing fails the run rather than falling back. A fallback is a cold branch that runs exactly when the estimate could not be trusted, and its failure is the invisible kind. Margin defaults to 1.20 and is a profile setting. 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. A profile of native transfers alone prices nothing and issues no extra call. Five mutations, five caught: an operation left unpriced, two operations priced against one method, the limit stopping coming from the model, a scenario declaring no calls at all, and the decomposition failing to round-trip. Co-Authored-By: Claude Opus 5 (1M context) --- config/config.go | 12 ++ config/settings.go | 21 +++ config/settings_test.go | 12 +- generator/gas.go | 152 +++++++++++++++++ generator/mockchain_test.go | 38 ++++- generator/prepare.go | 12 +- generator/scenarios/AMM.go | 52 ++---- generator/scenarios/AMM_test.go | 21 +-- generator/scenarios/Disperse.go | 21 ++- generator/scenarios/ERC20.go | 17 +- generator/scenarios/ERC20Conflict.go | 17 +- generator/scenarios/ERC20Noop.go | 17 +- generator/scenarios/ERC721.go | 39 +++-- generator/scenarios/ERC721_test.go | 55 ------ generator/scenarios/StorageRW.go | 88 +++++++--- generator/scenarios/StorageRW_test.go | 4 +- generator/scenarios/base.go | 114 +++++++++++++ generator/scenarios/doc.go | 61 ++++--- generator/scenarios/gasestimate.go | 98 +++++++++++ .../scenarios/gasestimate_internal_test.go | 161 ++++++++++++++++++ .../scenarios/gasestimate_test_helper_test.go | 47 +++++ 21 files changed, 870 insertions(+), 189 deletions(-) create mode 100644 generator/gas.go delete mode 100644 generator/scenarios/ERC721_test.go create mode 100644 generator/scenarios/gasestimate.go create mode 100644 generator/scenarios/gasestimate_internal_test.go create mode 100644 generator/scenarios/gasestimate_test_helper_test.go 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..8ad3cf3 --- /dev/null +++ b/generator/gas.go @@ -0,0 +1,152 @@ +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, + }) + 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 + } + if limit > blockGasLimit { + return scenarios.GasModel{}, fmt.Errorf( + "needs %d gas, past the chain's %d per block, so no limit admits 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) + } + 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(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(context.Background(), 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..b282741 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(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..2f8c619 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -55,11 +55,28 @@ func (s *DisperseScenario) SetContract(contract *bindings.Disperse) { s.contract = contract } +// disperseRecipients is how many accounts one disperse pays. The priced call and +// the sent call read the same constant, so they cannot drift apart. +const disperseRecipients = 100 + +// 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)}, + } +} + // 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) } 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..c03823e 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,28 @@ 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. +var gasProbeTokenID = new(big.Int).Lsh(big.NewInt(1), 255) + +// 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..956116c 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,89 @@ 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 +} + +// GasLimitForData returns the limit for one operation carrying data. A scenario +// whose calldata varies per transaction reads this, so one measurement covers +// every size it draws. +func (c *ContractScenarioBase[T]) GasLimitForData(operation string, data []byte) (uint64, error) { + model, ok := c.gasModels[operation] + if !ok { + return 0, fmt.Errorf("no measured gas limit for operation %q", operation) + } + return model.Limit(data) +} + +// 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) { + var widest GasModel + var found bool + for _, model := range c.gasModels { + if model.Exec > widest.Exec { + widest, found = model, true + } + } + if !found { + return 0, fmt.Errorf("no measured gas limits") + } + 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..f634a9b --- /dev/null +++ b/generator/scenarios/gasestimate.go @@ -0,0 +1,98 @@ +package scenarios + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + + "github.com/sei-protocol/sei-load/types" +) + +// 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 +} + +// 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. +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) + } + return max(uint64(float64(intrinsic+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. +// +// It draws from crypto/rand, like the account pool itself, so it consumes +// nothing from the run's PRNG. +func gasProbeAddress() common.Address { + return types.NewAccount(false).Address +} + +// 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..895061e --- /dev/null +++ b/generator/scenarios/gasestimate_internal_test.go @@ -0,0 +1,161 @@ +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") +} 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") +} From fa93aa7790917ed321837caa74aea31b7354ec2d Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 20:41:57 -0700 Subject: [PATCH 5/7] Drop the inline NotReady the merge kept alongside the deferred one The conflict resolution took main's deferred call but git had already auto-merged this branch's inline one from a region that did not conflict, so the signal path called NotReady twice. Harmless, and the opposite of what #72 did: it replaced the inline call precisely because it covered only that path. --- main.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/main.go b/main.go index bab1054..91d643d 100644 --- a/main.go +++ b/main.go @@ -404,10 +404,6 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return err } log.Print("\n🛑 Received shutdown signal, stopping gracefully...") - // Out of service, still alive. The run holds the pod open for the - // post-summary scrape window, and /healthz keeps answering through it so - // the kubelet does not read that hold as a hang. - probes.NotReady("shutting down") return nil }) // Print final statistics From 010bd0458f0415960a6e7b8d8cc79c0b500e307b Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 21:11:48 -0700 Subject: [PATCH 6/7] fix(gas): make a priced call cost at least what its transactions cost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-review found three things the first pass did not reach. All three are real and the first changes behaviour. A priced call has to bound the call the run sends, and for the four scenarios carrying an address it did not. GasLimitFor resolves against the probe's bytes, and calldata costs 16 gas for a non-zero byte against 4 for a zero one, so a probe address holding zero bytes prices a cheaper word than the address a transaction actually carries. Measured: 21,440 intrinsic against 21,560, a shortfall of 120. About one address in thirteen holds a zero byte, and the probe is minted once and held for the whole run, so it is a per-run coin flip rather than a per-transaction one. On the runs where it lands, nearly every transaction is short at a margin of 1, which Validate accepts. Both probe values are now non-zero in every byte, so the priced call is the more expensive one on calldata as well as on storage. The address is still one this run mints and never uses again, which is what makes its slots cold. The tell was in this package's own test helper. requireGasMatchesModel asserts the limit equals what the model derives from the transaction's own calldata, which is exactly the invariant at issue, and it was wired into the two scenarios that satisfy it. ERC721's test had been deleted rather than converted. There is now a test over every contract scenario asserting the priced call's intrinsic cost is at least the sent call's. The margin scaled the calldata intrinsic as well as execution, which both doc comments said it did not. It errs high, so it was not a correctness bug, but it declares gas no byte can consume and it lands hardest on the largest draws a size distribution produces: about thirty thousand at a 32 KiB pad. The margin now scales execution alone. 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 touches it nowhere, so nothing copies it into memory. A method taking bytes memory would pay memory expansion growing with the square of the length, none of it in an Exec measured at an empty pad. Said so on GasModel, along with why the floor comparison in Limit is not redundant. Three mutations, three caught. Two survived a first attempt, because the guards were weak rather than the fixes: the token-id case needed draws past 255, where an id first needs a second non-zero byte, and the margin case needed a margin above 1, where the two forms stop agreeing. A fixture of mine was also wrong — at a 32 KiB pad the EIP-7623 floor dominates, so the assertion was about the floor rather than the margin. Co-Authored-By: Claude Opus 5 (1M context) --- generator/scenarios/ERC721.go | 6 +- generator/scenarios/gasestimate.go | 38 ++++++- .../scenarios/gasestimate_internal_test.go | 100 ++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) diff --git a/generator/scenarios/ERC721.go b/generator/scenarios/ERC721.go index c03823e..e059a59 100644 --- a/generator/scenarios/ERC721.go +++ b/generator/scenarios/ERC721.go @@ -65,7 +65,11 @@ func (s *ERC721Scenario) SetContract(contract *bindings.ERC721) { // 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. -var gasProbeTokenID = new(big.Int).Lsh(big.NewInt(1), 255) +// 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 { diff --git a/generator/scenarios/gasestimate.go b/generator/scenarios/gasestimate.go index f634a9b..9cf3ac0 100644 --- a/generator/scenarios/gasestimate.go +++ b/generator/scenarios/gasestimate.go @@ -43,6 +43,18 @@ type GasEstimateCaller func(ctx context.Context, estimate GasEstimator) error // 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. @@ -64,7 +76,11 @@ func (m GasModel) Limit(data []byte) (uint64, error) { if err != nil { return 0, fmt.Errorf("calldata floor gas: %w", err) } - return max(uint64(float64(intrinsic+m.Exec)*m.Margin), floor), nil + // 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. @@ -76,10 +92,28 @@ func (m GasModel) Limit(data []byte) (uint64, error) { // 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. func gasProbeAddress() common.Address { - return types.NewAccount(false).Address + addr := types.NewAccount(false).Address + 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 diff --git a/generator/scenarios/gasestimate_internal_test.go b/generator/scenarios/gasestimate_internal_test.go index 895061e..04ac4eb 100644 --- a/generator/scenarios/gasestimate_internal_test.go +++ b/generator/scenarios/gasestimate_internal_test.go @@ -159,3 +159,103 @@ func TestTheModelNeverDeclaresLessThanTheCalldataFloor(t *testing.T) { 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") } + +// TestAPricedCallCostsAtLeastWhatItsTransactionsCost closes the seam between the +// calldata a scenario prices and the calldata it sends. +// +// A transaction pays 16 gas for a non-zero calldata byte and 4 for a zero one, +// so a probe carrying zero bytes where a real transaction carries non-zero ones +// prices a cheaper call than the run makes. GasLimitFor resolves against the +// probe's bytes, so the limit is then under what the transaction needs. The +// margin hides that at its default and not at 1, which Validate accepts. +// +// The probe address and token id are forced all-non-zero so this holds by +// construction rather than by luck. +func TestAPricedCallCostsAtLeastWhatItsTransactionsCost(t *testing.T) { + intrinsic := func(data []byte) uint64 { + gas, err := core.IntrinsicGas(data, nil, nil, false, true, true, true) + require.NoError(t, err) + return gas + } + + for name, factory := range scenarioFactories { + t.Run(name, func(t *testing.T) { + gen := factory(config.Scenario{Name: name}) + deployer, ok := gen.(interface { + GasEstimateCalls() []GasEstimateCall + }) + if !ok { + return + } + + priced := map[string]uint64{} + for _, call := range deployer.GasEstimateCalls() { + priced[call.Operation] = intrinsic(call.Data) + } + + 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, call GasEstimateCall) (GasModel, error) { + return GasModel{Exec: 200_000, Margin: 1}, nil + })) + + // Enough draws to pass 255, because ERC721 numbers its tokens from 1 and + // the probe only binds once an id needs a second non-zero byte. Also + // enough that the 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 { + scenario := &types.TxScenario{ + Name: name, + Nonce: uint64(i), + Sender: types.NewAccount(true), + Receiver: types.NewAccount(false).Address, + } + tx, err := gen.Generate(rng, scenario) + require.NoError(t, err) + + want, ok := priced[scenario.Operation] + if !ok { + want = priced[gen.Operation()] + } + require.GreaterOrEqual(t, want, intrinsic(tx.Data()), + "the priced call costs %d intrinsic gas and this transaction costs %d, "+ + "so the limit derived from the probe is under what the chain charges", + want, intrinsic(tx.Data())) + } + }) + } +} + +// 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)) +} From 511fc8f89171a791dbe8d78aede3573d93b40340 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 21:22:35 -0700 Subject: [PATCH 7/7] fix(gas): assert the limit a transaction carries, not the calldata behind it Re-review confirmed the three behaviour fixes and raised five smaller things. The one worth taking before merge was the shape of the new test. It compared calldata costs and threw the transaction's own limit away. Two different failures land there and only one is about calldata: a probe that prices a cheaper call than the run makes produces a short limit, and so does a scenario that prices correctly and then never reads the measurement back. The second is what Disperse did, and it has now been found twice by review rather than once by this suite. Asserting the limit catches both. It also fits a scenario whose calldata varies, which the calldata form did not. StorageRW recomposes against the bytes it is about to send, so its probe has no obligation to bound them, and it passed the old assertion only because the fixture left the size distribution unset. A default pad added later would have failed a scenario that was correct. The assertion caught Disperse immediately, wanting 258,072 against the 200,000 it declared. Rather than skip it, the Disperse fix comes down from #74: the priced call carries the value the contract requires, and the send path reads the measurement and sets that value too. That defect is in this diff, so it belongs in this commit. Also from the re-review: deleted GasLimitForData, which nothing called once the probe-maximal route was taken; keyed MaxGasLimitForData's presence check on the map rather than on a comparison, which reported a zero execution term as missing; named what the block-fit guard does not cover, since it evaluates the probe's bytes and a varying-calldata scenario can pass it and still exceed a block; gave mockGasLimits the caller's context; and stopped minting a secp256k1 key to produce twenty bytes that are only ever an ABI argument, which Disperse asked for a hundred of per call. Two mutations, two caught: a scenario that prices and never reads it back, and a probe address left holding zero bytes. Co-Authored-By: Claude Opus 5 (1M context) --- generator/gas.go | 21 ++++-- generator/prepare.go | 2 +- generator/scenarios/Disperse.go | 39 +++++++++- generator/scenarios/base.go | 23 ++---- generator/scenarios/gasestimate.go | 20 ++++- .../scenarios/gasestimate_internal_test.go | 75 +++++++++---------- 6 files changed, 110 insertions(+), 70 deletions(-) diff --git a/generator/gas.go b/generator/gas.go index 8ad3cf3..fbbf996 100644 --- a/generator/gas.go +++ b/generator/gas.go @@ -84,9 +84,10 @@ func gasEstimator(client *ethclient.Client, address common.Address, name string, // 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, + From: types.NewAccount(false).Address, + To: &address, + Data: call.Data, + Value: call.Value, }) if err != nil { return scenarios.GasModel{}, err @@ -106,9 +107,15 @@ func gasEstimator(client *ethclient.Client, address common.Address, name string, 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, past the chain's %d per block, so no limit admits it", limit, blockGasLimit) + "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 @@ -122,6 +129,8 @@ func blockGasLimit(ctx context.Context, client *ethclient.Client) (uint64, error 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 } @@ -132,7 +141,7 @@ 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(bindings []*binding) error { +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 { @@ -143,7 +152,7 @@ func (g *generatorBuilder) mockGasLimits(bindings []*binding) error { estimate := func(context.Context, scenarios.GasEstimateCall) (scenarios.GasModel, error) { return scenarios.GasModel{Exec: mockGasLimitExec, Margin: g.config.GetGasMargin()}, nil } - if err := price(context.Background(), estimate); err != nil { + if err := price(ctx, estimate); err != nil { return fmt.Errorf("price %s: %w", instance.Name, err) } } diff --git a/generator/prepare.go b/generator/prepare.go index b282741..4a0cac2 100644 --- a/generator/prepare.go +++ b/generator/prepare.go @@ -387,7 +387,7 @@ func (g *generatorBuilder) mockPrepareAll() error { if err := g.bindAll(nil, bindings); err != nil { return err } - return g.mockGasLimits(bindings) + return g.mockGasLimits(context.Background(), bindings) } // recordDeployments writes a chain file describing this chain, for an operator to diff --git a/generator/scenarios/Disperse.go b/generator/scenarios/Disperse.go index 2f8c619..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,9 +57,26 @@ func (s *DisperseScenario) SetContract(contract *bindings.Disperse) { s.contract = contract } -// disperseRecipients is how many accounts one disperse pays. The priced call and -// the sent call read the same constant, so they cannot drift apart. -const disperseRecipients = 100 +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 @@ -68,7 +87,11 @@ func (s *DisperseScenario) GasEstimateCalls() []GasEstimateCall { targets = append(targets, gasProbeAddress()) } return []GasEstimateCall{ - {Operation: config.OpDisperseEther, Data: mustPack(bindings.DisperseMetaData, "disperseEtherFixed", targets)}, + { + Operation: config.OpDisperseEther, + Data: mustPack(bindings.DisperseMetaData, "disperseEtherFixed", targets), + Value: disperseValue(), + }, } } @@ -79,5 +102,13 @@ func (s *DisperseScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind 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/base.go b/generator/scenarios/base.go index 956116c..aad7b9b 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -232,17 +232,6 @@ func (c *ContractScenarioBase[T]) GasLimitFor(operation string) (uint64, bool) { return limit, true } -// GasLimitForData returns the limit for one operation carrying data. A scenario -// whose calldata varies per transaction reads this, so one measurement covers -// every size it draws. -func (c *ContractScenarioBase[T]) GasLimitForData(operation string, data []byte) (uint64, error) { - model, ok := c.gasModels[operation] - if !ok { - return 0, fmt.Errorf("no measured gas limit for operation %q", operation) - } - return model.Limit(data) -} - // MaxGasLimitForData returns the limit for data under the most expensive // operation this scenario priced. // @@ -252,16 +241,18 @@ func (c *ContractScenarioBase[T]) GasLimitForData(operation string, data []byte) // 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 - var found bool for _, model := range c.gasModels { if model.Exec > widest.Exec { - widest, found = model, true + widest = model } } - if !found { - return 0, fmt.Errorf("no measured gas limits") - } return widest.Limit(data) } diff --git a/generator/scenarios/gasestimate.go b/generator/scenarios/gasestimate.go index 9cf3ac0..0170010 100644 --- a/generator/scenarios/gasestimate.go +++ b/generator/scenarios/gasestimate.go @@ -2,13 +2,13 @@ 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" - - "github.com/sei-protocol/sei-load/types" ) // GasEstimateCall is one call a scenario asks the chain to price before the run @@ -24,6 +24,12 @@ type GasEstimateCall struct { 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 @@ -105,9 +111,15 @@ func (m GasModel) Limit(data []byte) (uint64, error) { // cold. // // It draws from crypto/rand, like the account pool itself, so it consumes -// nothing from the run's PRNG. +// 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 { - addr := types.NewAccount(false).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 diff --git a/generator/scenarios/gasestimate_internal_test.go b/generator/scenarios/gasestimate_internal_test.go index 04ac4eb..019efd4 100644 --- a/generator/scenarios/gasestimate_internal_test.go +++ b/generator/scenarios/gasestimate_internal_test.go @@ -160,70 +160,67 @@ func TestTheModelNeverDeclaresLessThanTheCalldataFloor(t *testing.T) { "the limit is under the calldata floor, so the chain admits the transaction and then burns the whole limit in execution") } -// TestAPricedCallCostsAtLeastWhatItsTransactionsCost closes the seam between the -// calldata a scenario prices and the calldata it sends. +// TestEveryTransactionCarriesEnoughGasForItsOwnCalldata closes the seam between +// what a scenario prices and what it sends. // -// A transaction pays 16 gas for a non-zero calldata byte and 4 for a zero one, -// so a probe carrying zero bytes where a real transaction carries non-zero ones -// prices a cheaper call than the run makes. GasLimitFor resolves against the -// probe's bytes, so the limit is then under what the transaction needs. The -// margin hides that at its default and not at 1, which Validate accepts. +// 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. // -// The probe address and token id are forced all-non-zero so this holds by -// construction rather than by luck. -func TestAPricedCallCostsAtLeastWhatItsTransactionsCost(t *testing.T) { - intrinsic := func(data []byte) uint64 { - gas, err := core.IntrinsicGas(data, nil, nil, false, true, true, true) - require.NoError(t, err) - return gas - } +// 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}) - deployer, ok := gen.(interface { + if _, ok := gen.(interface { GasEstimateCalls() []GasEstimateCall - }) - if !ok { + }); !ok { return } - priced := map[string]uint64{} - for _, call := range deployer.GasEstimateCalls() { - priced[call.Operation] = intrinsic(call.Data) - } - 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, call GasEstimateCall) (GasModel, error) { - return GasModel{Exec: 200_000, Margin: 1}, nil + 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 - // the probe only binds once an id needs a second non-zero byte. Also - // enough that the drawn receivers vary in how many zero bytes they + // 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 { - scenario := &types.TxScenario{ + tx, err := gen.Generate(rng, &types.TxScenario{ Name: name, Nonce: uint64(i), Sender: types.NewAccount(true), Receiver: types.NewAccount(false).Address, - } - tx, err := gen.Generate(rng, scenario) + }) require.NoError(t, err) - want, ok := priced[scenario.Operation] - if !ok { - want = priced[gen.Operation()] - } - require.GreaterOrEqual(t, want, intrinsic(tx.Data()), - "the priced call costs %d intrinsic gas and this transaction costs %d, "+ - "so the limit derived from the probe is under what the chain charges", - want, intrinsic(tx.Data())) + 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) } }) }