From d0ff10bca1ae14eceb8fa9d8d54825fa586e802c Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 12 Jun 2026 07:53:05 -0700 Subject: [PATCH 1/3] PLT-461: StorageRW scenario scaffold + deploy wiring Add a "storagerw" scenario over the PLT-457 StorageRWv1 binding, mirroring ERC20Conflict: a ContractScenarioBase[StorageRWv1] that deploys the mapping-backed contract (no constructor args), binds it, and produces a fixed rmw tx against slot 0 with an empty pad. This proves the contract is reachable as a scenario end-to-end; the per-tx slot/value/pad distribution lands in PLT-465. Register "storagerw" in the factory's auto-generated block. Hand-written to match the current ERC20Conflict idiom rather than via `make generate`: the template script still emits the pre-PLT-457 signatures (NewXScenario()/NewContractScenarioBase(scenario)) and a sol-derived name, neither of which matches current base.go or the ticket's "storagerw" name. Test mirrors the mock-deploy generator coverage at the scenario level: attach at a known address, generate, and assert the tx targets the contract and carries the rmw selector (cross-checked against the binding ABI). Co-Authored-By: Claude Opus 4.8 (1M context) --- generator/scenarios/StorageRW.go | 88 +++++++++++++++++++++++++++ generator/scenarios/StorageRW_test.go | 66 ++++++++++++++++++++ generator/scenarios/factory.go | 1 + 3 files changed, 155 insertions(+) create mode 100644 generator/scenarios/StorageRW.go create mode 100644 generator/scenarios/StorageRW_test.go diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go new file mode 100644 index 0000000..8c23956 --- /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" + +// storageRWSlot is the fixed storage slot every transaction targets in this +// scaffold. The per-tx slot/value/pad distribution arrives in PLT-465. +var storageRWSlot = big.NewInt(0) + +// storageRWPad is the fixed calldata pad for this scaffold (empty). The +// distribution-driven pad sizing arrives in PLT-465. +var 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 +// StorageRWv1 transaction. This scaffold issues a fixed read-modify-write against +// a single hardcoded slot with an empty pad to prove the deploy/send path; the +// per-tx slot/value/pad distribution arrives in PLT-465. +func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { + 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..585d0ee --- /dev/null +++ b/generator/scenarios/StorageRW_test.go @@ -0,0 +1,66 @@ +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]) + + // 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/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 } From fc62305856f6ba3fb04f7684f4c4af372ebf6044 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 12 Jun 2026 08:07:00 -0700 Subject: [PATCH 2/3] Set explicit 50k gas limit on StorageRW rmw tx The rmw scaffold tx left auth.GasLimit at the 200k CreateTransactionOpts default while every sibling scenario tunes it. On a gas-limit-admission chain that under-packs blocks by ~7x, depressing observed TPS. rmw is SLOAD+SSTORE on one slot (~26k warm, ~44k cold-first); 50k covers cold-first-touch with headroom and packs ~4x denser. PLT-465 revisits once the calldata pad is distribution-driven. Pin the fixed scaffold calldata in the test (slot 0, empty pad). Co-Authored-By: Claude Opus 4.8 (1M context) --- generator/scenarios/StorageRW.go | 6 ++++++ generator/scenarios/StorageRW_test.go | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index 8c23956..9e57f17 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -84,5 +84,11 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add // a single hardcoded slot with an empty pad to prove the deploy/send path; the // per-tx slot/value/pad distribution arrives in PLT-465. func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { + // rmw is SLOAD+SSTORE on one slot (~26k warm, ~44k cold-first-touch); 50k + // covers cold-first-touch with headroom for the (currently empty) pad, and + // packs ~4x denser than the 200k CreateTransactionOpts default on a + // gas-limit-admission chain. PLT-465 revisits this once the calldata pad is + // distribution-driven (pad changes calldata gas). + auth.GasLimit = 50000 return s.contract.Rmw(auth, storageRWSlot, storageRWPad) } diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 585d0ee..5ddde6e 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -59,6 +59,16 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { 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) From 74e53eddb1924e76438746eed3772e784d2d9019 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 12 Jun 2026 14:05:18 -0700 Subject: [PATCH 3/3] PLT-461: Move StorageRW prose into package doc, lean inline comments Add generator/scenarios/doc.go documenting the contract-scenario pattern (ContractScenarioBase/ContractDeployer, MockDeploy attach, factory registration) and the StorageRW scaffold with the full gas-sizing derivation. Lean StorageRW.go: consolidate the three PLT-465 deferral notes to one terse note, and reduce the dense gas comment to a critical one-liner plus a pointer to the package doc. Comments and doc only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- generator/scenarios/StorageRW.go | 24 +++++-------- generator/scenarios/doc.go | 62 ++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 15 deletions(-) create mode 100644 generator/scenarios/doc.go diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index 9e57f17..e8d5a03 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -15,13 +15,11 @@ import ( const StorageRW = "storagerw" -// storageRWSlot is the fixed storage slot every transaction targets in this -// scaffold. The per-tx slot/value/pad distribution arrives in PLT-465. -var storageRWSlot = big.NewInt(0) - -// storageRWPad is the fixed calldata pad for this scaffold (empty). The -// distribution-driven pad sizing arrives in PLT-465. -var storageRWPad = []byte{} +// 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 { @@ -80,15 +78,11 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add } // CreateContractTransaction implements ContractDeployer interface - creates a -// StorageRWv1 transaction. This scaffold issues a fixed read-modify-write against -// a single hardcoded slot with an empty pad to prove the deploy/send path; the -// per-tx slot/value/pad distribution arrives in PLT-465. +// 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) { - // rmw is SLOAD+SSTORE on one slot (~26k warm, ~44k cold-first-touch); 50k - // covers cold-first-touch with headroom for the (currently empty) pad, and - // packs ~4x denser than the 200k CreateTransactionOpts default on a - // gas-limit-admission chain. PLT-465 revisits this once the calldata pad is - // distribution-driven (pad changes calldata gas). + // 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/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