From 33d158410548298bb1b66bf7e93b4083cc6ad5f1 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 27 Aug 2026 20:25:16 -0700 Subject: [PATCH 1/4] fix(scenarios): size the ERC721 mint limit from a measurement The scenario declared 22460 gas for a mint. Measured against the deployed binding, a mint to a receiver holding none of the token needs 69319, and one to a receiver that already holds some needs 51757. Every mint the scenario sent landed in a block with a failed status, having burned the whole limit, and trackReceipts defaults to false so the run reported each one as sent. 22460 is ERC20Noop's constant, copied. PLT-1091 covers the two scenarios that still carry it. The limit is now 75000, and the test pins it against the measurement rather than against itself. Broke the constant back to 22460 and to 200000 on purpose; the test caught both. Co-Authored-By: Claude Opus 5 (1M context) --- generator/scenarios/ERC721.go | 18 +++++++++- generator/scenarios/ERC721_test.go | 55 ++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 generator/scenarios/ERC721_test.go diff --git a/generator/scenarios/ERC721.go b/generator/scenarios/ERC721.go index 764987a..7390879 100644 --- a/generator/scenarios/ERC721.go +++ b/generator/scenarios/ERC721.go @@ -47,6 +47,22 @@ func (s *ERC721Scenario) DeployContract(opts *bind.TransactOpts, client *ethclie return address, tx, err } +// erc721MintGas bounds one mint. +// +// Measured against the deployed binding as a required gas limit, which is what +// eth_estimateGas returns and what a transaction has to carry before its refund +// lands at the end of execution. A receipt's GasUsed is the post-refund charge +// and runs lower, so it is the wrong number to size from. +// +// 69,319 to a receiver holding none of the token, and 51,757 to one that already +// holds some. A run draws its receivers from the account pool, so most mints pay +// the higher shape and the limit covers it. +// +// This constant read 22460 until it was measured. At that value every mint +// landed in a block with a failed status and burned the whole limit, and a run +// with trackReceipts off reported each one as a success. +const erc721MintGas = 75_000 + // GetBindFunc implements ContractDeployer interface - returns the binding function func (s *ERC721Scenario) GetBindFunc() ContractBindFunc[bindings.ERC721] { return bindings.NewERC721 @@ -59,6 +75,6 @@ func (s *ERC721Scenario) SetContract(contract *bindings.ERC721) { // CreateContractTransaction implements ContractDeployer interface - creates ERC721 transaction func (s *ERC721Scenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { - auth.GasLimit = 22460 + auth.GasLimit = erc721MintGas return s.contract.Mint(auth, scenario.Receiver, big.NewInt(atomic.AddInt64(&s.id, 1))) } diff --git a/generator/scenarios/ERC721_test.go b/generator/scenarios/ERC721_test.go new file mode 100644 index 0000000..e008372 --- /dev/null +++ b/generator/scenarios/ERC721_test.go @@ -0,0 +1,55 @@ +package scenarios_test + +import ( + mrand "math/rand/v2" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// erc721ColdMintGas is the gas limit one mint required when measured against +// the deployed binding, sending to a receiver that holds none of the token. A +// receiver that already holds some needed 51,757. A run draws its receivers +// from the account pool, so the higher shape is the common one. +// +// It is a required limit read from eth_estimateGas, not a receipt's GasUsed, +// because a transaction is provisioned for the peak before its refund lands. +// +// It is written down so the limit is checked against a measurement rather than +// against itself. An assertion comparing the limit to its own constant passes +// at any value. +const erc721ColdMintGas = 69_319 + +// TestERC721GasCoversAMeasuredMint guards the failure this constant shipped +// with: a limit under what a mint needs still reaches a block, with a failed +// status, having burned the whole limit. A run with trackReceipts off counts +// that as a success, so no other test in this package can see it. +func TestERC721GasCoversAMeasuredMint(t *testing.T) { + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.ERC721}) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) + + tx, err := gen.Generate(mrand.New(mrand.NewPCG(1, 2)), &types.TxScenario{ + Name: scenarios.ERC721, + Nonce: 0, + Sender: types.GenerateAccounts(1, true)[0], + Receiver: types.GenerateAccounts(1, false)[0].Address, + }) + require.NoError(t, err) + + require.GreaterOrEqual(t, tx.Gas(), uint64(erc721ColdMintGas), + "a mint needs %d gas and the limit is %d, so every mint lands with a failed status and burns the limit while the run reports it as sent", + erc721ColdMintGas, tx.Gas()) + require.LessOrEqual(t, tx.Gas(), uint64(erc721ColdMintGas*13/10), + "the limit is %d against a measured %d, so every mint reserves block space nothing spends", + tx.Gas(), erc721ColdMintGas) +} From 6c7e41d6400c83321c94e6c7c37196d3480b7466 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 27 Aug 2026 20:25:48 -0700 Subject: [PATCH 2/4] feat(scenarios): add an AMM swap scenario A DeFi profile had no contract to drive. This adds a constant-product pair with the storage and gas shape of a UniswapV2 swap: both reserves, the caller's balance in each token, and an event. The contract never reverts on bookkeeping, which is the choice StorageRWv1 already makes. The balances wrap rather than check, because nothing reads them back and a load generator that fails on its own accounting stops measuring the chain. A short caller is not credited: crediting exactly what is then debited returns the slot to zero, and a zero to non-zero storage write costs four times one that changes a slot already holding a value. Under the default mix, which draws one direction, that write would land on every swap rather than the first. The reserves sit between a floor and a ceiling. Without the ceiling the input side grows without bound and the output halves every 100000 swaps, so a long run prices nothing like its start. The ceiling is also what keeps one oversized call from ending the pair: a swap of 1e49 leaves the input reserve at 1e49, and the contract has no owner and no reset. Measured, the next ordinary swap instead resets that side to the floor and pays out in full. The gas limit is 85000, read from eth_estimateGas rather than from a receipt. GasUsed is the post-refund charge and a transaction carries the pre-refund peak; sizing from a receipt put an earlier draft 20% under what its own swap needed. An account's first swap needs 79988 and every later one needs 45177, so a run in steady state declares about 44% more gas than it spends. PLT-1093 carries the prewarm change that would close that. PLT-1092 carries the chain-parameter exposure, which is the whole package rather than this constant. Every guard here was broken on purpose before it was believed. Co-Authored-By: Claude Opus 5 (1M context) --- config/operation.go | 23 + generator/bindings/AMM.go | 586 ++++++++++++++++++ generator/contracts/AMM.sol | 118 ++++ generator/scenarios/AMM.go | 136 ++++ generator/scenarios/AMM_test.go | 181 ++++++ generator/scenarios/factory.go | 3 +- .../scenarios/operation_internal_test.go | 12 + 7 files changed, 1058 insertions(+), 1 deletion(-) create mode 100644 generator/bindings/AMM.go create mode 100644 generator/contracts/AMM.sol create mode 100644 generator/scenarios/AMM.go create mode 100644 generator/scenarios/AMM_test.go diff --git a/config/operation.go b/config/operation.go index eeb3d8a..7f2629c 100644 --- a/config/operation.go +++ b/config/operation.go @@ -38,15 +38,38 @@ const ( OpERC721Mint = "erc721_mint" // OpDisperseEther is Disperse disperseEtherFixed(address[]). OpDisperseEther = "disperse_ether" + // OpSwapAToB is AMM swapAToB(uint256), the A-for-B leg of a pair. + OpSwapAToB = "swap_a_to_b" + // OpSwapBToA is AMM swapBToA(uint256), the B-for-A leg. + OpSwapBToA = "swap_b_to_a" ) // StorageRWOperations is the operation set the storagerw scenario draws from. var StorageRWOperations = NewOperationSet(OpRmw, OpRead, OpWrite) +// AMMOperations is the operation set the amm scenario draws from: both legs of +// one pair, so a profile can weight the direction. A profile that sets no mix +// draws the first name for every transaction, which is one direction — the +// contract holds its reserves between a floor and a ceiling, so that run prices +// the same at its end as at its start. +var AMMOperations = NewOperationSet(OpSwapAToB, OpSwapBToA) + // scenarioOperations maps a scenario's wire name, lowercased, to the operations // it supports. A scenario absent from the table supports none. var scenarioOperations = map[string]*OperationSet{ "storagerw": StorageRWOperations, + "amm": AMMOperations, +} + +// OperationNamesFor returns every operation a scenario can draw, or nil if it +// draws none. It is exported so a test can check the whole vocabulary a +// scenario reaches the metrics with, rather than only its default. +func OperationNamesFor(scenario string) []string { + set := operationsFor(scenario) + if set == nil { + return nil + } + return set.Names() } // operationsFor returns the operations a scenario supports, or nil if it diff --git a/generator/bindings/AMM.go b/generator/bindings/AMM.go new file mode 100644 index 0000000..2d01591 --- /dev/null +++ b/generator/bindings/AMM.go @@ -0,0 +1,586 @@ +// Code generated - DO NOT EDIT. +// This file is a generated binding and any manual changes will be lost. + +package bindings + +import ( + "errors" + "math/big" + "strings" + + ethereum "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/accounts/abi" + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/event" +) + +// Reference imports to suppress errors if they are not otherwise used. +var ( + _ = errors.New + _ = big.NewInt + _ = strings.NewReader + _ = ethereum.NotFound + _ = bind.Bind + _ = common.Big1 + _ = types.BloomLookup + _ = event.NewSubscription + _ = abi.ConvertType +) + +// AMMMetaData contains all meta data concerning the AMM contract. +var AMMMetaData = &bind.MetaData{ + ABI: "[{\"inputs\":[],\"stateMutability\":\"nonpayable\",\"type\":\"constructor\"},{\"anonymous\":false,\"inputs\":[{\"indexed\":true,\"internalType\":\"address\",\"name\":\"sender\",\"type\":\"address\"},{\"indexed\":true,\"internalType\":\"bool\",\"name\":\"aToB\",\"type\":\"bool\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amountIn\",\"type\":\"uint256\"},{\"indexed\":false,\"internalType\":\"uint256\",\"name\":\"amountOut\",\"type\":\"uint256\"}],\"name\":\"Swap\",\"type\":\"event\"},{\"inputs\":[],\"name\":\"RESERVE_CEIL\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"RESERVE_FLOOR\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOfA\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"address\",\"name\":\"account\",\"type\":\"address\"}],\"name\":\"balanceOfB\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reserveA\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[],\"name\":\"reserveB\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"view\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"amountIn\",\"type\":\"uint256\"}],\"name\":\"swapAToB\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"},{\"inputs\":[{\"internalType\":\"uint256\",\"name\":\"amountIn\",\"type\":\"uint256\"}],\"name\":\"swapBToA\",\"outputs\":[{\"internalType\":\"uint256\",\"name\":\"\",\"type\":\"uint256\"}],\"stateMutability\":\"nonpayable\",\"type\":\"function\"}]", + Bin: "0x608060405234801561001057600080fd5b5069d3c21bcecceda10000006000819055600155610405806100336000396000f3fe608060405234801561001057600080fd5b50600436106100885760003560e01c806338720f721161005b57806338720f72146100ea57806385949788146100fd578063dc5fa6c514610110578063ddc4838d1461011957600080fd5b80630563e8671461008d57806319e36f3b146100b057806325d33d2f146100b95780632a6622e6146100c1575b600080fd5b61009e69d3c21bcecceda100000081565b60405190815260200160405180910390f35b61009e60015481565b61009e610142565b61009e6100cf36600461033a565b6001600160a01b031660009081526003602052604090205490565b61009e6100f836600461036a565b61015a565b61009e61010b36600461036a565b61016d565b61009e60005481565b61009e61012736600461033a565b6001600160a01b031660009081526002602052604090205490565b61015769d3c21bcecceda10000006002610399565b81565b6000610167600183610176565b92915050565b60006101676000835b600080836101865760015461018a565b6000545b905060008461019b5760005461019f565b6001545b905069d3c21bcecceda10000008210806101cc57506101c969d3c21bcecceda10000006002610399565b82115b156101df5769d3c21bcecceda100000091505b69d3c21bcecceda100000081108061020a575061020769d3c21bcecceda10000006002610399565b81115b1561021c575069d3c21bcecceda10000005b600061022a856103e5610399565b905060006102388383610399565b9050600082610249866103e8610399565b61025391906103b0565b9050600061026182846103c3565b9050600089610271576003610274565b60025b905060008a610284576002610287565b60035b3360009081526020848152604080832080548f9003905590839052902080548501905590508a156102d1576102bc8a896103b0565b6000556102c983886103e5565b6001556102ec565b6102db8a896103b0565b6001556102e883886103e5565b6000555b604080518b8152602081018590528c15159133917fbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd910160405180910390a350909998505050505050505050565b60006020828403121561034c57600080fd5b81356001600160a01b038116811461036357600080fd5b9392505050565b60006020828403121561037c57600080fd5b5035919050565b634e487b7160e01b600052601160045260246000fd5b808202811582820484141761016757610167610383565b8082018082111561016757610167610383565b6000826103e057634e487b7160e01b600052601260045260246000fd5b500490565b818103818111156101675761016761038356fea164736f6c6343000813000a", +} + +// AMMABI is the input ABI used to generate the binding from. +// Deprecated: Use AMMMetaData.ABI instead. +var AMMABI = AMMMetaData.ABI + +// AMMBin is the compiled bytecode used for deploying new contracts. +// Deprecated: Use AMMMetaData.Bin instead. +var AMMBin = AMMMetaData.Bin + +// DeployAMM deploys a new Ethereum contract, binding an instance of AMM to it. +func DeployAMM(auth *bind.TransactOpts, backend bind.ContractBackend) (common.Address, *types.Transaction, *AMM, error) { + parsed, err := AMMMetaData.GetAbi() + if err != nil { + return common.Address{}, nil, nil, err + } + if parsed == nil { + return common.Address{}, nil, nil, errors.New("GetABI returned nil") + } + + address, tx, contract, err := bind.DeployContract(auth, *parsed, common.FromHex(AMMBin), backend) + if err != nil { + return common.Address{}, nil, nil, err + } + return address, tx, &AMM{AMMCaller: AMMCaller{contract: contract}, AMMTransactor: AMMTransactor{contract: contract}, AMMFilterer: AMMFilterer{contract: contract}}, nil +} + +// AMM is an auto generated Go binding around an Ethereum contract. +type AMM struct { + AMMCaller // Read-only binding to the contract + AMMTransactor // Write-only binding to the contract + AMMFilterer // Log filterer for contract events +} + +// AMMCaller is an auto generated read-only Go binding around an Ethereum contract. +type AMMCaller struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AMMTransactor is an auto generated write-only Go binding around an Ethereum contract. +type AMMTransactor struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AMMFilterer is an auto generated log filtering Go binding around an Ethereum contract events. +type AMMFilterer struct { + contract *bind.BoundContract // Generic contract wrapper for the low level calls +} + +// AMMSession is an auto generated Go binding around an Ethereum contract, +// with pre-set call and transact options. +type AMMSession struct { + Contract *AMM // Generic contract binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// AMMCallerSession is an auto generated read-only Go binding around an Ethereum contract, +// with pre-set call options. +type AMMCallerSession struct { + Contract *AMMCaller // Generic contract caller binding to set the session for + CallOpts bind.CallOpts // Call options to use throughout this session +} + +// AMMTransactorSession is an auto generated write-only Go binding around an Ethereum contract, +// with pre-set transact options. +type AMMTransactorSession struct { + Contract *AMMTransactor // Generic contract transactor binding to set the session for + TransactOpts bind.TransactOpts // Transaction auth options to use throughout this session +} + +// AMMRaw is an auto generated low-level Go binding around an Ethereum contract. +type AMMRaw struct { + Contract *AMM // Generic contract binding to access the raw methods on +} + +// AMMCallerRaw is an auto generated low-level read-only Go binding around an Ethereum contract. +type AMMCallerRaw struct { + Contract *AMMCaller // Generic read-only contract binding to access the raw methods on +} + +// AMMTransactorRaw is an auto generated low-level write-only Go binding around an Ethereum contract. +type AMMTransactorRaw struct { + Contract *AMMTransactor // Generic write-only contract binding to access the raw methods on +} + +// NewAMM creates a new instance of AMM, bound to a specific deployed contract. +func NewAMM(address common.Address, backend bind.ContractBackend) (*AMM, error) { + contract, err := bindAMM(address, backend, backend, backend) + if err != nil { + return nil, err + } + return &AMM{AMMCaller: AMMCaller{contract: contract}, AMMTransactor: AMMTransactor{contract: contract}, AMMFilterer: AMMFilterer{contract: contract}}, nil +} + +// NewAMMCaller creates a new read-only instance of AMM, bound to a specific deployed contract. +func NewAMMCaller(address common.Address, caller bind.ContractCaller) (*AMMCaller, error) { + contract, err := bindAMM(address, caller, nil, nil) + if err != nil { + return nil, err + } + return &AMMCaller{contract: contract}, nil +} + +// NewAMMTransactor creates a new write-only instance of AMM, bound to a specific deployed contract. +func NewAMMTransactor(address common.Address, transactor bind.ContractTransactor) (*AMMTransactor, error) { + contract, err := bindAMM(address, nil, transactor, nil) + if err != nil { + return nil, err + } + return &AMMTransactor{contract: contract}, nil +} + +// NewAMMFilterer creates a new log filterer instance of AMM, bound to a specific deployed contract. +func NewAMMFilterer(address common.Address, filterer bind.ContractFilterer) (*AMMFilterer, error) { + contract, err := bindAMM(address, nil, nil, filterer) + if err != nil { + return nil, err + } + return &AMMFilterer{contract: contract}, nil +} + +// bindAMM binds a generic wrapper to an already deployed contract. +func bindAMM(address common.Address, caller bind.ContractCaller, transactor bind.ContractTransactor, filterer bind.ContractFilterer) (*bind.BoundContract, error) { + parsed, err := AMMMetaData.GetAbi() + if err != nil { + return nil, err + } + return bind.NewBoundContract(address, *parsed, caller, transactor, filterer), nil +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_AMM *AMMRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _AMM.Contract.AMMCaller.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_AMM *AMMRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _AMM.Contract.AMMTransactor.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_AMM *AMMRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _AMM.Contract.AMMTransactor.contract.Transact(opts, method, params...) +} + +// Call invokes the (constant) contract method with params as input values and +// sets the output to result. The result type might be a single field for simple +// returns, a slice of interfaces for anonymous returns and a struct for named +// returns. +func (_AMM *AMMCallerRaw) Call(opts *bind.CallOpts, result *[]interface{}, method string, params ...interface{}) error { + return _AMM.Contract.contract.Call(opts, result, method, params...) +} + +// Transfer initiates a plain transaction to move funds to the contract, calling +// its default method if one is available. +func (_AMM *AMMTransactorRaw) Transfer(opts *bind.TransactOpts) (*types.Transaction, error) { + return _AMM.Contract.contract.Transfer(opts) +} + +// Transact invokes the (paid) contract method with params as input values. +func (_AMM *AMMTransactorRaw) Transact(opts *bind.TransactOpts, method string, params ...interface{}) (*types.Transaction, error) { + return _AMM.Contract.contract.Transact(opts, method, params...) +} + +// RESERVECEIL is a free data retrieval call binding the contract method 0x25d33d2f. +// +// Solidity: function RESERVE_CEIL() view returns(uint256) +func (_AMM *AMMCaller) RESERVECEIL(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "RESERVE_CEIL") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// RESERVECEIL is a free data retrieval call binding the contract method 0x25d33d2f. +// +// Solidity: function RESERVE_CEIL() view returns(uint256) +func (_AMM *AMMSession) RESERVECEIL() (*big.Int, error) { + return _AMM.Contract.RESERVECEIL(&_AMM.CallOpts) +} + +// RESERVECEIL is a free data retrieval call binding the contract method 0x25d33d2f. +// +// Solidity: function RESERVE_CEIL() view returns(uint256) +func (_AMM *AMMCallerSession) RESERVECEIL() (*big.Int, error) { + return _AMM.Contract.RESERVECEIL(&_AMM.CallOpts) +} + +// RESERVEFLOOR is a free data retrieval call binding the contract method 0x0563e867. +// +// Solidity: function RESERVE_FLOOR() view returns(uint256) +func (_AMM *AMMCaller) RESERVEFLOOR(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "RESERVE_FLOOR") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// RESERVEFLOOR is a free data retrieval call binding the contract method 0x0563e867. +// +// Solidity: function RESERVE_FLOOR() view returns(uint256) +func (_AMM *AMMSession) RESERVEFLOOR() (*big.Int, error) { + return _AMM.Contract.RESERVEFLOOR(&_AMM.CallOpts) +} + +// RESERVEFLOOR is a free data retrieval call binding the contract method 0x0563e867. +// +// Solidity: function RESERVE_FLOOR() view returns(uint256) +func (_AMM *AMMCallerSession) RESERVEFLOOR() (*big.Int, error) { + return _AMM.Contract.RESERVEFLOOR(&_AMM.CallOpts) +} + +// BalanceOfA is a free data retrieval call binding the contract method 0xddc4838d. +// +// Solidity: function balanceOfA(address account) view returns(uint256) +func (_AMM *AMMCaller) BalanceOfA(opts *bind.CallOpts, account common.Address) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "balanceOfA", account) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOfA is a free data retrieval call binding the contract method 0xddc4838d. +// +// Solidity: function balanceOfA(address account) view returns(uint256) +func (_AMM *AMMSession) BalanceOfA(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfA(&_AMM.CallOpts, account) +} + +// BalanceOfA is a free data retrieval call binding the contract method 0xddc4838d. +// +// Solidity: function balanceOfA(address account) view returns(uint256) +func (_AMM *AMMCallerSession) BalanceOfA(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfA(&_AMM.CallOpts, account) +} + +// BalanceOfB is a free data retrieval call binding the contract method 0x2a6622e6. +// +// Solidity: function balanceOfB(address account) view returns(uint256) +func (_AMM *AMMCaller) BalanceOfB(opts *bind.CallOpts, account common.Address) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "balanceOfB", account) + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// BalanceOfB is a free data retrieval call binding the contract method 0x2a6622e6. +// +// Solidity: function balanceOfB(address account) view returns(uint256) +func (_AMM *AMMSession) BalanceOfB(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfB(&_AMM.CallOpts, account) +} + +// BalanceOfB is a free data retrieval call binding the contract method 0x2a6622e6. +// +// Solidity: function balanceOfB(address account) view returns(uint256) +func (_AMM *AMMCallerSession) BalanceOfB(account common.Address) (*big.Int, error) { + return _AMM.Contract.BalanceOfB(&_AMM.CallOpts, account) +} + +// ReserveA is a free data retrieval call binding the contract method 0xdc5fa6c5. +// +// Solidity: function reserveA() view returns(uint256) +func (_AMM *AMMCaller) ReserveA(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "reserveA") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ReserveA is a free data retrieval call binding the contract method 0xdc5fa6c5. +// +// Solidity: function reserveA() view returns(uint256) +func (_AMM *AMMSession) ReserveA() (*big.Int, error) { + return _AMM.Contract.ReserveA(&_AMM.CallOpts) +} + +// ReserveA is a free data retrieval call binding the contract method 0xdc5fa6c5. +// +// Solidity: function reserveA() view returns(uint256) +func (_AMM *AMMCallerSession) ReserveA() (*big.Int, error) { + return _AMM.Contract.ReserveA(&_AMM.CallOpts) +} + +// ReserveB is a free data retrieval call binding the contract method 0x19e36f3b. +// +// Solidity: function reserveB() view returns(uint256) +func (_AMM *AMMCaller) ReserveB(opts *bind.CallOpts) (*big.Int, error) { + var out []interface{} + err := _AMM.contract.Call(opts, &out, "reserveB") + + if err != nil { + return *new(*big.Int), err + } + + out0 := *abi.ConvertType(out[0], new(*big.Int)).(**big.Int) + + return out0, err + +} + +// ReserveB is a free data retrieval call binding the contract method 0x19e36f3b. +// +// Solidity: function reserveB() view returns(uint256) +func (_AMM *AMMSession) ReserveB() (*big.Int, error) { + return _AMM.Contract.ReserveB(&_AMM.CallOpts) +} + +// ReserveB is a free data retrieval call binding the contract method 0x19e36f3b. +// +// Solidity: function reserveB() view returns(uint256) +func (_AMM *AMMCallerSession) ReserveB() (*big.Int, error) { + return _AMM.Contract.ReserveB(&_AMM.CallOpts) +} + +// SwapAToB is a paid mutator transaction binding the contract method 0x38720f72. +// +// Solidity: function swapAToB(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactor) SwapAToB(opts *bind.TransactOpts, amountIn *big.Int) (*types.Transaction, error) { + return _AMM.contract.Transact(opts, "swapAToB", amountIn) +} + +// SwapAToB is a paid mutator transaction binding the contract method 0x38720f72. +// +// Solidity: function swapAToB(uint256 amountIn) returns(uint256) +func (_AMM *AMMSession) SwapAToB(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapAToB(&_AMM.TransactOpts, amountIn) +} + +// SwapAToB is a paid mutator transaction binding the contract method 0x38720f72. +// +// Solidity: function swapAToB(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactorSession) SwapAToB(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapAToB(&_AMM.TransactOpts, amountIn) +} + +// SwapBToA is a paid mutator transaction binding the contract method 0x85949788. +// +// Solidity: function swapBToA(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactor) SwapBToA(opts *bind.TransactOpts, amountIn *big.Int) (*types.Transaction, error) { + return _AMM.contract.Transact(opts, "swapBToA", amountIn) +} + +// SwapBToA is a paid mutator transaction binding the contract method 0x85949788. +// +// Solidity: function swapBToA(uint256 amountIn) returns(uint256) +func (_AMM *AMMSession) SwapBToA(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapBToA(&_AMM.TransactOpts, amountIn) +} + +// SwapBToA is a paid mutator transaction binding the contract method 0x85949788. +// +// Solidity: function swapBToA(uint256 amountIn) returns(uint256) +func (_AMM *AMMTransactorSession) SwapBToA(amountIn *big.Int) (*types.Transaction, error) { + return _AMM.Contract.SwapBToA(&_AMM.TransactOpts, amountIn) +} + +// AMMSwapIterator is returned from FilterSwap and is used to iterate over the raw logs and unpacked data for Swap events raised by the AMM contract. +type AMMSwapIterator struct { + Event *AMMSwap // Event containing the contract specifics and raw log + + contract *bind.BoundContract // Generic contract to use for unpacking event data + event string // Event name to use for unpacking event data + + logs chan types.Log // Log channel receiving the found contract events + sub ethereum.Subscription // Subscription for errors, completion and termination + done bool // Whether the subscription completed delivering logs + fail error // Occurred error to stop iteration +} + +// Next advances the iterator to the subsequent event, returning whether there +// are any more events found. In case of a retrieval or parsing error, false is +// returned and Error() can be queried for the exact failure. +func (it *AMMSwapIterator) Next() bool { + // If the iterator failed, stop iterating + if it.fail != nil { + return false + } + // If the iterator completed, deliver directly whatever's available + if it.done { + select { + case log := <-it.logs: + it.Event = new(AMMSwap) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + default: + return false + } + } + // Iterator still in progress, wait for either a data or an error event + select { + case log := <-it.logs: + it.Event = new(AMMSwap) + if err := it.contract.UnpackLog(it.Event, it.event, log); err != nil { + it.fail = err + return false + } + it.Event.Raw = log + return true + + case err := <-it.sub.Err(): + it.done = true + it.fail = err + return it.Next() + } +} + +// Error returns any retrieval or parsing error occurred during filtering. +func (it *AMMSwapIterator) Error() error { + return it.fail +} + +// Close terminates the iteration process, releasing any pending underlying +// resources. +func (it *AMMSwapIterator) Close() error { + it.sub.Unsubscribe() + return nil +} + +// AMMSwap represents a Swap event raised by the AMM contract. +type AMMSwap struct { + Sender common.Address + AToB bool + AmountIn *big.Int + AmountOut *big.Int + Raw types.Log // Blockchain specific contextual infos +} + +// FilterSwap is a free log retrieval operation binding the contract event 0xbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd. +// +// Solidity: event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut) +func (_AMM *AMMFilterer) FilterSwap(opts *bind.FilterOpts, sender []common.Address, aToB []bool) (*AMMSwapIterator, error) { + + var senderRule []interface{} + for _, senderItem := range sender { + senderRule = append(senderRule, senderItem) + } + var aToBRule []interface{} + for _, aToBItem := range aToB { + aToBRule = append(aToBRule, aToBItem) + } + + logs, sub, err := _AMM.contract.FilterLogs(opts, "Swap", senderRule, aToBRule) + if err != nil { + return nil, err + } + return &AMMSwapIterator{contract: _AMM.contract, event: "Swap", logs: logs, sub: sub}, nil +} + +// WatchSwap is a free log subscription operation binding the contract event 0xbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd. +// +// Solidity: event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut) +func (_AMM *AMMFilterer) WatchSwap(opts *bind.WatchOpts, sink chan<- *AMMSwap, sender []common.Address, aToB []bool) (event.Subscription, error) { + + var senderRule []interface{} + for _, senderItem := range sender { + senderRule = append(senderRule, senderItem) + } + var aToBRule []interface{} + for _, aToBItem := range aToB { + aToBRule = append(aToBRule, aToBItem) + } + + logs, sub, err := _AMM.contract.WatchLogs(opts, "Swap", senderRule, aToBRule) + if err != nil { + return nil, err + } + return event.NewSubscription(func(quit <-chan struct{}) error { + defer sub.Unsubscribe() + for { + select { + case log := <-logs: + // New log arrived, parse the event and forward to the user + event := new(AMMSwap) + if err := _AMM.contract.UnpackLog(event, "Swap", log); err != nil { + return err + } + event.Raw = log + + select { + case sink <- event: + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + case err := <-sub.Err(): + return err + case <-quit: + return nil + } + } + }), nil +} + +// ParseSwap is a log parse operation binding the contract event 0xbfd50a04f1e6e4aee344f5d0e7f15d74d0dbb58cd1f711daa6463094ca9508cd. +// +// Solidity: event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut) +func (_AMM *AMMFilterer) ParseSwap(log types.Log) (*AMMSwap, error) { + event := new(AMMSwap) + if err := _AMM.contract.UnpackLog(event, "Swap", log); err != nil { + return nil, err + } + event.Raw = log + return event, nil +} diff --git a/generator/contracts/AMM.sol b/generator/contracts/AMM.sol new file mode 100644 index 0000000..c0469ac --- /dev/null +++ b/generator/contracts/AMM.sol @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +/// @title AMM +/// @notice A constant-product swap with the storage and gas shape of a +/// UniswapV2 pair, sized for load generation rather than for correctness. +/// +/// A swap here touches what a real pair touches: both reserves, the caller's +/// balance in each token, and an event. That is the cost this contract exists +/// to reproduce. What it does not reproduce is the economics — there is no +/// router, no fee split to an LP, no price oracle and no minimum-output check. +/// +/// Nothing reverts on bookkeeping, which is the same choice StorageRWv1.sol +/// makes. The balances wrap instead of checking, because nothing reads them +/// back and a load generator that fails on its own accounting stops measuring +/// the chain. The arithmetic on lines outside the unchecked block stays +/// checked: it reverts only on an input the scenario never sends, and a silent +/// wrap there would price a swap from garbage. +/// +/// Every swap writes the same four slots, so after a caller's first swap the +/// cost of its thousandth is the cost of its second. +contract AMM { + uint256 public reserveA; + uint256 public reserveB; + + /// @dev The reserve a swap restores an exhausted side to. + uint256 public constant RESERVE_FLOOR = 1_000_000 * 10**18; + + /// @dev The reserve above which a swap restores a side to the floor. A run + /// drives one pair for its whole length and only ever adds to the input + /// side, so without a ceiling the price walks away from where it started: + /// measured, the output halves every 100,000 swaps. With it the reserves + /// saw-tooth and the price holds for any run length. + /// + /// A swap clamps a reserve as it reads it and writes the result after, so a + /// side settles within one swap's size of each bound rather than exactly on + /// it. Measured over 40 swaps of a tenth of the floor, the input side stayed + /// in [1.1x, 2.1x] of the floor. + /// + /// The ceiling is also what keeps one oversized call from ending the pair. + /// A swap of 1e49 leaves the input reserve at 1e49, which without a ceiling + /// prices every later swap at nothing; measured, the next ordinary swap + /// instead resets that side to the floor and pays out in full. + uint256 public constant RESERVE_CEIL = 2 * RESERVE_FLOOR; + + mapping(address => uint256) private _balanceA; + mapping(address => uint256) private _balanceB; + + event Swap(address indexed sender, bool indexed aToB, uint256 amountIn, uint256 amountOut); + + constructor() { + reserveA = RESERVE_FLOOR; + reserveB = RESERVE_FLOOR; + } + + function balanceOfA(address account) public view returns (uint256) { + return _balanceA[account]; + } + + function balanceOfB(address account) public view returns (uint256) { + return _balanceB[account]; + } + + /// @notice Swap amountIn of token A for token B. + function swapAToB(uint256 amountIn) public returns (uint256) { + return _swap(true, amountIn); + } + + /// @notice Swap amountIn of token B for token A. + function swapBToA(uint256 amountIn) public returns (uint256) { + return _swap(false, amountIn); + } + + function _swap(bool aToB, uint256 amountIn) private returns (uint256) { + uint256 reserveIn = aToB ? reserveA : reserveB; + uint256 reserveOut = aToB ? reserveB : reserveA; + + // Hold both sides between the floor and the ceiling. See RESERVE_CEIL. + if (reserveIn < RESERVE_FLOOR || reserveIn > RESERVE_CEIL) { + reserveIn = RESERVE_FLOOR; + } + if (reserveOut < RESERVE_FLOOR || reserveOut > RESERVE_CEIL) { + reserveOut = RESERVE_FLOOR; + } + + // x*y=k with the 0.3% fee UniswapV2 charges, so the arithmetic is the + // same width and the same number of operations. + uint256 amountInWithFee = amountIn * 997; + uint256 numerator = amountInWithFee * reserveOut; + uint256 denominator = reserveIn * 1000 + amountInWithFee; + uint256 amountOut = numerator / denominator; + + mapping(address => uint256) storage balIn = aToB ? _balanceA : _balanceB; + mapping(address => uint256) storage balOut = aToB ? _balanceB : _balanceA; + + // Wrap rather than revert, and rather than credit a short caller. A + // credit that restores the slot to what it held leaves it at zero for a + // caller that started there, and a zero-to-non-zero write costs four + // times one that changes a slot already holding a value. Under the + // default mix, which draws one direction, that cold write would land on + // every swap the run makes rather than on the first. + unchecked { + balIn[msg.sender] = balIn[msg.sender] - amountIn; + balOut[msg.sender] = balOut[msg.sender] + amountOut; + } + + if (aToB) { + reserveA = reserveIn + amountIn; + reserveB = reserveOut - amountOut; + } else { + reserveB = reserveIn + amountIn; + reserveA = reserveOut - amountOut; + } + + emit Swap(msg.sender, aToB, amountIn, amountOut); + return amountOut; + } +} diff --git a/generator/scenarios/AMM.go b/generator/scenarios/AMM.go new file mode 100644 index 0000000..b45c673 --- /dev/null +++ b/generator/scenarios/AMM.go @@ -0,0 +1,136 @@ +package scenarios + +import ( + "fmt" + "math/big" + mrand "math/rand/v2" + + "github.com/ethereum/go-ethereum/accounts/abi/bind" + "github.com/ethereum/go-ethereum/common" + ethtypes "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/ethclient" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/bindings" + "github.com/sei-protocol/sei-load/types" +) + +const AMM = "amm" + +// ammSwapGas bounds one swap. +// +// The number is a required gas limit read from eth_estimateGas, not a receipt's +// GasUsed. GasUsed is what the chain charges after the refund lands at the end +// of execution; a transaction still has to be provisioned for the peak before +// it. Sizing this constant from a receipt once put it 20% under the limit the +// same swap needed, which fails every transaction and burns the whole limit. +// +// Two shapes, measured over six accounts against the deployed binding on a +// chain running the default storage gas costs: +// +// - 79,988, an account's first swap. It writes the account's balance in both +// tokens from zero, and a zero to non-zero storage write costs four times +// one that changes a slot already holding a value. +// - 45,177, every later swap by that account. The balances wrap rather than +// return to zero, so the slots stay non-zero for the rest of the run. +// +// One limit has to cover the higher shape, so a run in steady state declares +// about 44% more gas than it spends. A chain that admits transactions against +// their declared limit reserves that difference for gas no swap uses, which +// costs the throughput a profile can reach. Priming both slots during prewarm +// would let this drop near the lower shape; that needs a transaction class the +// prewarm path does not have yet, and PLT-1093 carries it. +// +// The calibration assumes the chain charges the default 20,000 for a zero to +// non-zero storage write. Sei sets that as a chain parameter, and pacific-1 and +// atlantic-2 charge about 74,700, which puts the first shape near 185,000 +// there. Every hard-coded limit in this package has the same exposure, so +// PLT-1092 covers the package rather than this constant. +// +// Estimating per transaction would put an eth_estimateGas on the send path, +// which is the load this tool exists to avoid adding. +const ammSwapGas = 85_000 + +// ammSwapAmount is the input every swap sends. +// +// It is fixed rather than drawn, because drawing it would buy no gas coverage. +// Measured across the steady shape, a swap needed 45,141 gas at 1e6 and 45,201 +// at 1e23: sixty gas over seventeen orders of magnitude, and all of it the +// calldata bytes of the larger number rather than execution. +// +// The one amount that does change the shape is zero, which makes the output zero +// and turns the payout write into a write of the value already there. The +// scenario never sends it. +// +// A draw would also cost a draw from the run's single PRNG, which every other +// scenario's replay at the same seed depends on. +var ammSwapAmount = new(big.Int).Mul(big.NewInt(10), big.NewInt(1e18)) + +// AMMScenario drives a constant-product pair, which is the shape most of a DeFi +// workload's transactions have. +type AMMScenario struct { + *ContractScenarioBase[bindings.AMM] + contract *bindings.AMM + operations *config.OperationPicker +} + +// NewAMMScenario creates a new AMM scenario. +func NewAMMScenario(cfg config.Scenario) TxGenerator { + scenario := &AMMScenario{ + operations: config.AMMOperations.Picker(cfg.Operations), + } + scenario.ContractScenarioBase = NewContractScenarioBase[bindings.AMM](scenario, cfg) + return scenario +} + +// Name returns the name of the scenario. +func (s *AMMScenario) Name() string { + return AMM +} + +// Operation returns the scenario's default operation. +func (s *AMMScenario) Operation() string { + return config.OpSwapAToB +} + +// DeployContract implements ContractDeployer. AMM seeds both reserves in its +// constructor and takes no arguments. +func (s *AMMScenario) DeployContract(opts *bind.TransactOpts, client *ethclient.Client) (common.Address, *ethtypes.Transaction, error) { + address, tx, _, err := bindings.DeployAMM(opts, client) + return address, tx, err +} + +// GetBindFunc implements ContractDeployer. +func (s *AMMScenario) GetBindFunc() ContractBindFunc[bindings.AMM] { + return bindings.NewAMM +} + +// SetContract implements ContractDeployer. +func (s *AMMScenario) SetContract(contract *bindings.AMM) { + s.contract = contract +} + +// CreateContractTransaction implements ContractDeployer - builds one swap in the +// direction the operation mix drew. +func (s *AMMScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { + auth.GasLimit = ammSwapGas + + // The draw is part of the replay contract. One draw today, so there is no + // order to get wrong; a second axis must land after this one, because every + // scenario shares the run's PRNG and a reordered draw shifts every later + // one at the same seed. + op := s.operations.Select(rng) + scenario.Operation = op + + switch op { + case config.OpSwapAToB: + return s.contract.SwapAToB(auth, ammSwapAmount) + case config.OpSwapBToA: + return s.contract.SwapBToA(auth, ammSwapAmount) + default: + // A name added to the set with no call here would otherwise be stamped + // into the metric and sent as the other leg, so the dimension would say + // one thing while the chain saw another. + return nil, fmt.Errorf("amm: no call for operation %q", op) + } +} diff --git a/generator/scenarios/AMM_test.go b/generator/scenarios/AMM_test.go new file mode 100644 index 0000000..bca6023 --- /dev/null +++ b/generator/scenarios/AMM_test.go @@ -0,0 +1,181 @@ +package scenarios_test + +import ( + mrand "math/rand/v2" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/generator/bindings" + "github.com/sei-protocol/sei-load/generator/scenarios" + "github.com/sei-protocol/sei-load/types" +) + +// ammColdSwapGas is the largest gas limit one swap required when measured +// against the deployed binding over six accounts, on a chain running the +// default storage gas costs. It is a required limit read from eth_estimateGas, +// not a receipt's GasUsed, because a transaction is provisioned for the peak +// before the refund lands. The steady-state shape needed 45,177. +// +// It is written down so the limit is checked against a measurement rather than +// against itself. An assertion comparing the limit to its own constant passes +// at any value. +const ammColdSwapGas = 79_988 + +func newAttachedAMM(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { + t.Helper() + sc.Name = scenarios.AMM + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + gen := scenarios.CreateScenario(sc) + require.NoError(t, gen.Ready(cfg)) + require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) + return gen, &types.TxScenario{ + Name: scenarios.AMM, + Nonce: 0, + Sender: types.GenerateAccounts(1, true)[0], + } +} + +// decodeAMM names the method AMM calldata calls, through the binding's own ABI +// so the test cannot drift from the contract. +func decodeAMM(t *testing.T, data []byte) string { + t.Helper() + parsed, err := bindings.AMMMetaData.GetAbi() + require.NoError(t, err) + method, err := parsed.MethodById(data[:4]) + require.NoError(t, err) + return method.Name +} + +// ammMethodFor maps an operation name to the contract method that name promises. +var ammMethodFor = map[string]string{ + config.OpSwapAToB: "swapAToB", + config.OpSwapBToA: "swapBToA", +} + +// TestAMMCoversItsDeclaredOperations closes the seam between the names config +// declares for this scenario and the methods the scenario calls: weighting one +// name alone must produce calldata for that name's method. A name added to the +// set with no call behind it fails here rather than mislabelling every +// transaction of a run. +func TestAMMCoversItsDeclaredOperations(t *testing.T) { + names := config.OperationNamesFor(scenarios.AMM) + require.NotEmpty(t, names) + for _, name := range names { + t.Run(name, func(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{name: 1}, + }) + tx, err := gen.Generate(newTestRng(1), txs) + require.NoError(t, err) + want, known := ammMethodFor[name] + require.True(t, known, "no expected method recorded for %q", name) + require.Equal(t, want, decodeAMM(t, tx.Data())) + }) + } +} + +// TestAMMStampsTheDrawnOperation asserts the operation recorded on the +// TxScenario is the method the calldata actually calls, across a mix. +// +// The weights are deliberately lopsided and written with the later-declared +// name first. A balanced two-name mix in declared order passes for any picker +// that is not wholly degenerate, including one that walks the map in Go's +// randomised order, so it would assert nothing about the draw. +func TestAMMStampsTheDrawnOperation(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{ + config.OpSwapBToA: 1, + config.OpSwapAToB: 3, + }, + }) + rng := newTestRng(7) + seen := map[string]int{} + for range 200 { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + require.Equal(t, ammMethodFor[txs.Operation], decodeAMM(t, tx.Data()), + "the metric says %q and the chain sees a different method", txs.Operation) + seen[txs.Operation]++ + } + require.Len(t, seen, 2, "one leg never drew, so the mix is not being read") + require.Greater(t, seen[config.OpSwapAToB], seen[config.OpSwapBToA], + "the heavier weight did not draw more often, so the weights are ignored") +} + +// TestAMMLegsCallDifferentMethods fails when both operations produce the same +// calldata. Two names that reach the metrics as separate dimensions must reach +// the chain as separate calls, or the run reports a split it did not make. +func TestAMMLegsCallDifferentMethods(t *testing.T) { + sel := map[string]string{} + for _, name := range config.OperationNamesFor(scenarios.AMM) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{name: 1}, + }) + tx, err := gen.Generate(newTestRng(3), txs) + require.NoError(t, err) + sel[name] = string(tx.Data()[:4]) + } + require.Len(t, sel, 2) + require.NotEqual(t, sel[config.OpSwapAToB], sel[config.OpSwapBToA], + "both legs send the same selector, so the two dimensions are one workload") +} + +// TestAMMGasCoversAMeasuredSwap holds the declared limit between the cost a swap +// actually has and a ceiling above it. +// +// Both bounds matter and they fail differently. Below the real cost every +// transaction lands with a failed status and burns the whole limit, which a run +// without receipt tracking reports as success. Far above it, a chain that admits +// transactions against their declared limit reserves block space the swap never +// spends, and the profile's reachable throughput falls for no visible reason. +// +// The bounds are measured, not the constant restated: an assertion against the +// constant itself passes at any value. +func TestAMMGasCoversAMeasuredSwap(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{}) + tx, err := gen.Generate(newTestRng(1), txs) + require.NoError(t, err) + + require.Greater(t, tx.Gas(), uint64(ammColdSwapGas), + "the limit is below the most a swap cost when measured, so an account's "+ + "first swap lands with a failed status and burns the whole limit") + require.Less(t, tx.Gas(), uint64(ammColdSwapGas*13/10), + "the limit is far above a measured swap, so it reserves block space nothing spends") +} + +// TestAMMDefaultPathDrawsNoRandomness asserts a profile with no operation mix +// consumes no randomness, so adding this scenario to a run cannot shift the +// draw sequence every other scenario's replay depends on. +func TestAMMDefaultPathDrawsNoRandomness(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{}) + rng := newTestRng(11) + _, err := gen.Generate(rng, txs) + require.NoError(t, err) + require.Equal(t, newTestRng(11).Uint64(), rng.Uint64(), + "the default path drew from the run's PRNG, which shifts every later draw") +} + +// TestAMMMixDrawsExactlyOnce asserts a profile with a mix consumes one draw per +// transaction, for the same reason. +func TestAMMMixDrawsExactlyOnce(t *testing.T) { + gen, txs := newAttachedAMM(t, config.Scenario{ + Operations: config.OperationMix{config.OpSwapAToB: 1, config.OpSwapBToA: 1}, + }) + rng := newTestRng(11) + _, err := gen.Generate(rng, txs) + require.NoError(t, err) + + want := newTestRng(11) + consumeOne(want) + require.Equal(t, want.Uint64(), rng.Uint64(), + "the mix path drew a different number of times than one") +} + +// consumeOne advances a PRNG by the one draw an operation pick costs. +func consumeOne(rng *mrand.Rand) { rng.Uint64N(2) } diff --git a/generator/scenarios/factory.go b/generator/scenarios/factory.go index 409ad5c..a6d9346 100644 --- a/generator/scenarios/factory.go +++ b/generator/scenarios/factory.go @@ -18,10 +18,11 @@ var scenarioFactories = map[string]ScenarioFactory{ // Auto-generated entries will be added below this line by make generate // DO NOT EDIT BELOW THIS LINE - AUTO-GENERATED CONTENT + AMM: NewAMMScenario, Disperse: NewDisperseScenario, + ERC20: NewERC20Scenario, ERC20Conflict: NewERC20ConflictScenario, ERC20Noop: NewERC20NoopScenario, - ERC20: NewERC20Scenario, ERC721: NewERC721Scenario, StorageRW: NewStorageRWScenario, diff --git a/generator/scenarios/operation_internal_test.go b/generator/scenarios/operation_internal_test.go index 70c2b27..cb2e447 100644 --- a/generator/scenarios/operation_internal_test.go +++ b/generator/scenarios/operation_internal_test.go @@ -19,6 +19,8 @@ var frozenOperations = map[string]struct{}{ config.OpERC20Transfer: {}, config.OpERC721Mint: {}, config.OpDisperseEther: {}, + config.OpSwapAToB: {}, + config.OpSwapBToA: {}, } // TestEveryScenarioNamesAFrozenOperation asserts that every registered scenario @@ -37,6 +39,16 @@ func TestEveryScenarioNamesAFrozenOperation(t *testing.T) { op := factory(config.Scenario{Name: name}).Operation() require.NotEmpty(t, op) require.Contains(t, frozenOperations, op) + + // Every name the scenario can draw, not only the one it defaults to. + // A scenario with an operation set reaches the metric dimension with + // any of them, so checking the default alone leaves the rest of the + // vocabulary unchecked while the test's name says otherwise. + for _, drawable := range config.OperationNamesFor(name) { + require.Contains(t, frozenOperations, drawable, + "scenario %q can draw %q, which is not in the frozen vocabulary", + name, drawable) + } }) } } From 137675a899672c04e494def0f67c132301b07657 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 11:59:08 -0700 Subject: [PATCH 3/4] feat(health): serve healthz and readyz A deployment has no way to tell a run that is starting from one that is stuck. The process serves /metrics and nothing else, so a probe set has nothing to gate on and a pod counts as available the moment its container starts. /healthz answers as soon as the server binds and never reads the startup sequence. /readyz refuses until the dispatcher is running. Keeping those separate is the whole point. Funding, deployment and prewarm take minutes against a cold chain. A liveness probe that reported the run dead for that window would restart the pod before it sent a transaction, then restart the next attempt at the same place, and the cause would read as a crash loop rather than a slow start. While /readyz refuses it names the phase, so a ten-minute startup shows the step it is on. Measured against the binary: healthz held 200 through a 21 second prewarm while readyz reported "prewarming accounts", then both answered once the dispatcher started. The flag and the phase are stored as one value rather than as two atomics. Two would leave a window where a reader sees the run serving while the body still names the step it left, so the status and the body would disagree about the same instant. Five mutations, five caught, including that one: split into two atomics, a reader observed a serving status carrying "funding accounts". Co-Authored-By: Claude Opus 5 (1M context) --- health/probes.go | 86 +++++++++++++++++++++++ health/probes_test.go | 156 ++++++++++++++++++++++++++++++++++++++++++ main.go | 16 +++++ 3 files changed, 258 insertions(+) create mode 100644 health/probes.go create mode 100644 health/probes_test.go diff --git a/health/probes.go b/health/probes.go new file mode 100644 index 0000000..9858a8d --- /dev/null +++ b/health/probes.go @@ -0,0 +1,86 @@ +// Package health answers the liveness and readiness probes a deployment gates +// on. +package health + +import ( + "net/http" + "sync/atomic" +) + +// Probes reports whether a run still responds, and whether it has finished +// starting. +// +// The two answer different questions, and conflating them restarts a pod that +// is working. Liveness asks whether the process is still there. Readiness asks +// whether it has finished a startup that funds accounts, deploys contracts and +// prewarms, which takes minutes against a cold chain. A liveness probe that +// waited for all of that would kill the run before its first transaction, and +// kill the next attempt at the same point. +// +// So /healthz answers as soon as the server binds and never consults the +// startup sequence. Only /readyz gates on it. +type Probes struct { + state atomic.Pointer[state] +} + +// state is one consistent answer. The phase sits beside the flag in a single +// stored value, so a reader cannot pair a stale phase with a fresh flag. +type state struct { + ready bool + phase string +} + +// New returns probes reporting the given phase, not yet ready. +func New(phase string) *Probes { + probes := &Probes{} + probes.state.Store(&state{phase: phase}) + return probes +} + +// Enter records the phase a run is working through. It does not make the run +// ready. The phase is what /readyz reports while it is still refusing, so an +// operator watching a ten-minute startup reads the step rather than a bare 503. +func (p *Probes) Enter(phase string) { + p.state.Store(&state{phase: phase}) +} + +// Ready marks the run started and serving. +func (p *Probes) Ready() { + p.state.Store(&state{ready: true, phase: "running"}) +} + +// NotReady takes a run out of service without reporting it dead. A shutting-down +// run answers /healthz until its server stops, so the kubelet lets it finish +// rather than killing it as unresponsive. +func (p *Probes) NotReady(phase string) { + p.state.Store(&state{phase: phase}) +} + +// Register mounts both endpoints on mux. +func (p *Probes) Register(mux *http.ServeMux) { + mux.HandleFunc("/healthz", p.serveLive) + mux.HandleFunc("/readyz", p.serveReady) +} + +// serveLive answers for as long as the server runs. It reads no state on +// purpose: see the type's documentation. +func (p *Probes) serveLive(w http.ResponseWriter, _ *http.Request) { + writeText(w, http.StatusOK, "ok") +} + +func (p *Probes) serveReady(w http.ResponseWriter, _ *http.Request) { + current := p.state.Load() + if !current.ready { + writeText(w, http.StatusServiceUnavailable, current.phase) + return + } + writeText(w, http.StatusOK, current.phase) +} + +func writeText(w http.ResponseWriter, status int, body string) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(status) + // The probe reads the status line. A short body is for an operator running + // curl, so a write that fails changes nothing worth reporting. + _, _ = w.Write([]byte(body + "\n")) +} diff --git a/health/probes_test.go b/health/probes_test.go new file mode 100644 index 0000000..b1f17ed --- /dev/null +++ b/health/probes_test.go @@ -0,0 +1,156 @@ +package health_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/health" +) + +// get returns the status and body one endpoint answers with. +func get(t *testing.T, probes *health.Probes, path string) (int, string) { + t.Helper() + mux := http.NewServeMux() + probes.Register(mux) + recorder := httptest.NewRecorder() + mux.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, path, nil)) + return recorder.Code, strings.TrimSpace(recorder.Body.String()) +} + +// TestLivenessDoesNotWaitForStartup guards the failure that makes these probes +// worse than none. Funding, deployment and prewarm take minutes against a cold +// chain. A liveness probe that reported the run dead for that whole window would +// restart the pod before it sent a transaction, and restart the next attempt at +// the same point, so the run never happens and the cause looks like a crash. +func TestLivenessDoesNotWaitForStartup(t *testing.T) { + probes := health.New("starting") + + for _, phase := range []string{"starting", "deploying contracts", "funding accounts", "prewarming accounts"} { + probes.Enter(phase) + status, _ := get(t, probes, "/healthz") + require.Equal(t, http.StatusOK, status, + "liveness failed during %q, so the kubelet restarts the pod mid-startup and the run never reaches its first transaction", phase) + } +} + +// TestReadinessWaitsForStartup is the other half: a run that has not deployed or +// funded anything must not be reported as serving. +func TestReadinessWaitsForStartup(t *testing.T) { + probes := health.New("starting") + + status, _ := get(t, probes, "/readyz") + require.Equal(t, http.StatusServiceUnavailable, status, + "readiness passed before the dispatcher started, so the probe cannot tell a run that is working from one still funding") + + probes.Ready() + status, _ = get(t, probes, "/readyz") + require.Equal(t, http.StatusOK, status, + "readiness still fails after the run started, so a startupProbe would exhaust its budget and kill a healthy run") +} + +// TestReadinessNamesThePhaseItIsWaitingOn keeps the body useful. A ten-minute +// startup that answers only "503" tells an operator nothing about which step is +// slow. +func TestReadinessNamesThePhaseItIsWaitingOn(t *testing.T) { + probes := health.New("starting") + probes.Enter("funding accounts") + + status, body := get(t, probes, "/readyz") + require.Equal(t, http.StatusServiceUnavailable, status) + require.Equal(t, "funding accounts", body, + "the body does not name the phase, so a slow startup reports no more than a bare failure") +} + +// TestShutdownLeavesServiceWithoutReportingDeath covers the window where a run +// holds the pod open for the post-summary scrape. Readiness must drop so nothing +// routes to it. Liveness must hold, or the kubelet reads the deliberate hold as +// a hang and kills the process before the scrape lands. +func TestShutdownLeavesServiceWithoutReportingDeath(t *testing.T) { + probes := health.New("starting") + probes.Ready() + probes.NotReady("shutting down") + + ready, body := get(t, probes, "/readyz") + require.Equal(t, http.StatusServiceUnavailable, ready, + "a shutting-down run still reports ready, so traffic routes to a process that is leaving") + require.Equal(t, "shutting down", body) + + live, _ := get(t, probes, "/healthz") + require.Equal(t, http.StatusOK, live, + "liveness failed during shutdown, so the kubelet kills the run before its final metrics are scraped") +} + +// TestAReadyStatusNeverCarriesAStartupPhase pins the reason the flag and the +// phase are stored as one value rather than as two atomics. Stored separately, +// a writer setting the flag and then the phase leaves a window where a reader +// sees the run serving while the body still names the step it was on. The +// status and the body would then disagree about the same instant, and an +// operator reading the body would act on a phase the run had left. +func TestAReadyStatusNeverCarriesAStartupPhase(t *testing.T) { + probes := health.New("starting") + phases := []string{"deploying contracts", "funding accounts", "prewarming accounts"} + + var group sync.WaitGroup + group.Add(2) + stop := make(chan struct{}) + go func() { + defer group.Done() + defer close(stop) + for i := 0; i < 20_000; i++ { + probes.Enter(phases[i%len(phases)]) + probes.Ready() + probes.NotReady("shutting down") + } + }() + go func() { + defer group.Done() + for { + select { + case <-stop: + return + default: + } + if status, body := get(t, probes, "/readyz"); status == http.StatusOK { + require.Equal(t, "running", body, + "a serving status carried the phase %q, so the flag and the phase are not stored as one value", body) + } + } + }() + group.Wait() +} + +// TestConcurrentPhasesAndProbesDoNotRace runs the real pairing: the run goroutine +// advances phases while the probe goroutine reads. Under -race this fails if the +// phase and the flag are ever stored separately. +func TestConcurrentPhasesAndProbesDoNotRace(t *testing.T) { + probes := health.New("starting") + phases := []string{"deploying contracts", "funding accounts", "prewarming accounts"} + + var group sync.WaitGroup + group.Add(2) + go func() { + defer group.Done() + for i := 0; i < 200; i++ { + probes.Enter(phases[i%len(phases)]) + } + probes.Ready() + }() + go func() { + defer group.Done() + for i := 0; i < 200; i++ { + status, body := get(t, probes, "/readyz") + // Never a ready status paired with a startup phase: the two are stored + // as one value, so a reader cannot see half of an update. + if status == http.StatusOK { + require.Equal(t, "running", body, + "a ready status carried a startup phase, so the flag and the phase are not stored together") + } + } + }() + group.Wait() +} diff --git a/main.go b/main.go index f22beb0..7779284 100644 --- a/main.go +++ b/main.go @@ -24,6 +24,7 @@ import ( "github.com/sei-protocol/sei-load/config" "github.com/sei-protocol/sei-load/funder" "github.com/sei-protocol/sei-load/generator" + "github.com/sei-protocol/sei-load/health" "github.com/sei-protocol/sei-load/observability" "github.com/sei-protocol/sei-load/sender" "github.com/sei-protocol/sei-load/stats" @@ -171,6 +172,10 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { listenAddr := cmd.Flag("metricsListenAddr").Value.String() log.Printf("serving metrics at %s/metrics", listenAddr) + // Built before the server so /readyz answers from the first scrape rather + // than from whenever the run reaches its first phase. + probes := health.New("starting") + obsShutdown, err := observability.Setup(ctx, observability.Config{ RunScope: observability.RunScopeFromEnv(), OTLPEndpoint: os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), @@ -189,6 +194,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // EnableOpenMetrics is load-bearing: the default promhttp.Handler() strips // exemplars regardless of the scraper's Accept header. mux := http.NewServeMux() + probes.Register(mux) mux.Handle("/metrics", promhttp.HandlerFor( prometheus.DefaultGatherer, promhttp.HandlerOpts{EnableOpenMetrics: true}, @@ -237,6 +243,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { } // Create the generator from the config struct + probes.Enter("deploying contracts") gen, err := generator.NewGenerator(ctx, rng, cfg, deployer) if err != nil { return fmt.Errorf("failed to create generator: %w", err) @@ -328,6 +335,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { for _, a := range gen.Accounts() { addrs = append(addrs, a.Address) } + probes.Enter("funding accounts") if err := funder.FundAccounts(ctx, cfg, deployer, addrs); err != nil { return fmt.Errorf("failed to fund accounts: %w", err) } @@ -342,6 +350,7 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Set up prewarming if enabled if cfg.Settings.Prewarm { + probes.Enter("prewarming accounts") log.Printf("šŸ”„ Creating prewarm generator...") if err := gen.Prewarm(ctx, rng, cfg, snd); err != nil { return fmt.Errorf("gen.Prewarm(): %w", err) @@ -357,6 +366,9 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { s.SpawnBgNamed("generator", func() error { return gen.Run(ctx, rng, snd) }) log.Printf("āœ… Started dispatcher") + // Everything a run needs is up: contracts deployed, accounts funded and + // prewarmed, sender and dispatcher running. + probes.Ready() // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) @@ -385,6 +397,10 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return err } log.Print("\nšŸ›‘ Received shutdown signal, stopping gracefully...") + // Out of service, still alive. The run holds the pod open for the + // post-summary scrape window, and /healthz keeps answering through it so + // the kubelet does not read that hold as a hang. + probes.NotReady("shutting down") return nil }) // Print final statistics From d555a268a2317d18226af7c8a0f3b424e14695e5 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 20:32:22 -0700 Subject: [PATCH 4/4] fix(health): leave service on every exit, not only on a signal NotReady ran on the signal path alone. A run whose duration expired, or whose background worker failed, returned early and never reached it, so readiness stayed true through the whole shutdown: the final statistics, the run summary, and the post-summary hold that keeps the pod open for a last scrape. That hold defaults to 25 seconds, and it is exactly the window readiness exists to cover. Deferring it right after Ready covers every path out by construction rather than by remembering to call it at each return. Measured against the same duration-bounded run, before and after. Before, /readyz answered "running" for all fourteen seconds, through the deadline at five and the ten-second hold after it. After, it flips to "shutting down" at the deadline. /healthz answers 200 throughout in both, which is what keeps the kubelet from reading a deliberate hold as a hang. The guard here is structural rather than a test: a defer at the top of the scope covers every return, and this package has no harness that drives the run's lifecycle. The measurement above is what stands in for one. Co-Authored-By: Claude Opus 5 (1M context) --- main.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/main.go b/main.go index 7779284..91d643d 100644 --- a/main.go +++ b/main.go @@ -369,6 +369,13 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { // Everything a run needs is up: contracts deployed, accounts funded and // prewarmed, sender and dispatcher running. probes.Ready() + // Deferred because every path out of this run leaves service, not only + // the signal below. A duration deadline and a failed background worker + // both return early, and the run then logs its summary and holds the pod + // open for the scrape window — the whole time readiness is meant to + // cover. /healthz keeps answering through it, so the kubelet does not + // read that hold as a hang. + defer probes.NotReady("shutting down") // Set up signal handling for graceful shutdown sigChan := make(chan os.Signal, 1) @@ -397,10 +404,6 @@ func runLoadTest(ctx context.Context, cmd *cobra.Command) error { return err } log.Print("\nšŸ›‘ Received shutdown signal, stopping gracefully...") - // Out of service, still alive. The run holds the pod open for the - // post-summary scrape window, and /healthz keeps answering through it so - // the kubelet does not read that hold as a hang. - probes.NotReady("shutting down") return nil }) // Print final statistics