diff --git a/config/config.go b/config/config.go index 72ba289..9370f7e 100644 --- a/config/config.go +++ b/config/config.go @@ -42,6 +42,14 @@ type LoadConfig struct { Funding *FundingConfig `json:"funding,omitempty"` // Path to write a JSON report of the load test. ReportPath string `json:"reportPath,omitempty"` + // gasFeeCapWei is the fee cap every transaction this run sends declares, + // resolved once from the chain at startup. + // + // It is deliberately not a profile field. A fee cap written down is a number + // that goes stale the next time the chain reprices, and a cap under the base + // fee is rejected before the transaction reaches the EVM. Sei's live networks + // have moved past two of the three caps this repo used to hard-code. + gasFeeCapWei *big.Int // Seed roots the PRNG behind every workload draw: key and size // distributions, gas pickers, operation mixes, and account selection. The // same seed and the same config reproduce the same draw sequence. @@ -159,6 +167,44 @@ func (c *LoadConfig) GetChainID() *big.Int { return big.NewInt(c.ChainID) } +// SetGasFeeCap records the cap resolved from the chain. The preparation step +// calls it once, before anything is signed. +func (c *LoadConfig) SetGasFeeCap(wei *big.Int) { + c.gasFeeCapWei = new(big.Int).Set(wei) +} + +// GetGasFeeCap returns the cap resolved from the chain, and whether one was +// resolved. +// +// It reports absence rather than a default, because a default is what the three +// hard-coded caps were. A caller that cannot proceed without one says so. +func (c *LoadConfig) GetGasFeeCap() (*big.Int, bool) { + if c.gasFeeCapWei == nil { + return nil, false + } + return new(big.Int).Set(c.gasFeeCapWei), true +} + +// GetGasFeeCapMultiplier returns what to scale the chain's reported gas price by. +func (c *LoadConfig) GetGasFeeCapMultiplier() float64 { + if c.Settings == nil || c.Settings.GasFeeCapMultiplier < 1 { + return DefaultSettings().GasFeeCapMultiplier + } + return c.Settings.GasFeeCapMultiplier +} + +// GetGasMargin returns the margin to apply to what the chain quotes for a call. +// +// It falls back to the default when a config carries no settings, which is how a +// config assembled in code rather than parsed from a profile arrives. A margin +// of zero would declare no gas at all. +func (c *LoadConfig) GetGasMargin() float64 { + if c.Settings == nil || c.Settings.GasMargin < 1 { + return DefaultSettings().GasMargin + } + return c.Settings.GasMargin +} + // AccountConfig stores the configuration for account generation. type AccountConfig struct { NewAccountRate float64 `json:"newAccountRate,omitempty"` diff --git a/config/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/config/settings.go b/config/settings.go index 0d47431..3f1614e 100644 --- a/config/settings.go +++ b/config/settings.go @@ -37,6 +37,34 @@ type Settings struct { // coordinated-omission fix), "closed_loop" (default) keeps the legacy // generate-then-send lockstep as the regression baseline. ArrivalModel string `json:"arrivalModel,omitempty"` + // GasMargin multiplies what the chain quotes for a call, to give the limit + // room the quote does not carry. + // + // It is a margin on execution, not on calldata: the calldata part is a closed + // form over the exact bytes on the wire and needs none. The quote itself + // already carries about 1.5%, because the node stops its search once the + // bracket is that tight. + // + // Erring high is close to free and erring low is not. Sei fills a block + // against two budgets: one charged at the declared limit and one charged at + // what the transaction spends, and the declared one binds only past four + // times the spend. Below that, margin costs no block space, only the balance + // each in-flight transaction locks. A limit under what a call needs, by + // contrast, lands in a block, burns the whole limit, and reports as sent. + GasMargin float64 `json:"gasMargin,omitempty"` + // GasFeeCapMultiplier scales what the chain reports gas costs into the fee + // cap every transaction declares. + // + // The cap is a ceiling, not a price: a transaction pays the base fee and the + // cap only says how high it will follow one. So a generous multiple costs + // nothing per transaction. What it does cost is balance, because the chain + // locks the cap times the gas limit while a transaction is in flight. + // + // It needs to be generous because the base fee moves. Sei raises it by up to + // about 1.9% per block while blocks are full, which is the state a load run + // is trying to produce, and a transaction whose cap the base fee has passed + // is rejected before it reaches the EVM. + GasFeeCapMultiplier float64 `json:"gasFeeCapMultiplier,omitempty"` // MaxInFlight bounds concurrent in-flight sends in the open-loop model; // txs that would exceed it at their scheduled instant are dropped and // counted rather than throttling the arrival clock. @@ -56,6 +84,12 @@ func (s Settings) Validate() error { if s.MaxInFlight <= 0 { return fmt.Errorf("MaxInFlight = %v, want > 0", s.MaxInFlight) } + if s.GasFeeCapMultiplier < 1 { + return fmt.Errorf("GasFeeCapMultiplier = %v, want >= 1: a cap under what the chain charges is rejected before the transaction reaches the EVM", s.GasFeeCapMultiplier) + } + if s.GasMargin < 1 { + return fmt.Errorf("GasMargin = %v, want >= 1: a margin below 1 declares less gas than the chain quoted, so every transaction burns its limit", s.GasMargin) + } return nil } @@ -80,6 +114,8 @@ func DefaultSettings() Settings { PostSummaryFlushDelay: Duration(25 * time.Second), ArrivalModel: ArrivalModelClosedLoop, MaxInFlight: 10_000, + GasMargin: 1.20, + GasFeeCapMultiplier: 5, } } @@ -133,6 +169,8 @@ func InitializeViper(cmd *cobra.Command) error { viper.SetDefault("postSummaryFlushDelay", defaults.PostSummaryFlushDelay.ToDuration()) viper.SetDefault("arrivalModel", defaults.ArrivalModel) viper.SetDefault("maxInFlight", defaults.MaxInFlight) + viper.SetDefault("gasMargin", defaults.GasMargin) + viper.SetDefault("gasFeeCapMultiplier", defaults.GasFeeCapMultiplier) return nil } @@ -177,5 +215,7 @@ func ResolveSettings() *Settings { PostSummaryFlushDelay: Duration(viper.GetDuration("postSummaryFlushDelay")), ArrivalModel: viper.GetString("arrivalModel"), MaxInFlight: viper.GetInt("maxInFlight"), + GasMargin: viper.GetFloat64("gasMargin"), + GasFeeCapMultiplier: viper.GetFloat64("gasFeeCapMultiplier"), } } diff --git a/config/settings_test.go b/config/settings_test.go index 16c28b9..81a7ae2 100644 --- a/config/settings_test.go +++ b/config/settings_test.go @@ -155,6 +155,8 @@ func TestDefaultSettings(t *testing.T) { PostSummaryFlushDelay: Duration(25 * time.Second), ArrivalModel: ArrivalModelClosedLoop, MaxInFlight: 10_000, + GasMargin: 1.20, + GasFeeCapMultiplier: 5, } if defaults != expected { @@ -170,7 +172,7 @@ func TestSettingsValidate(t *testing.T) { }{ { name: "positive max-in-flight is valid", - settings: Settings{MaxInFlight: 1}, + settings: Settings{MaxInFlight: 1, GasMargin: 1, GasFeeCapMultiplier: 1}, }, { name: "default settings are valid", @@ -178,14 +180,24 @@ func TestSettingsValidate(t *testing.T) { }, { name: "zero max-in-flight is rejected", - settings: Settings{MaxInFlight: 0}, + settings: Settings{MaxInFlight: 0, GasMargin: 1, GasFeeCapMultiplier: 1}, wantErr: "MaxInFlight = 0, want > 0", }, { name: "negative max-in-flight is rejected", - settings: Settings{MaxInFlight: -1}, + settings: Settings{MaxInFlight: -1, GasMargin: 1, GasFeeCapMultiplier: 1}, wantErr: "MaxInFlight = -1, want > 0", }, + { + name: "a fee cap multiplier below one is rejected", + settings: Settings{MaxInFlight: 1, GasMargin: 1, GasFeeCapMultiplier: 0.5}, + wantErr: "GasFeeCapMultiplier = 0.5, want >= 1", + }, + { + name: "a margin below one is rejected", + settings: Settings{MaxInFlight: 1, GasMargin: 0.9, GasFeeCapMultiplier: 1}, + wantErr: "GasMargin = 0.9, want >= 1", + }, } for _, tt := range tests { diff --git a/funder/funder.go b/funder/funder.go index f016e16..7c513ad 100644 --- a/funder/funder.go +++ b/funder/funder.go @@ -75,8 +75,12 @@ func FundAccounts(ctx context.Context, cfg *config.LoadConfig, root types.Accoun return fmt.Errorf("funder: transactor: %w", err) } auth.Context = ctx - auth.GasTipCap = big.NewInt(1_000_000_000) // 1 gwei (chain min fee) - auth.GasFeeCap = big.NewInt(100_000_000_000) // 100 gwei + feeCap, ok := cfg.GetGasFeeCap() + if !ok { + return fmt.Errorf("funder: no fee cap resolved from the chain") + } + auth.GasTipCap = big.NewInt(1_000_000_000) // 1 gwei (chain min fee) + auth.GasFeeCap = feeCap disperse, err := deployDisperse(ctx, client, auth) if err != nil { diff --git a/generator/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/fee.go b/generator/fee.go new file mode 100644 index 0000000..9db6834 --- /dev/null +++ b/generator/fee.go @@ -0,0 +1,62 @@ +package generator + +import ( + "context" + "fmt" + "log" + "math/big" + "time" + + "github.com/ethereum/go-ethereum/ethclient" + + loadutils "github.com/sei-protocol/sei-load/utils" +) + +// feeCapTimeout bounds the one call that resolves the fee cap. ethclient over +// HTTP sets no deadline of its own. +const feeCapTimeout = 30 * time.Second + +// resolveGasFeeCap asks the chain what gas costs and records the cap every +// transaction this run declares. +// +// It runs before anything is signed, because a contract deployment carries a cap +// too and a deployment priced under the base fee is rejected the same way a load +// transaction is. +// +// The alternative was a constant, and this repo carried three of them: 20 gwei +// for a contract call, 100 for a deployment, 200 for a native transfer. Sei's +// live base fee is 50, so one of the three was already rejecting every +// transaction it priced and the other two were guesses that happened to clear. +func (g *generatorBuilder) resolveGasFeeCap(ctx context.Context, client *ethclient.Client) error { + return loadutils.WithinBudget(ctx, feeCapTimeout, "fee cap", func(ctx context.Context) error { + suggested, err := client.SuggestGasPrice(ctx) + if err != nil { + return fmt.Errorf("ask the chain what gas costs: %w", err) + } + if suggested.Sign() <= 0 { + return fmt.Errorf("the chain reported a gas price of %s, so no cap can be derived from it", suggested) + } + cap := scaleWei(suggested, g.config.GetGasFeeCapMultiplier()) + g.config.SetGasFeeCap(cap) + log.Printf("⛽ gas price %s wei, fee cap %s wei (x%.1f)", suggested, cap, g.config.GetGasFeeCapMultiplier()) + return nil + }) +} + +// scaleWei multiplies a wei amount by a fractional factor without leaving the +// integer domain, so a large price cannot lose precision through float64. +func scaleWei(wei *big.Int, factor float64) *big.Int { + const scale = 1000 + num := big.NewInt(int64(factor * scale)) + out := new(big.Int).Mul(wei, num) + return out.Div(out, big.NewInt(scale)) +} + +// mockGasFeeCap records a placeholder cap for a run that reaches no chain. +func (g *generatorBuilder) mockGasFeeCap() { + g.config.SetGasFeeCap(big.NewInt(mockGasFeeCapWei)) +} + +// mockGasFeeCapWei is what a dry run declares. A dry run sends nothing, so this +// is a placeholder rather than a measurement. +const mockGasFeeCapWei = 100_000_000_000 diff --git a/generator/fee_internal_test.go b/generator/fee_internal_test.go new file mode 100644 index 0000000..ccdb982 --- /dev/null +++ b/generator/fee_internal_test.go @@ -0,0 +1,70 @@ +package generator + +import ( + "math" + "math/big" + "testing" + + "github.com/stretchr/testify/require" +) + +// seiBaseFeeGrowthPerBlock is the most Sei raises the base fee in one block +// while blocks are full, which is the state a load run exists to produce. +const seiBaseFeeGrowthPerBlock = 1.019 + +// blocksOfHeadroom returns how many consecutive full blocks the base fee can +// climb through before it passes cap. +func blocksOfHeadroom(cap *big.Int, baseFee int64) float64 { + ratio, _ := new(big.Float).Quo(new(big.Float).SetInt(cap), big.NewFloat(float64(baseFee))).Float64() + return math.Log(ratio) / math.Log(seiBaseFeeGrowthPerBlock) +} + +// TestTheFeeCapSurvivesBaseFeeDrift is the property the whole change exists for. +// +// A cap under the base fee is rejected by the fee ante before the transaction +// reaches the EVM, after the nonce is consumed, so it produces a failed receipt +// rather than no receipt at all. Clearing the base fee at the instant of the +// read is not enough: the run then fills blocks, which is what makes the base +// fee climb, so the cap has to clear it by enough to outlive the climb. +// +// The fixtures are what the three live networks reported: arctic-1 at 10 gwei +// base and 11 suggested, pacific-1 and atlantic-2 at 50 and 55. The constant +// this change removed declared 20 gwei, which cleared the first and not the +// other two. +func TestTheFeeCapSurvivesBaseFeeDrift(t *testing.T) { + // Enough blocks that a run notices the climb and can be restarted, rather + // than starting to fail seconds after it reaches full blocks. + const wantBlocks = 30 + + for _, tc := range []struct { + name string + baseFee int64 + suggested int64 + }{ + {"arctic-1", 10_000_000_000, 11_000_000_000}, + {"pacific-1", 50_000_000_000, 55_000_000_000}, + {"atlantic-2", 50_000_000_000, 55_000_000_000}, + } { + t.Run(tc.name, func(t *testing.T) { + cap := scaleWei(big.NewInt(tc.suggested), 5) + require.Positive(t, cap.Cmp(big.NewInt(tc.baseFee)), + "the cap is at or under the base fee, so the ante rejects every transaction before it runs") + require.GreaterOrEqual(t, blocksOfHeadroom(cap, tc.baseFee), float64(wantBlocks), + "the cap clears the base fee by only %.0f blocks of growth, so it lapses shortly after the run fills blocks", + blocksOfHeadroom(cap, tc.baseFee)) + }) + } +} + +// TestScalingKeepsPrecisionAtChainScale guards the arithmetic. A price is wei, +// which passes what a float64 holds exactly, so scaling through one would move +// the cap by an amount nothing else in the run would explain. +func TestScalingKeepsPrecisionAtChainScale(t *testing.T) { + huge, ok := new(big.Int).SetString("123456789012345678901234567890", 10) + require.True(t, ok) + + require.Equal(t, "246913578024691357802469135780", scaleWei(huge, 2).String(), + "doubling a chain-scale price did not double it, so the cap is derived through a lossy conversion") + require.Equal(t, "55000000000", scaleWei(big.NewInt(11_000_000_000), 5).String()) + require.Equal(t, "16500000000", scaleWei(big.NewInt(11_000_000_000), 1.5).String()) +} diff --git a/generator/fee_test.go b/generator/fee_test.go new file mode 100644 index 0000000..42d0c29 --- /dev/null +++ b/generator/fee_test.go @@ -0,0 +1,41 @@ +package generator_test + +import ( + "math/big" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// TestStartupResolvesTheFeeCapFromTheChain drives the real startup path against +// a chain and asserts the cap it came away with. +// +// The arithmetic has its own test. This one asserts startup actually applies it: +// a resolver that read the price and forgot to scale it would leave the run +// declaring what the chain charges right now, with no room for the base fee to +// climb once the run starts filling blocks. +func TestStartupResolvesTheFeeCapFromTheChain(t *testing.T) { + chain := newMockChain(t, mockChainConfig{}) + cfg := &config.LoadConfig{ + ChainID: 7777, + Endpoints: []string{chain.url}, + Accounts: &config.AccountConfig{Accounts: 2}, + Scenarios: []config.Scenario{{Name: scenarios.ERC20, Weight: 1}}, + Settings: &config.Settings{GasFeeCapMultiplier: 5, GasMargin: 1.2, MaxInFlight: 10}, + } + + _, err := generator.NewGenerator(t.Context(), newTestRng(1), cfg, types.NewAccount(false)) + require.NoError(t, err) + + cap, ok := cfg.GetGasFeeCap() + require.True(t, ok, "startup finished without resolving a fee cap, so every transaction is priced by nothing") + + want := new(big.Int).Mul(big.NewInt(mockGasPriceWei), big.NewInt(5)) + require.Equal(t, want.String(), cap.String(), + "the cap is not the chain's price scaled by the configured multiplier, so it carries no room for the base fee to climb") +} diff --git a/generator/gas.go b/generator/gas.go new file mode 100644 index 0000000..bf145e6 --- /dev/null +++ b/generator/gas.go @@ -0,0 +1,161 @@ +package generator + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" + loadutils "github.com/sei-protocol/sei-load/utils" +) + +// gasMeasureTimeout bounds the whole pricing step. ethclient over HTTP sets no +// deadline of its own, so an endpoint that accepts the connection and never +// answers would hold startup open with nothing logged. +const gasMeasureTimeout = 60 * time.Second + +// gasEstimateCallTimeout bounds one quote inside the collective budget, so a +// single endpoint that accepts a request and never answers cannot spend the +// whole step's ceiling and starve every scenario behind it. +const gasEstimateCallTimeout = 10 * time.Second + +// gasEstimateCap is the largest limit a node will estimate. Sei caps +// eth_estimateGas at its simulation gas limit, so a shape needing more than this +// cannot be priced at all and the error names the allowance rather than the +// shape. +const gasEstimateCap = 10_000_000 + +// measureGasLimits asks the chain what each scenario's calls cost, and stores +// the answer for the run. +// +// It runs after every contract is bound, because a call is priced against the +// deployment the run will actually drive. It runs before funding, because +// pricing needs no funded account: the estimate carries no fee cap, so the node +// does not check the caller's balance. +// +// A failure here stops the run. The alternative is a hard-coded limit, and a +// limit below what a call needs does not fail visibly: the transaction reaches a +// block, burns the whole limit, and is reported as sent. Refusing to start says +// so once, at startup, instead of publishing a throughput number that is a +// fabrication. +func (g *generatorBuilder) measureGasLimits(ctx context.Context, client *ethclient.Client, bindings []*binding) error { + type priced struct { + name string + address common.Address + price scenarios.GasEstimateCaller + } + var work []priced + for _, b := range bindings { + for _, instance := range b.instances { + if price := instance.Scenario.GasEstimateCaller(); price != nil { + work = append(work, priced{instance.Name, b.address, price}) + } + } + } + // A profile of native transfers alone prices nothing, so it reads no header + // and issues no estimate. Startup costs what the profile asks for. + if len(work) == 0 { + return nil + } + + return loadutils.WithinBudget(ctx, gasMeasureTimeout, "gas measurement", func(ctx context.Context) error { + blockGasLimit, err := blockGasLimit(ctx, client) + if err != nil { + return err + } + margin := g.config.GetGasMargin() + for _, w := range work { + estimate := gasEstimator(client, w.address, w.name, margin, blockGasLimit) + if err := w.price(ctx, estimate); err != nil { + return fmt.Errorf("price %s: %w", w.name, err) + } + } + return nil + }) +} + +// gasEstimator returns the estimator one scenario's calls are priced through. +func gasEstimator(client *ethclient.Client, address common.Address, name string, + margin float64, blockGasLimit uint64) scenarios.GasEstimator { + return func(ctx context.Context, call scenarios.GasEstimateCall) (scenarios.GasModel, error) { + ctx, cancel := context.WithTimeout(ctx, gasEstimateCallTimeout) + defer cancel() + + // The three fee fields stay unset. A call carrying one makes the node + // check the caller's balance, and this caller has none by design: it is a + // fresh address chosen so every slot the call writes is still zero. + required, err := client.EstimateGas(ctx, ethereum.CallMsg{ + From: types.NewAccount(false).Address, + To: &address, + Data: call.Data, + Value: call.Value, + }) + if err != nil { + return scenarios.GasModel{}, err + } + + intrinsic, err := core.IntrinsicGas(call.Data, nil, nil, false, true, true, true) + if err != nil { + return scenarios.GasModel{}, fmt.Errorf("intrinsic gas: %w", err) + } + if required <= intrinsic { + return scenarios.GasModel{}, fmt.Errorf( + "the chain quoted %d, at or under this call's own intrinsic cost of %d", required, intrinsic) + } + + model := scenarios.GasModel{Exec: required - intrinsic, Margin: margin} + limit, err := model.Limit(call.Data) + if err != nil { + return scenarios.GasModel{}, err + } + 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/generator_test.go b/generator/generator_test.go index ba47838..4c1544d 100644 --- a/generator/generator_test.go +++ b/generator/generator_test.go @@ -109,6 +109,9 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { }, }, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) rng := newTestRng(1) gen, err := generator.NewGenerator(t.Context(), rng, cfg, types.NewAccount(false)) @@ -147,9 +150,12 @@ func TestScenarioWeightsAndAccountDistribution(t *testing.T) { // scenario's name, and it once stamped IntendedSendTime, which defeated the // inclusion tracker's not-scheduled guard. func TestPrewarmLabelsEveryTransaction(t *testing.T) { + // A real chain, because startup asks it what gas costs before it signs + // anything, and a transfer declares a fee cap like every other transaction. + chain := newMockChain(t, mockChainConfig{}) cfg := &config.LoadConfig{ ChainID: 7777, - Endpoints: []string{"http://localhost:8545"}, + Endpoints: []string{chain.url}, Accounts: &config.AccountConfig{Accounts: 4}, Scenarios: []config.Scenario{{Name: scenarios.EVMTransfer, Weight: 1}}, } diff --git a/generator/mockchain_test.go b/generator/mockchain_test.go index 8e32a42..4f89167 100644 --- a/generator/mockchain_test.go +++ b/generator/mockchain_test.go @@ -151,8 +151,54 @@ 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 +} + +// mockGasPriceWei is what the chain reports gas costs. Fee-cap resolution reads +// it once at startup. +const mockGasPriceWei = 10_000_000_000 + +// GasPrice serves what gas costs, which the run scales into the fee cap every +// transaction declares. +func (m *mockChain) GasPrice(_ context.Context) (*hexutil.Big, error) { + return (*hexutil.Big)(big.NewInt(mockGasPriceWei)), nil +} + +// GetBlockByNumber serves a header carrying the block gas limit. Gas sizing +// reads it once at startup. +func (m *mockChain) GetBlockByNumber(_ context.Context, _ rpc.BlockNumber, _ bool) (map[string]any, error) { + return map[string]any{ + "number": hexutil.Uint64(1), + "hash": common.Hash{}, + "parentHash": common.Hash{}, + "sha3Uncles": common.Hash{}, + "stateRoot": common.Hash{}, + "transactionsRoot": common.Hash{}, + "receiptsRoot": common.Hash{}, + "logsBloom": hexutil.Bytes(make([]byte, 256)), + "difficulty": (*hexutil.Big)(big.NewInt(0)), + "gasLimit": hexutil.Uint64(mockBlockGasLimit), + "gasUsed": hexutil.Uint64(0), + "timestamp": hexutil.Uint64(1), + "extraData": hexutil.Bytes{}, + "miner": common.Address{}, + "nonce": ethtypes.BlockNonce{}, + "mixHash": common.Hash{}, + "size": hexutil.Uint64(0), + "totalDifficulty": (*hexutil.Big)(big.NewInt(0)), + "transactions": []common.Hash{}, + "uncles": []common.Hash{}, + }, nil } // txCount returns how many transactions the chain has accepted. diff --git a/generator/prepare.go b/generator/prepare.go index be747e6..294623f 100644 --- a/generator/prepare.go +++ b/generator/prepare.go @@ -107,6 +107,11 @@ func (g *generatorBuilder) prepareAll(ctx context.Context, deployer types.Accoun } defer client.Close() + // Before anything is signed: a deployment declares a fee cap too. + if err := g.resolveGasFeeCap(ctx, client); err != nil { + return err + } + bindings, err := g.planAll(ctx, reg, client) if err != nil { return err @@ -120,7 +125,12 @@ func (g *generatorBuilder) prepareAll(ctx context.Context, deployer types.Accoun if err := g.bindAll(client, bindings); err != nil { return err } - return g.recordDeployments(bindings, reg) + if err := g.recordDeployments(bindings, reg); err != nil { + return err + } + // Last, so a pricing failure does not discard the record of deployments this + // run already paid for. + return g.measureGasLimits(ctx, client, bindings) } // planAll decides one address per contract the profile drives. It deploys @@ -373,13 +383,17 @@ func (g *generatorBuilder) mockPrepareAll() error { if err != nil { return err } + g.mockGasFeeCap() if err := g.readyAll(); err != nil { return err } for _, b := range bindings { b.address = types.NewAccount(false).Address } - return g.bindAll(nil, bindings) + if err := g.bindAll(nil, bindings); err != nil { + return err + } + return g.mockGasLimits(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 new file mode 100644 index 0000000..0c6be2a --- /dev/null +++ b/generator/scenarios/AMM.go @@ -0,0 +1,116 @@ +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" + +// 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 +} + +// 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) { + // 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 + + 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) + 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..377c5be --- /dev/null +++ b/generator/scenarios/AMM_test.go @@ -0,0 +1,170 @@ +package scenarios_test + +import ( + "math/big" + 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" +) + +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"}, + } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) + gen := scenarios.CreateScenario(sc) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) + priceGasCalls(t, gen) + return gen, &types.TxScenario{ + Name: scenarios.AMM, + Nonce: 0, + 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 TestAMMGasComesFromTheMeasurement(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{}) + tx, err := gen.Generate(newTestRng(1), txs) + require.NoError(t, err) + requireGasMatchesModel(t, tx) +} + +// 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/Disperse.go b/generator/scenarios/Disperse.go index d29dcca..40bc5bb 100644 --- a/generator/scenarios/Disperse.go +++ b/generator/scenarios/Disperse.go @@ -1,6 +1,8 @@ package scenarios import ( + "fmt" + "math/big" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -55,12 +57,58 @@ func (s *DisperseScenario) SetContract(contract *bindings.Disperse) { s.contract = contract } +const ( + // disperseRecipients is how many accounts one disperse pays. The priced call + // and the sent call read the same constant, so they cannot drift apart. + disperseRecipients = 100 + // disperseFixedEtherAmountWei is what the contract pays each recipient, and + // what DeployContract constructs it with. disperseEtherFixed opens with + // require(msg.value == fixedEtherAmount * recipients.length), so every call + // has to carry exactly the product. + // + // A contract this run deployed holds this value by construction. One bound + // from a registry entry was deployed by something else and could hold + // another, which would revert every call; reading it back off the contract + // is the fix and it needs GasEstimateCalls to be able to report a failure. + disperseFixedEtherAmountWei = 1 +) + +// disperseValue is what one disperse must carry. +func disperseValue() *big.Int { + return big.NewInt(disperseFixedEtherAmountWei * disperseRecipients) +} + +// GasEstimateCalls prices one disperse to the same number of recipients the send +// path uses, all of them fresh, because each one the contract pays creates an +// account. +func (s *DisperseScenario) GasEstimateCalls() []GasEstimateCall { + targets := make([]common.Address, 0, disperseRecipients) + for range disperseRecipients { + targets = append(targets, gasProbeAddress()) + } + return []GasEstimateCall{ + { + Operation: config.OpDisperseEther, + Data: mustPack(bindings.DisperseMetaData, "disperseEtherFixed", targets), + Value: disperseValue(), + }, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates Disperse transaction func (s *DisperseScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { // create new accounts so that it auto-creates the accounts. - targets := make([]common.Address, 0, 100) - for range 100 { + targets := make([]common.Address, 0, disperseRecipients) + for range disperseRecipients { targets = append(targets, s.pool.NextAccount(rng).Address) } + limit, ok := s.GasLimitFor(config.OpDisperseEther) + if !ok { + return nil, fmt.Errorf("disperse: no measured gas limit") + } + auth.GasLimit = limit + // Without this the contract's own require rejects the call, so every disperse + // reverts on entry and burns whatever limit it declared. + auth.Value = disperseValue() return s.contract.DisperseEtherFixed(auth, targets) } diff --git a/generator/scenarios/ERC20.go b/generator/scenarios/ERC20.go index 2482fda..ba8f311 100644 --- a/generator/scenarios/ERC20.go +++ b/generator/scenarios/ERC20.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -54,8 +55,22 @@ func (s *ERC20Scenario) DeployContract(opts *bind.TransactOpts, client *ethclien return address, tx, err } +// GasEstimateCalls prices one transfer to a recipient that holds none of the +// token, which is what a run mostly sends: receivers are drawn from the pool and +// the sender's own balance oscillates, so the slot-from-zero write is the +// common shape rather than a warm-up. +func (s *ERC20Scenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC20Transfer, Data: mustPack(bindings.ERC20MetaData, "transfer", gasProbeAddress(), bigOne)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC20 transaction func (s *ERC20Scenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 72156 + limit, ok := s.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, fmt.Errorf("erc20: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC20Conflict.go b/generator/scenarios/ERC20Conflict.go index 99d5fea..9226e8b 100644 --- a/generator/scenarios/ERC20Conflict.go +++ b/generator/scenarios/ERC20Conflict.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -54,8 +55,22 @@ func (s *ERC20ConflictScenario) SetContract(contract *bindings.ERC20Conflict) { s.contract = contract } +// GasEstimateCalls prices one transfer to a recipient that holds none of the +// token, which is what a run mostly sends: receivers are drawn from the pool and +// the sender's own balance oscillates, so the slot-from-zero write is the +// common shape rather than a warm-up. +func (s *ERC20ConflictScenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC20Transfer, Data: mustPack(bindings.ERC20ConflictMetaData, "transfer", gasProbeAddress(), bigOne)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC20Conflict transaction func (s *ERC20ConflictScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 22460 + limit, ok := s.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, fmt.Errorf("erc20conflict: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC20Noop.go b/generator/scenarios/ERC20Noop.go index cd72612..fe5b1d7 100644 --- a/generator/scenarios/ERC20Noop.go +++ b/generator/scenarios/ERC20Noop.go @@ -1,6 +1,7 @@ package scenarios import ( + "fmt" mrand "math/rand/v2" "github.com/ethereum/go-ethereum/accounts/abi/bind" @@ -54,8 +55,22 @@ func (s *ERC20NoopScenario) SetContract(contract *bindings.ERC20Noop) { s.contract = contract } +// GasEstimateCalls prices one transfer to a recipient that holds none of the +// token, which is what a run mostly sends: receivers are drawn from the pool and +// the sender's own balance oscillates, so the slot-from-zero write is the +// common shape rather than a warm-up. +func (s *ERC20NoopScenario) GasEstimateCalls() []GasEstimateCall { + return []GasEstimateCall{ + {Operation: config.OpERC20Transfer, Data: mustPack(bindings.ERC20NoopMetaData, "transfer", gasProbeAddress(), bigOne)}, + } +} + // CreateContractTransaction implements ContractDeployer interface - creates ERC20Noop transaction func (s *ERC20NoopScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 22460 + limit, ok := s.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, fmt.Errorf("erc20noop: no measured gas limit") + } + auth.GasLimit = limit return s.contract.Transfer(auth, scenario.Receiver, bigOne) } diff --git a/generator/scenarios/ERC721.go b/generator/scenarios/ERC721.go index 764987a..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" @@ -57,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 = 22460 + 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/EVMTransfer.go b/generator/scenarios/EVMTransfer.go index 5700762..ad58096 100644 --- a/generator/scenarios/EVMTransfer.go +++ b/generator/scenarios/EVMTransfer.go @@ -2,6 +2,7 @@ package scenarios import ( "context" + "fmt" "math/big" mrand "math/rand/v2" "time" @@ -52,15 +53,20 @@ func (s *EVMTransferScenario) DeployScenario(ctx context.Context, config *config // CreateTransaction implements ScenarioDeployer interface - creates ETH transfer transaction func (s *EVMTransferScenario) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types2.TxScenario) (*ethtypes.Transaction, error) { + feeCap, ok := config.GetGasFeeCap() + if !ok { + return nil, fmt.Errorf("evmtransfer: no fee cap resolved from the chain") + } + // Create transaction with value transfer tx := ðtypes.DynamicFeeTx{ Nonce: scenario.Nonce, To: &scenario.Receiver, Value: big.NewInt(time.Now().Unix()), - Gas: 21000, // Standard gas limit for ETH transfer - GasTipCap: big.NewInt(2000000000), // 2 gwei - GasFeeCap: big.NewInt(200000000000), // 200 gwei - Data: nil, // No data for simple transfer + Gas: 21000, // Standard gas limit for ETH transfer + GasTipCap: big.NewInt(2000000000), // 2 gwei + GasFeeCap: feeCap, + Data: nil, // No data for simple transfer } if s.scenarioConfig.GasPicker != nil { diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index c6ddb06..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..556a6c1 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -43,6 +43,9 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { MockDeploy: true, Endpoints: []string{"http://localhost:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) @@ -50,6 +53,7 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { contractAddr := types.GenerateAccounts(1, false)[0].Address require.NoError(t, gen.Ready(cfg)) require.NoError(t, gen.Binder()(nil, contractAddr)) + priceGasCalls(t, gen) // Build the tx scenario the way the weighted generator does: a funded sender. sender := types.GenerateAccounts(1, true)[0] @@ -99,9 +103,13 @@ func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerat MockDeploy: true, Endpoints: []string{"http://localhost:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(sc) require.NoError(t, gen.Ready(cfg)) require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) + priceGasCalls(t, gen) return gen, &types.TxScenario{ Name: scenarios.StorageRW, Nonce: 0, @@ -246,7 +254,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) } @@ -386,6 +394,9 @@ func TestDeployTimeoutIsNotAContextSentinel(t *testing.T) { // the budget expires there rather than at dial. Endpoints: []string{"http://198.51.100.1:8545"}, } + // The preparation step resolves this from the chain; a test that builds a + // config by hand supplies it the same way. + cfg.SetGasFeeCap(big.NewInt(50_000_000_000)) gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) _, err := gen.Deploy(t.Context(), cfg, types.GenerateAccounts(1, true)[0]) diff --git a/generator/scenarios/base.go b/generator/scenarios/base.go index 071ef4e..58e967e 100644 --- a/generator/scenarios/base.go +++ b/generator/scenarios/base.go @@ -44,6 +44,11 @@ type TxGenerator interface { // scenario its contract, or nil for a scenario that drives none. The step // supplies the backend and the address, so no scenario opens a connection. Binder() ContractBinder + // GasEstimateCaller returns the hand-off a preparation step drives to price + // this scenario's calls against the chain, or nil for a scenario that drives + // no contract. A native transfer costs the protocol's own 21,000 whatever the + // chain charges for storage, so those scenarios have nothing to price. + GasEstimateCaller() GasEstimateCaller Deploy(ctx context.Context, config *config.LoadConfig, deployer types.Account) (common.Address, error) } @@ -87,6 +92,19 @@ type ContractDeployer[T any] interface { // CreateContractTransaction creates a contract interaction transaction CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) + + // GasEstimateCalls returns one call per operation this scenario issues, built + // the way CreateContractTransaction builds one, so the chain prices the same + // work the run will send. + // + // Build each from constants and never from a draw: pricing runs before the + // dispatcher, and a draw here would come from the run's single PRNG and shift + // every later one at the same seed. + // + // ContractScenarioBase does not implement this. Every contract scenario + // declares its own, so adding one without saying what to price does not + // compile, and no transaction is sent under a limit nobody measured. + GasEstimateCalls() []GasEstimateCall } // ScenarioBase holds no contract address. CDR-021 keeps an address out of a @@ -143,6 +161,13 @@ func (s *ScenarioBase) Generate(rng *mrand.Rand, scenario *types.TxScenario) (*e return s.deployer.CreateTransaction(rng, s.config, scenario) } +// GasEstimateCaller reports that this scenario prices nothing. A scenario +// without a contract sends a native transfer, whose 21,000 is a protocol +// constant rather than a chain parameter. +func (s *ScenarioBase) GasEstimateCaller() GasEstimateCaller { + return nil +} + // GetConfig returns the configuration func (s *ScenarioBase) GetConfig() *config.LoadConfig { return s.config @@ -152,6 +177,22 @@ func (s *ScenarioBase) GetConfig() *config.LoadConfig { type ContractScenarioBase[T any] struct { *ScenarioBase deployer ContractDeployer[T] + + // gasModels holds what the chain quoted for each operation, for a scenario + // whose calldata varies per transaction and has to recompose it. + // + // The preparation step writes it once, before the dispatcher goroutine + // exists, and the send path only reads it. That is the same lifecycle + // ScenarioBase.config has, so it needs no lock: starting the goroutine is + // the happens-before edge. + gasModels map[string]GasModel + // gasLimits holds the limit already resolved against each priced call's own + // calldata, so the send path reads a number rather than deriving one. + // + // Deriving it per transaction would mean rebuilding the priced call, and + // building one mints a fresh address. That is a keypair generated per + // transaction, on the path this whole change exists to keep free of work. + gasLimits map[string]uint64 } // NewContractScenarioBase creates a new base scenario with the given contract deployer @@ -161,6 +202,78 @@ func NewContractScenarioBase[T any](deployer ContractDeployer[T], cfg config.Sce return base } +// GasEstimateCaller prices every call this scenario declares and stores the +// result. A scenario that declares none fails here rather than sending +// transactions under a limit nobody measured. +func (c *ContractScenarioBase[T]) GasEstimateCaller() GasEstimateCaller { + return func(ctx context.Context, estimate GasEstimator) error { + calls := c.deployer.GasEstimateCalls() + if len(calls) == 0 { + return fmt.Errorf("declares no gas estimate calls") + } + models := make(map[string]GasModel, len(calls)) + limits := make(map[string]uint64, len(calls)) + for _, call := range calls { + model, err := estimate(ctx, call) + if err != nil { + return fmt.Errorf("operation %q: %w", call.Operation, err) + } + limit, err := model.Limit(call.Data) + if err != nil { + return fmt.Errorf("operation %q: %w", call.Operation, err) + } + models[call.Operation] = model + limits[call.Operation] = limit + } + c.gasModels, c.gasLimits = models, limits + return nil + } +} + +// GasLimitFor returns the limit measured for one operation, and whether one was +// measured. A scenario whose calldata is the same every time reads this. +// +// It reports absence rather than returning zero, because bind reads a zero +// GasLimit as "estimate this one", which would put an eth_estimateGas on the +// send path against a backend that may be nil. +func (c *ContractScenarioBase[T]) GasLimitFor(operation string) (uint64, bool) { + limit, ok := c.gasLimits[operation] + return limit, ok +} + +// 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) +} + func dial(config *config.LoadConfig) (*ethclient.Client, error) { if len(config.Endpoints) == 0 { return ethclient.NewClient(nil), nil @@ -209,7 +322,11 @@ func (c *ContractScenarioBase[T]) deployWithin(ctx context.Context, config *conf return common.Address{}, fmt.Errorf("dial: %w", err) } - auth, err := utils.CreateDeploymentOpts(ctx, config.GetChainID(), deployer) + feeCap, ok := config.GetGasFeeCap() + if !ok { + return common.Address{}, fmt.Errorf("no fee cap resolved from the chain") + } + auth, err := utils.CreateDeploymentOpts(ctx, config.GetChainID(), feeCap, deployer) if err != nil { return common.Address{}, fmt.Errorf("deployment options for %s: %w", deployer.Address.Hex(), err) } @@ -315,6 +432,10 @@ func fetchTransactionErrorByHash(ctx context.Context, client *ethclient.Client, // CreateTransaction implements ScenarioDeployer interface for contract scenarios func (c *ContractScenarioBase[T]) CreateTransaction(rng *mrand.Rand, config *config.LoadConfig, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth := utils.CreateTransactionOpts(config.GetChainID(), scenario) + feeCap, ok := config.GetGasFeeCap() + if !ok { + return nil, fmt.Errorf("no fee cap resolved from the chain") + } + auth := utils.CreateTransactionOpts(config.GetChainID(), feeCap, scenario) return c.deployer.CreateContractTransaction(rng, auth, scenario) } diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index e4e4771..e41e2fb 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -95,27 +95,42 @@ // empty and there is no warm-up phase — the same threshold, showing up in gas // rather than in contention. // -// Gas sizing. All three operations share one base GasLimit of 50k. The measured -// worst case is a read that first writes readAccumulator, at 46,269 including -// intrinsic cost; rmw and write cold-first-touch sit near 44k. So 50k clears all -// three, but by only ~3.7k — and SSTORE_SET is a Sei governance parameter -// (SeiSstoreSetGasEip2200, default 20,000), so a raise past roughly 23.7k would -// put read out of gas. Widening the keyspace also makes cold first touches the -// normal case rather than the exception, which is the regime this headroom has to -// survive. -// -// One limit for all three trades slack on the cheaper operations for a single -// number to reason about. Density is why the number is tight at all: it packs -// roughly 4x denser than the 200k default in CreateTransactionOpts, and on a -// gas-limit-admission chain a block admits transactions up to their declared -// limit regardless of gas actually used, so an oversized limit reserves block -// space the transaction never spends and throttles achievable throughput. -// -// The drawn pad is charged at 10 gas per on-wire byte on top of the base. That is -// the EIP-7623 floor rate, which is live on Sei, and it is the binding cost above -// roughly 4.6 KiB of pad. Sei's ante checks only the intrinsic cost, so a limit -// sized to the older 4-gas rate is admitted, reserves its full limit, then fails -// in execution with GasUsed equal to the limit — an included failure that -// inflates the gas-used metric the run reports. An empty pad leaves the limit at -// exactly 50k. +// Gas sizing. A scenario does not declare a gas limit. It declares the calls it +// issues, and the run asks the chain what each costs before it sends any of +// them. GasEstimateCalls is where a scenario says what to price, and adding a +// scenario without one does not compile. +// +// A constant cannot be right on more than one chain. SSTORE_SET is a Sei +// governance parameter: the EVM default is 20,000 and Sei's live networks charge +// 72,000, so a limit calibrated against one is short by a factor on the other. A +// limit that is short does not fail visibly. The transaction reaches a block, +// burns the whole limit, and a run without receipt tracking reports it as sent. +// Every constant this package used to carry was wrong on Sei, one of them by +// eight gas and one of them by a factor of two. +// +// The priced call is the expensive shape. Cost is bimodal per account: the first +// transaction from an address writes slots that hold zero, and a write from zero +// costs several times one that changes a value already there. Pricing from a +// freshly generated address makes every such slot cold by construction, so the +// measurement bounds what a run will send rather than describing its cheap case. +// The call carries no fee cap, because a call that carries one makes the node +// check the caller's balance, and this caller has none. +// +// Calldata is recomposed, not measured. GasModel keeps what the chain quoted for +// execution separately from what it charged for the priced call's own bytes, so +// a scenario whose calldata varies reuses one measurement across every size it +// draws. StorageRW is that scenario. The recomposition runs the same two +// computations the chain runs, so it is exact rather than fitted, and it covers +// the EIP-7623 floor, which is live on Sei and which Sei's ante does not check. +// +// StorageRW takes the largest of its three priced calls. read's expensive shape +// needs its target slot already written and its accumulator still zero, which a +// single call against an untouched slot cannot produce. write and rmw both carry +// the slot-from-zero cost that dominates it, so the largest covers read to within +// one cold read of its own peak. +// +// Margin is small on purpose. Sei fills a block against two budgets, one charged +// at the declared limit and one at what the transaction spends, and the declared +// one binds only past four times the spend. Below that, margin costs no block +// space, only the balance each in-flight transaction locks. package scenarios diff --git a/generator/scenarios/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/gasestimate.go b/generator/scenarios/gasestimate.go new file mode 100644 index 0000000..5ba131b --- /dev/null +++ b/generator/scenarios/gasestimate.go @@ -0,0 +1,105 @@ +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" + "math/big" + + "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 + // Value is what the call must carry, for a method that checks msg.value + // before it does anything. Nil means none. + // + // A method that rejects the wrong value rejects a priced call carrying none, + // so without this the estimate reports a revert and the run refuses to start. + Value *big.Int +} + +// GasEstimator prices one call against the chain and returns the model to hold +// for the run. The preparation step supplies it, so a scenario neither dials nor +// decides how much headroom to carry. +type GasEstimator func(ctx context.Context, call GasEstimateCall) (GasModel, error) + +// GasEstimateCaller is the hand-off a preparation step drives to price a +// scenario's calls and store the results, or nil for a scenario that drives no +// contract. +type GasEstimateCaller func(ctx context.Context, estimate GasEstimator) error + +// GasModel is what one call cost, with the calldata part taken back out. +// +// Splitting it that way is what lets a scenario whose calldata varies reuse one +// measurement. The chain charges the execution and the calldata separately, so +// recomposing them against the bytes a transaction actually carries reproduces +// what that transaction needs, rather than approximating it with a per-byte +// constant. +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..6647e4b --- /dev/null +++ b/generator/scenarios/gasestimate_internal_test.go @@ -0,0 +1,240 @@ +package scenarios + +import ( + "bytes" + "context" + "fmt" + 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") +} + +// TestNoResolvedFeeCapRefusesToGenerate covers the other half of the fail-closed +// posture. A run that never asked the chain what gas costs has no cap to +// declare, and the constant it used to fall back to was rejected outright on two +// of Sei's three live networks. +func TestNoResolvedFeeCapRefusesToGenerate(t *testing.T) { + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + // Deliberately no SetGasFeeCap. + gen := CreateScenario(config.Scenario{Name: AMM}) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.NewAccount(false).Address)) + + _, err := gen.Generate(mrand.New(mrand.NewPCG(1, 2)), &types.TxScenario{ + Name: AMM, + Sender: types.NewAccount(true), + }) + require.ErrorContains(t, err, "fee cap", + "a scenario generated a transaction with no cap resolved from the chain, so it would be priced by a constant again") +} + +// countingDeployer records how often its priced calls are built. +type countingDeployer struct { + *ContractScenarioBase[struct{}] + built int +} + +func (d *countingDeployer) GasEstimateCalls() []GasEstimateCall { + d.built++ + return []GasEstimateCall{{Operation: config.OpERC20Transfer, Data: []byte{0xa9, 0x05, 0x9c, 0xbb}}} +} + +func (d *countingDeployer) DeployContract(*bind.TransactOpts, *ethclient.Client) (common.Address, *ethtypes.Transaction, error) { + return common.Address{}, nil, nil +} +func (d *countingDeployer) GetBindFunc() ContractBindFunc[struct{}] { return nil } +func (d *countingDeployer) SetContract(*struct{}) {} +func (d *countingDeployer) CreateContractTransaction(_ *mrand.Rand, auth *bind.TransactOpts, _ *types.TxScenario) (*ethtypes.Transaction, error) { + limit, ok := d.GasLimitFor(config.OpERC20Transfer) + if !ok { + return nil, errNoLimit + } + auth.GasLimit = limit + return nil, nil +} + +var errNoLimit = fmt.Errorf("no measured gas limit") + +// TestTheSendPathNeverRebuildsAPricedCall pins where the priced call is built. +// +// Building one mints a fresh address, which is a secp256k1 keypair. Deriving the +// limit from the call rather than storing it puts that keygen on the send path +// of a load generator, once per transaction, which is the work this whole change +// exists to keep off it. +func TestTheSendPathNeverRebuildsAPricedCall(t *testing.T) { + scenario := &countingDeployer{} + scenario.ContractScenarioBase = NewContractScenarioBase[struct{}](scenario, config.Scenario{Name: "counting"}) + + require.NoError(t, scenario.GasEstimateCaller()(t.Context(), + func(context.Context, GasEstimateCall) (GasModel, error) { + return GasModel{Exec: 100_000, Margin: 1.2}, nil + })) + afterPricing := scenario.built + + for range 50 { + auth := &bind.TransactOpts{} + _, err := scenario.CreateContractTransaction(nil, auth, nil) + require.NoError(t, err) + require.NotZero(t, auth.GasLimit) + } + + require.Equal(t, afterPricing, scenario.built, + "the send path rebuilt the priced call %d times over 50 transactions, so it mints a keypair per transaction", + scenario.built-afterPricing) +} diff --git a/generator/scenarios/gasestimate_test_helper_test.go b/generator/scenarios/gasestimate_test_helper_test.go new file mode 100644 index 0000000..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") +} 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) + } }) } } diff --git a/generator/utils/utils.go b/generator/utils/utils.go index ac0e811..14359fe 100644 --- a/generator/utils/utils.go +++ b/generator/utils/utils.go @@ -30,19 +30,16 @@ const ( // txGasLimit is the default per-transaction limit; a scenario that knows its // own cost overrides it. txGasLimit = 200_000 - // gasTipCapWei is the priority fee (2 gwei). + // gasTipCapWei is the priority fee (2 gwei). It is a tip rather than a + // ceiling, so unlike the fee cap it does not have to track what the chain + // charges; a transaction is admitted on its cap. gasTipCapWei = 2_000_000_000 - // gasFeeCapWei is the max fee, base plus priority (20 gwei). - gasFeeCapWei = 20_000_000_000 - // deployGasFeeCapWei is the max fee for a contract creation (100 gwei), - // matching the funding path because both sign from the same key. - deployGasFeeCapWei = 100_000_000_000 ) // CreateDeploymentOpts returns the options for a contract deployment signed by // account. The transaction is sent live, so ctx bounds the send and the nonce // fetch behind it. -func CreateDeploymentOpts(ctx context.Context, chainID *big.Int, account loadtypes.Account) (*bind.TransactOpts, error) { +func CreateDeploymentOpts(ctx context.Context, chainID *big.Int, feeCap *big.Int, account loadtypes.Account) (*bind.TransactOpts, error) { auth, err := bind.NewKeyedTransactorWithChainID(account.PrivKey, chainID) if err != nil { return nil, err @@ -51,19 +48,19 @@ func CreateDeploymentOpts(ctx context.Context, chainID *big.Int, account loadtyp auth.GasLimit = deployGasLimit auth.GasTipCap = big.NewInt(gasTipCapWei) // A deploy is the first transaction on the deployer's nonce stream, and when - // funding is configured that stream belongs to the root key. Pricing it at - // the load-transaction cap would put the stream's weakest-priced transaction - // at its head, so a base fee above that cap blocks every later root - // transaction until someone replaces the nonce by hand. Match the funding - // cap instead — the same key, the same exposure, one number. - auth.GasFeeCap = big.NewInt(deployGasFeeCapWei) + // funding is configured that stream belongs to the root key. A cap the base + // fee has passed puts the stream's weakest-priced transaction at its head and + // blocks every later root transaction until someone replaces the nonce by + // hand. Every path takes the one cap the run resolved from the chain, so no + // two of them can drift apart. + auth.GasFeeCap = new(big.Int).Set(feeCap) return auth, nil } // CreateTransactionOpts returns the options for one load transaction against a // contract. NoSend keeps the transaction in hand for the sender, and the signer // hands it back unsigned: the sender signs it at send time. -func CreateTransactionOpts(chainID *big.Int, scenario *loadtypes.TxScenario) *bind.TransactOpts { +func CreateTransactionOpts(chainID *big.Int, feeCap *big.Int, scenario *loadtypes.TxScenario) *bind.TransactOpts { auth, err := bind.NewKeyedTransactorWithChainID(scenario.Sender.PrivKey, chainID) if err != nil { panic("Failed to create transaction options: " + err.Error()) @@ -72,7 +69,7 @@ func CreateTransactionOpts(chainID *big.Int, scenario *loadtypes.TxScenario) *bi auth.NoSend = true auth.GasLimit = txGasLimit auth.GasTipCap = big.NewInt(gasTipCapWei) - auth.GasFeeCap = big.NewInt(gasFeeCapWei) + auth.GasFeeCap = new(big.Int).Set(feeCap) auth.Signer = func(address common.Address, tx *ethtypes.Transaction) (*ethtypes.Transaction, error) { if address != scenario.Sender.Address { return nil, bind.ErrNotAuthorized diff --git a/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