diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go new file mode 100644 index 0000000..e8d5a03 --- /dev/null +++ b/generator/scenarios/StorageRW.go @@ -0,0 +1,88 @@ +package scenarios + +import ( + "math/big" + + "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 StorageRW = "storagerw" + +// Fixed slot and empty pad for the scaffold; PLT-465 makes these per-tx. +var ( + storageRWSlot = big.NewInt(0) + storageRWPad = []byte{} +) + +// StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations +type StorageRWScenario struct { + *ContractScenarioBase[bindings.StorageRWv1] + contract *bindings.StorageRWv1 +} + +// NewStorageRWScenario creates a new StorageRW scenario +func NewStorageRWScenario(cfg config.Scenario) TxGenerator { + scenario := &StorageRWScenario{} + scenario.ContractScenarioBase = NewContractScenarioBase[bindings.StorageRWv1](scenario, cfg) + return scenario +} + +// Name returns the name of the scenario. +func (s *StorageRWScenario) Name() string { + return StorageRW +} + +// DeployContract implements ContractDeployer interface - deploys StorageRWv1. +// StorageRWv1 is mapping-backed and takes no constructor arguments; the keyspace +// is generator-side. +func (s *StorageRWScenario) DeployContract(opts *bind.TransactOpts, client *ethclient.Client) (common.Address, *ethtypes.Transaction, error) { + address, tx, _, err := bindings.DeployStorageRWv1(opts, client) + return address, tx, err +} + +// GetBindFunc implements ContractDeployer interface - returns the binding function +func (s *StorageRWScenario) GetBindFunc() ContractBindFunc[bindings.StorageRWv1] { + return bindings.NewStorageRWv1 +} + +// SetContract implements ContractDeployer interface - stores the contract instance +func (s *StorageRWScenario) SetContract(contract *bindings.StorageRWv1) { + s.contract = contract +} + +// Attach implements TxGenerator interface - attaches to an existing contract +func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Address) error { + // Call base Attach to set deployed flag and config + if err := s.ContractScenarioBase.Attach(config, address); err != nil { + return err + } + + var client *ethclient.Client + var err error + if !config.MockDeploy { + client, err = ethclient.Dial(config.Endpoints[0]) + if err != nil { + return err + } + } + + s.contract, err = bindings.NewStorageRWv1(address, client) + return err +} + +// CreateContractTransaction implements ContractDeployer interface - creates a +// fixed StorageRWv1 rmw transaction. See package doc for the scaffold and gas +// rationale. +func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { + // 50k fits rmw (SLOAD+SSTORE) with headroom; see package doc for sizing. + // PLT-465 revisits with the distribution-driven pad. + auth.GasLimit = 50000 + return s.contract.Rmw(auth, storageRWSlot, storageRWPad) +} diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go new file mode 100644 index 0000000..5ddde6e --- /dev/null +++ b/generator/scenarios/StorageRW_test.go @@ -0,0 +1,76 @@ +package scenarios_test + +import ( + "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" +) + +// rmwSelector is the 4-byte function selector for StorageRWv1.rmw(uint256,bytes). +// It is the ABI-derived discriminator the produced calldata must start with. +var rmwSelector = []byte{0x22, 0x74, 0x6b, 0x07} + +// TestStorageRWFactoryRegistration proves the scenario is reachable by name +// through the factory. +func TestStorageRWFactoryRegistration(t *testing.T) { + gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) + require.NotNil(t, gen) + require.Equal(t, scenarios.StorageRW, gen.Name()) +} + +// TestStorageRWDeployAndGenerate proves the deploy + send path end-to-end under +// mock deploy: the scenario binds StorageRWv1, attaches at a known address, and +// produces a valid fixed rmw transaction targeting that contract. +func TestStorageRWDeployAndGenerate(t *testing.T) { + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + + gen := scenarios.CreateScenario(config.Scenario{Name: scenarios.StorageRW}) + + // Mirror generator.mockDeployAll: attach the bound contract at a known address. + contractAddr := types.GenerateAccounts(1)[0].Address + require.NoError(t, gen.Attach(cfg, contractAddr)) + + // Build the tx scenario the way the weighted generator does: a funded sender. + sender := types.GenerateAccounts(1)[0] + txScenario := &types.TxScenario{ + Name: scenarios.StorageRW, + Sender: sender, + } + + loadTx := gen.Generate(txScenario) + require.NotNil(t, loadTx) + require.NotNil(t, loadTx.EthTx) + + // The produced tx must target the deployed contract... + require.NotNil(t, loadTx.EthTx.To()) + require.Equal(t, contractAddr, *loadTx.EthTx.To()) + + // ...and carry rmw calldata against the fixed slot 0. + data := loadTx.EthTx.Data() + require.GreaterOrEqual(t, len(data), 4) + require.Equal(t, rmwSelector, data[:4]) + + // Pin the fixed scaffold calldata: rmw(uint256 slot, bytes _pad) with + // slot == 0 and an empty pad. ABI head is the slot operand (32B) then the + // bytes offset (0x40); the tail is the bytes length (0). All zero except the + // 0x40 offset, so the full body is 96 bytes. + body := data[4:] + require.Len(t, body, 96) + wantBody := make([]byte, 96) + wantBody[63] = 0x40 // offset to the _pad bytes argument + require.Equal(t, wantBody, body) + + // Sanity: the selector we assert against matches the binding's ABI. + parsed, err := bindings.StorageRWv1MetaData.GetAbi() + require.NoError(t, err) + require.Equal(t, rmwSelector, parsed.Methods["rmw"].ID) +} diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go new file mode 100644 index 0000000..8d1815c --- /dev/null +++ b/generator/scenarios/doc.go @@ -0,0 +1,62 @@ +// Package scenarios defines the load scenarios seiload can generate, and the +// shared scaffolding that lets a contract-backed scenario describe only what is +// unique to its contract. +// +// # The contract-scenario pattern +// +// Every scenario satisfies TxGenerator (Name/Generate/Attach/Deploy). Non-contract +// scenarios (the EVMTransfer family) implement it directly; contract scenarios +// compose ContractScenarioBase[T], which factors out the deploy-wait-bind flow +// and the per-tx auth construction so the concrete scenario only supplies its +// contract specifics. +// +// A contract scenario embeds *ContractScenarioBase[T] (T being the generated +// binding) and implements ContractDeployer[T]: +// +// - DeployContract — deploy the contract for this run. +// - GetBindFunc — return the binding's constructor so the base can bind the +// deployed (or attached) address. +// - SetContract — receive the bound instance for later CreateContractTransaction +// calls. +// - CreateContractTransaction — build one load transaction against the contract. +// +// The base owns the rest: DeployScenario deploys, waits for the receipt, asserts +// success, then binds and hands back the instance via SetContract; AttachScenario +// binds an already-deployed address the same way; CreateTransaction builds the +// per-tx auth and delegates to CreateContractTransaction. +// +// # MockDeploy attach +// +// Under config.MockDeploy a scenario attaches to a known address without a live +// endpoint, so the bind backend is nil. This is the path the tests and +// generator.mockDeployAll exercise: bind at an address, produce calldata, but +// never send. CreateContractTransaction must therefore stay pure (it shapes a +// transaction; it does not touch the chain). +// +// # Factory registration +// +// scenarioFactories maps a lowercase scenario name to its constructor, and +// CreateScenario resolves a config.Scenario by name. Non-contract entries are +// hand-written; contract entries below the AUTO-GENERATED marker in factory.go +// are emitted by `make generate` from the contract bindings — do not edit that +// block by hand. +// +// # StorageRW scaffold +// +// StorageRW issues a read-modify-write against StorageRWv1 to exercise the SLOAD +// + SSTORE storage path under load. PLT-461 lands it as a scaffold: every +// transaction targets one fixed slot with an empty calldata pad, which is enough +// to prove the deploy/send path. The per-tx slot/value/pad distribution arrives +// in PLT-465. +// +// Gas sizing. The rmw is an SLOAD + SSTORE on a single slot: ~26k gas warm, but +// ~44k on a cold first touch (the cold-SLOAD and the zero-to-nonzero SSTORE both +// charge their higher rates). The scaffold pins GasLimit to 50k: it covers the +// cold-first-touch case with headroom for the (currently empty) pad, and packs +// roughly 4x denser than the 200k default in CreateTransactionOpts. Density +// matters on a gas-limit-admission chain, where a block admits transactions up +// to its gas limit regardless of gas actually used — an oversized limit reserves +// block space the rmw never spends and throttles achievable throughput. PLT-465 +// revisits the limit once the calldata pad is distribution-driven, since pad size +// changes calldata gas. +package scenarios diff --git a/generator/scenarios/factory.go b/generator/scenarios/factory.go index 629f509..409ad5c 100644 --- a/generator/scenarios/factory.go +++ b/generator/scenarios/factory.go @@ -23,6 +23,7 @@ var scenarioFactories = map[string]ScenarioFactory{ ERC20Noop: NewERC20NoopScenario, ERC20: NewERC20Scenario, ERC721: NewERC721Scenario, + StorageRW: NewStorageRWScenario, // DO NOT EDIT ABOVE THIS LINE - AUTO-GENERATED CONTENT }