From 247833d9176dbb9d77f372eb9645678d76d1e3c5 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 15 Jun 2026 15:40:53 -0700 Subject: [PATCH 1/3] feat(generator): wire key/size/op distributions into StorageRW (PLT-465) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn StorageRW into the contention + size axes: slot = keyDist.SampleIndex( recordcount), op (read/write/rmw) by configured proportions, calldata pad = sizeDist bucket draw — each on an INDEPENDENT seeded sub-stream so changing one axis never perturbs another. Nil-guarded like the gas-picker: no distribution config => fixed slot 0 / rmw / empty pad, byte-identical to the PLT-461 scaffold (consumes zero randomness, account cadence untouched). Adds one append-only sub-stream id (dist:%d:op) to the FROZEN set per the append-only derivation rule; pad gas added as intrinsic EIP-2028 cost so it can't underprovision. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 23 +++ config/operation.go | 51 ++++++ config/operation_test.go | 61 ++++++ generator/generator.go | 3 + generator/scenarios/StorageRW.go | 91 +++++++-- generator/scenarios/StorageRW_test.go | 255 ++++++++++++++++++++++++++ generator/scenarios/doc.go | 28 +-- utils/rng/rng.go | 3 +- utils/rng/rng_test.go | 19 ++ utils/rng/streams.go | 5 + 10 files changed, 516 insertions(+), 23 deletions(-) create mode 100644 config/operation.go create mode 100644 config/operation_test.go diff --git a/config/config.go b/config/config.go index 938a8e4..ebcaece 100644 --- a/config/config.go +++ b/config/config.go @@ -81,4 +81,27 @@ type Scenario struct { GasTipCapPicker *GasPicker `json:"gasTipCapPicker,omitempty"` KeyDistribution *Distribution `json:"keyDistribution,omitempty"` SizeDistribution *Distribution `json:"sizeDistribution,omitempty"` + // RecordCount is the keyspace size the KeyDistribution indexes into: the + // per-tx slot is a draw in [0, RecordCount). Zero (the default) is the + // single-slot, 100%-conflict scaffold behavior. + RecordCount uint64 `json:"recordCount,omitempty"` + // SizeBuckets is the calldata-pad-length histogram the SizeDistribution + // indexes into: the per-tx pad length is SizeBuckets[draw]. Empty (the + // default) is the empty-pad scaffold behavior. + SizeBuckets []int `json:"sizeBuckets,omitempty"` + // Operations is the read/write/rmw selection mix. Nil (the default) is the + // all-rmw scaffold behavior. + Operations *OperationMix `json:"operations,omitempty"` +} + +// OperationMix is the relative weighting of the StorageRW read/write/rmw +// operations. The weights need not sum to anything in particular; a per-tx draw +// selects an operation in proportion to its weight over the total. An all-zero +// (or nil) mix falls back to rmw, the scaffold default. +type OperationMix struct { + Read uint64 `json:"read,omitempty"` + Write uint64 `json:"write,omitempty"` + Rmw uint64 `json:"rmw,omitempty"` + + holder opStreamHolder } diff --git a/config/operation.go b/config/operation.go new file mode 100644 index 0000000..910b516 --- /dev/null +++ b/config/operation.go @@ -0,0 +1,51 @@ +package config + +import ( + "math/rand/v2" + + "github.com/sei-protocol/sei-load/utils/rng" +) + +// Operation identifies one StorageRW contract method. +type Operation uint8 + +const ( + // OpRmw is the read-modify-write operation; it is the zero value so a + // zero/nil OperationMix selects rmw, matching the scaffold default. + OpRmw Operation = iota + OpRead + OpWrite +) + +// stream is set by SetStream; nil means draw from the unseeded global RNG. The +// pointer aliases on copy, matching GasPicker/Distribution. +type opStreamHolder struct { + stream *rng.Stream +} + +// SetStream binds the selector to a deterministic sub-stream (nil = unseeded +// global RNG), mirroring GasPicker.SetStream and Distribution.SetStream. +func (m *OperationMix) SetStream(s *rng.Stream) { m.holder.stream = s } + +// Select draws one operation in proportion to the configured weights. A zero +// total (all weights zero) falls back to OpRmw so an empty mix is the scaffold +// default rather than a panic. +func (m *OperationMix) Select() Operation { + total := m.Read + m.Write + m.Rmw + if total == 0 { + return OpRmw + } + var u uint64 + if m.holder.stream != nil { + u = m.holder.stream.Uint64N(total) + } else { + u = rand.Uint64N(total) + } + if u < m.Rmw { + return OpRmw + } + if u < m.Rmw+m.Read { + return OpRead + } + return OpWrite +} diff --git a/config/operation_test.go b/config/operation_test.go new file mode 100644 index 0000000..4cc2e6b --- /dev/null +++ b/config/operation_test.go @@ -0,0 +1,61 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" + "github.com/sei-protocol/sei-load/utils/rng" +) + +// TestOperationMixEmptyFallsBackToRmw: a zero-weight (or nil-equivalent) mix +// selects rmw, the scaffold default, rather than panicking on a zero total. +func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { + t.Parallel() + var m config.OperationMix + m.SetStream(rng.NewSource(1).Stream(rng.OpDistributionStream(0))) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRmw, m.Select()) + } +} + +// TestOperationMixHonorsWeights: a single-weighted op is selected exclusively, +// and a balanced mix selects all three. +func TestOperationMixHonorsWeights(t *testing.T) { + t.Parallel() + t.Run("single", func(t *testing.T) { + m := config.OperationMix{Read: 1} + m.SetStream(rng.NewSource(1).Stream(rng.OpDistributionStream(0))) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRead, m.Select()) + } + }) + t.Run("balanced", func(t *testing.T) { + m := config.OperationMix{Read: 1, Write: 1, Rmw: 1} + m.SetStream(rng.NewSource(1).Stream(rng.OpDistributionStream(0))) + seen := map[config.Operation]int{} + for i := 0; i < 3000; i++ { + seen[m.Select()]++ + } + require.Positive(t, seen[config.OpRead]) + require.Positive(t, seen[config.OpWrite]) + require.Positive(t, seen[config.OpRmw]) + }) +} + +// TestOperationMixDeterminism: same seed + same stream id reproduces the +// selection sequence. +func TestOperationMixDeterminism(t *testing.T) { + t.Parallel() + draw := func() []config.Operation { + m := config.OperationMix{Read: 2, Write: 3, Rmw: 5} + m.SetStream(rng.NewSource(99).Stream(rng.OpDistributionStream(0))) + out := make([]config.Operation, 256) + for i := range out { + out[i] = m.Select() + } + return out + } + require.Equal(t, draw(), draw()) +} diff --git a/generator/generator.go b/generator/generator.go index 7a8b04d..a60c1c1 100644 --- a/generator/generator.go +++ b/generator/generator.go @@ -152,6 +152,9 @@ func (g *configBasedGenerator) bindDistributionStreams(i int, cfg config.Scenari if cfg.SizeDistribution != nil { cfg.SizeDistribution.SetStream(g.rng.Stream(rng.SizeDistributionStream(i))) } + if cfg.Operations != nil { + cfg.Operations.SetStream(g.rng.Stream(rng.OpDistributionStream(i))) + } } // mockDeployAll deploys all scenario instances that require deployment (for unit tests). diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index e8d5a03..a598a38 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -15,12 +15,23 @@ import ( const StorageRW = "storagerw" -// Fixed slot and empty pad for the scaffold; PLT-465 makes these per-tx. -var ( - storageRWSlot = big.NewInt(0) - storageRWPad = []byte{} +const ( + // storageRWBaseGas covers the cold-first-touch rmw (cold SLOAD + zero->nonzero + // SSTORE, ~44k) plus the fixed calldata head, with headroom. The distribution- + // driven pad's intrinsic cost is added on top per-tx; see package doc. + storageRWBaseGas = 50000 + // calldataZeroByteGas is the EIP-2028 intrinsic cost of one zero calldata byte. + // The pad is a zero-filled slice, so each pad byte costs exactly this. + calldataZeroByteGas = 4 + // storageRWWriteValue is the constant value write() stores; the load contract + // never asserts on it. + storageRWWriteValue = 1 ) +// storageRWDefaultSlot is the single slot every tx targets when no key +// distribution is configured (the scaffold's 100%-conflict default). +var storageRWDefaultSlot = big.NewInt(0) + // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations type StorageRWScenario struct { *ContractScenarioBase[bindings.StorageRWv1] @@ -77,12 +88,70 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add return err } -// CreateContractTransaction implements ContractDeployer interface - creates a -// fixed StorageRWv1 rmw transaction. See package doc for the scaffold and gas -// rationale. +// CreateContractTransaction implements ContractDeployer interface - builds one +// StorageRWv1 transaction whose slot (key contention), operation, and calldata +// pad (tx size) are drawn from the configured distributions. With no +// distribution config it reproduces the scaffold's single-slot empty-pad rmw. +// See package doc for the 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) + slot, err := s.pickSlot() + if err != nil { + return nil, err + } + pad, err := s.pickPad() + if err != nil { + return nil, err + } + + // The pad's intrinsic calldata cost is the only gas the base does not already + // cover; add it so a large pad cannot underprovision the tx. + auth.GasLimit = storageRWBaseGas + uint64(len(pad))*calldataZeroByteGas + + switch s.pickOp() { + case config.OpRead: + return s.contract.Read(auth, slot, pad) + case config.OpWrite: + return s.contract.Write(auth, slot, big.NewInt(storageRWWriteValue), pad) + default: + return s.contract.Rmw(auth, slot, pad) + } +} + +// pickSlot draws the storage slot from the key distribution over the configured +// RecordCount keyspace. With no key distribution it returns the fixed default +// slot, preserving the scaffold's 100%-conflict behavior. +func (s *StorageRWScenario) pickSlot() (*big.Int, error) { + cfg := s.scenarioConfig + if cfg.KeyDistribution == nil || cfg.RecordCount == 0 { + return storageRWDefaultSlot, nil + } + idx, err := cfg.KeyDistribution.SampleIndex(cfg.RecordCount) + if err != nil { + return nil, err + } + return new(big.Int).SetUint64(idx), nil +} + +// pickPad draws the calldata pad length from the size distribution over the +// configured SizeBuckets histogram, on a sub-stream independent of the key draw. +// With no size distribution it returns an empty pad, preserving the scaffold. +func (s *StorageRWScenario) pickPad() ([]byte, error) { + cfg := s.scenarioConfig + if cfg.SizeDistribution == nil || len(cfg.SizeBuckets) == 0 { + return nil, nil + } + bucket, err := cfg.SizeDistribution.SampleIndex(uint64(len(cfg.SizeBuckets))) + if err != nil { + return nil, err + } + return make([]byte, cfg.SizeBuckets[bucket]), nil +} + +// pickOp selects read/write/rmw from the configured mix on its own independent +// sub-stream. With no mix it returns rmw, preserving the scaffold. +func (s *StorageRWScenario) pickOp() config.Operation { + if s.scenarioConfig.Operations == nil { + return config.OpRmw + } + return s.scenarioConfig.Operations.Select() } diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 5ddde6e..a48141c 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -1,14 +1,18 @@ package scenarios_test import ( + "encoding/json" + "math/big" "testing" + "github.com/ethereum/go-ethereum/accounts/abi" "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" + "github.com/sei-protocol/sei-load/utils/rng" ) // rmwSelector is the 4-byte function selector for StorageRWv1.rmw(uint256,bytes). @@ -74,3 +78,254 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.NoError(t, err) require.Equal(t, rmwSelector, parsed.Methods["rmw"].ID) } + +// storageRWABI returns the parsed StorageRWv1 ABI for calldata decoding. +func storageRWABI(t *testing.T) *abi.ABI { + t.Helper() + parsed, err := bindings.StorageRWv1MetaData.GetAbi() + require.NoError(t, err) + return parsed +} + +// decodeStorageRWTx decodes one produced tx into (method name, slot, pad-length). +// It identifies the method by its 4-byte selector and unpacks the operands so a +// test can assert on the slot draw and pad size without reimplementing ABI rules. +func decodeStorageRWTx(t *testing.T, parsed *abi.ABI, data []byte) (string, uint64, int) { + t.Helper() + require.GreaterOrEqual(t, len(data), 4) + method, err := parsed.MethodById(data[:4]) + require.NoError(t, err) + args, err := method.Inputs.Unpack(data[4:]) + require.NoError(t, err) + slot := args[0].(*big.Int) + pad := args[len(args)-1].([]byte) // _pad is always the trailing operand. + return method.Name, slot.Uint64(), len(pad) +} + +// newConfiguredStorageRW builds a mock-deployed StorageRW scenario from cfg and +// binds its distribution streams the way generator.bindDistributionStreams does, +// so the produced txs are seeded deterministically. +func newConfiguredStorageRW(t *testing.T, seed uint64, cfg config.Scenario) scenarios.TxGenerator { + t.Helper() + cfg.Name = scenarios.StorageRW + src := rng.NewSource(seed) + if cfg.KeyDistribution != nil { + cfg.KeyDistribution.SetStream(src.Stream(rng.KeyDistributionStream(0))) + } + if cfg.SizeDistribution != nil { + cfg.SizeDistribution.SetStream(src.Stream(rng.SizeDistributionStream(0))) + } + if cfg.Operations != nil { + cfg.Operations.SetStream(src.Stream(rng.OpDistributionStream(0))) + } + gen := scenarios.CreateScenario(cfg) + loadCfg := &config.LoadConfig{ChainID: 7777, MockDeploy: true, Endpoints: []string{"http://localhost:8545"}} + require.NoError(t, gen.Attach(loadCfg, types.GenerateAccounts(1)[0].Address)) + return gen +} + +// drawSlots produces count txs and returns the slot drawn for each. +func drawSlots(t *testing.T, gen scenarios.TxGenerator, parsed *abi.ABI, count int) []uint64 { + t.Helper() + sender := types.GenerateAccounts(1)[0] + slots := make([]uint64, count) + for i := range slots { + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + _, slot, _ := decodeStorageRWTx(t, parsed, tx.EthTx.Data()) + slots[i] = slot + } + return slots +} + +func uniformDist(t *testing.T) *config.Distribution { + t.Helper() + var d config.Distribution + require.NoError(t, d.UnmarshalJSON([]byte(`{"Name":"uniform"}`))) + return &d +} + +// TestStorageRWContentionSweep pins the contention continuum at its two ends: a +// uniform draw over a huge keyspace collides almost never, and the default +// single-slot config collides always. The assertion is on the generator's slot +// draw, not on-chain state. +func TestStorageRWContentionSweep(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + const draws = 2000 + + t.Run("huge_uniform_keyspace_near_zero_collision", func(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 1, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 1_000_000, + }) + slots := drawSlots(t, gen, parsed, draws) + seen := make(map[uint64]int, draws) + for _, s := range slots { + seen[s]++ + } + // Birthday collisions over a 1e6 keyspace with 2000 draws are a handful; + // require >99% distinct so a regression that collapsed the keyspace fails. + require.Greater(t, len(seen), int(float64(draws)*0.99), + "uniform over a huge keyspace must barely collide; got %d distinct of %d", len(seen), draws) + }) + + t.Run("single_slot_full_collision", func(t *testing.T) { + t.Parallel() + // No key distribution => scaffold single-slot default: every draw is slot 0. + gen := newConfiguredStorageRW(t, 1, config.Scenario{}) + slots := drawSlots(t, gen, parsed, draws) + for _, s := range slots { + require.Zero(t, s, "default config must target the single fixed slot") + } + }) +} + +// TestStorageRWSizeBuckets proves the size distribution selects calldata pad +// lengths from the configured bucket histogram and only from those buckets. +func TestStorageRWSizeBuckets(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + buckets := []int{0, 64, 256, 1024} + allowed := make(map[int]bool, len(buckets)) + for _, b := range buckets { + allowed[b] = true + } + + gen := newConfiguredStorageRW(t, 7, config.Scenario{ + SizeDistribution: uniformDist(t), + SizeBuckets: buckets, + }) + + sender := types.GenerateAccounts(1)[0] + const draws = 4000 + hits := make(map[int]int, len(buckets)) + for i := 0; i < draws; i++ { + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + _, _, padLen := decodeStorageRWTx(t, parsed, tx.EthTx.Data()) + require.True(t, allowed[padLen], "pad length %d not in configured buckets", padLen) + hits[padLen]++ + } + // Uniform over 4 buckets: each should be well-represented (no empty bucket), + // proving the size draw actually spans the histogram. + for _, b := range buckets { + require.Positive(t, hits[b], "bucket %d never selected under uniform size dist", b) + } +} + +// TestStorageRWKeySizeIndependence is the core trap guard: changing the size +// distribution must not perturb the key sequence. Same seed + same key config +// must yield an identical slot sequence regardless of the size config. +func TestStorageRWKeySizeIndependence(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + const seed, draws = 42, 500 + + keyOnly := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + }) + withSize := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + SizeDistribution: uniformDist(t), + SizeBuckets: []int{0, 128, 512}, + }) + + require.Equal(t, + drawSlots(t, keyOnly, parsed, draws), + drawSlots(t, withSize, parsed, draws), + "adding a size distribution must not change the key draw sequence") +} + +// TestStorageRWOpIndependence guards that op selection rides its own sub-stream: +// configuring an op mix must not change the key draw sequence. +func TestStorageRWOpIndependence(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + const seed, draws = 42, 500 + + keyOnly := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + }) + withOps := newConfiguredStorageRW(t, seed, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 100_000, + Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + }) + + require.Equal(t, + drawSlots(t, keyOnly, parsed, draws), + drawSlots(t, withOps, parsed, draws), + "adding an operation mix must not change the key draw sequence") +} + +// TestStorageRWOpMix proves the operation selector honors the configured mix: +// all three methods appear when all three are weighted, and a single-op mix +// produces only that op. +func TestStorageRWOpMix(t *testing.T) { + t.Parallel() + parsed := storageRWABI(t) + sender := types.GenerateAccounts(1)[0] + + countOps := func(gen scenarios.TxGenerator, draws int) map[string]int { + out := map[string]int{} + for i := 0; i < draws; i++ { + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + name, _, _ := decodeStorageRWTx(t, parsed, tx.EthTx.Data()) + out[name]++ + } + return out + } + + t.Run("all_three_appear", func(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 3, config.Scenario{ + Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + }) + got := countOps(gen, 3000) + require.Positive(t, got["read"]) + require.Positive(t, got["write"]) + require.Positive(t, got["rmw"]) + }) + + t.Run("single_op_only", func(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 3, config.Scenario{ + Operations: &config.OperationMix{Write: 1}, + }) + got := countOps(gen, 500) + require.Equal(t, 500, got["write"]) + require.Len(t, got, 1, "single-op mix must produce only that op") + }) +} + +// TestStorageRWDefaultPathByteIdentical pins the additive guarantee: a scenario +// with no distribution config produces calldata byte-identical to the PLT-461 +// scaffold (fixed slot 0, empty pad, rmw) for a fixed sender/nonce. +func TestStorageRWDefaultPathByteIdentical(t *testing.T) { + t.Parallel() + gen := newConfiguredStorageRW(t, 99, config.Scenario{}) + sender := types.GenerateAccounts(1)[0] + tx := gen.Generate(&types.TxScenario{Name: scenarios.StorageRW, Sender: sender}) + + data := tx.EthTx.Data() + require.Equal(t, rmwSelector, data[:4]) + body := data[4:] + require.Len(t, body, 96) + want := make([]byte, 96) + want[63] = 0x40 // offset to the empty _pad bytes argument. + require.Equal(t, want, body) +} + +// TestStorageRWScenarioConfigAdditive proves the new fields are omitempty: a +// scenario carrying none of them round-trips without introducing their keys. +func TestStorageRWScenarioConfigAdditive(t *testing.T) { + t.Parallel() + out, err := json.Marshal(config.Scenario{Name: scenarios.StorageRW}) + require.NoError(t, err) + for _, key := range []string{"recordCount", "sizeBuckets", "operations"} { + require.NotContains(t, string(out), key) + } +} diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 8d1815c..8808f5d 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -41,22 +41,28 @@ // are emitted by `make generate` from the contract bindings — do not edit that // block by hand. // -// # StorageRW scaffold +// # StorageRW // -// 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. +// StorageRW exercises the SLOAD + SSTORE storage path under load against +// StorageRWv1. PLT-465 turns it into the two customer-named axes: key contention +// and tx size. Per tx the scenario draws a slot from the key distribution over +// the configured RecordCount keyspace, an operation (read/write/rmw) from the +// configured mix, and a calldata-pad length from the size distribution over the +// configured SizeBuckets histogram. The three draws ride independent rng +// sub-streams (dist:i:key, dist:i:op, dist:i:size) so tuning any one axis leaves +// the others' sequences identical. Each field is nil-guarded exactly like the +// gas pickers: with no distribution config the scenario reproduces the PLT-461 +// scaffold byte-for-byte — single slot 0, empty pad, rmw. // // 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 +// charge their higher rates). The base GasLimit is pinned to 50k, which covers +// the cold-first-touch case with headroom for the fixed calldata head 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. +// block space the rmw never spends and throttles achievable throughput. The +// distribution-driven pad adds its own intrinsic calldata cost (4 gas per +// zero pad byte, EIP-2028) on top of the base so a large pad cannot +// underprovision the tx, without inflating the limit when the pad is empty. package scenarios diff --git a/utils/rng/rng.go b/utils/rng/rng.go index 24ea692..9967a0e 100644 --- a/utils/rng/rng.go +++ b/utils/rng/rng.go @@ -43,7 +43,8 @@ import ( // streamID feeds fnv1a64, so renaming "gas:0:base" reseeds that stream. // Additions are append-only and do not perturb existing streams (a new id // hashes to its own sub-stream); PLT-460 added "dist:%d:key" and -// "dist:%d:size" for the per-scenario distribution index samplers. +// "dist:%d:size" for the per-scenario distribution index samplers, and +// PLT-465 added "dist:%d:op" for the per-scenario operation-mix selector. // 3. The per-stream draw order. Each stream is a sequence; drawing base before // tip before feecap is part of the contract — reordering draws within a // stream shifts every downstream value. diff --git a/utils/rng/rng_test.go b/utils/rng/rng_test.go index 693bc8b..4e443ae 100644 --- a/utils/rng/rng_test.go +++ b/utils/rng/rng_test.go @@ -25,6 +25,25 @@ func TestSameSeedSameStreamReproduces(t *testing.T) { } } +// TestDistributionStreamsAreDistinct pins that scenario i's key, size, and op +// distribution streams are mutually distinct ids. Sharing any two would couple +// the axes — drawing a size or op would perturb the key sequence — which the +// StorageRW independence tests rely on being impossible. +func TestDistributionStreamsAreDistinct(t *testing.T) { + ids := map[string]string{ + "key": KeyDistributionStream(0), + "size": SizeDistributionStream(0), + "op": OpDistributionStream(0), + } + seen := map[string]string{} + for name, id := range ids { + if prev, dup := seen[id]; dup { + t.Fatalf("stream id %q shared by %s and %s", id, prev, name) + } + seen[id] = name + } +} + func TestDifferentStreamsDiverge(t *testing.T) { a := drawSeq(42, "gas:0:base", 64) b := drawSeq(42, "gas:1:base", 64) diff --git a/utils/rng/streams.go b/utils/rng/streams.go index 4f4aa60..becb53b 100644 --- a/utils/rng/streams.go +++ b/utils/rng/streams.go @@ -36,3 +36,8 @@ func KeyDistributionStream(i int) string { return fmt.Sprintf("dist:%d:key", i) // SizeDistributionStream is the stream id for scenario i's size-distribution // index sampler (PLT-460). func SizeDistributionStream(i int) string { return fmt.Sprintf("dist:%d:size", i) } + +// OpDistributionStream is the stream id for scenario i's operation-mix selector +// (PLT-465). Distinct from the key and size streams so the op draw is +// independent: changing the op mix must not perturb the key or size sequence. +func OpDistributionStream(i int) string { return fmt.Sprintf("dist:%d:op", i) } From ca2b957eec143625b9a551730e36c3e25d4b2109 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 15 Jun 2026 15:50:54 -0700 Subject: [PATCH 2/3] fix(config): validate SizeBuckets + inline op-stream field (cohort review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Scenario.Validate rejects SizeBuckets[i] < 0 (make([]byte,-1) panic on the pickPad hot path) and > maxCalldataPadBytes (1 MiB OOM guard); wired via LoadConfig.ValidateScenarios in loadConfig, mirroring ValidateFunding. - Inline OperationMix.stream (drop the one-field opStreamHolder wrapper) to match GasPicker/Distribution; move the mis-subjected doc comment to the field. No change to draw behavior — byte-identical-default + independence tests green. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 38 +++++++++++++++++++++++++++++++++++++- config/config_test.go | 29 +++++++++++++++++++++++++++++ config/operation.go | 12 +++--------- main.go | 4 ++++ 4 files changed, 73 insertions(+), 10 deletions(-) create mode 100644 config/config_test.go diff --git a/config/config.go b/config/config.go index ebcaece..db90fb1 100644 --- a/config/config.go +++ b/config/config.go @@ -5,6 +5,8 @@ import ( "fmt" "math/big" "time" + + "github.com/sei-protocol/sei-load/utils/rng" ) // LoadConfig stores the configuration for load-related settings. @@ -94,6 +96,38 @@ type Scenario struct { Operations *OperationMix `json:"operations,omitempty"` } +// maxCalldataPadBytes caps each SizeBuckets entry. It is a generous guard +// against a config typo (e.g. a stray extra digit OOMing the generator on the +// make([]byte, n) hot path), not a security boundary: configs are +// author-controlled today. +const maxCalldataPadBytes = 1 << 20 // 1 MiB + +// Validate checks per-scenario invariants that a malformed config would +// otherwise surface as a hot-path panic or OOM. Mirrors ZipfianDistribution's +// parameter validation; call once after the config is loaded. +func (s *Scenario) Validate() error { + for i, n := range s.SizeBuckets { + if n < 0 { + return fmt.Errorf("scenario %q: sizeBuckets[%d] is negative (%d)", s.Name, i, n) + } + if n > maxCalldataPadBytes { + return fmt.Errorf("scenario %q: sizeBuckets[%d]=%d exceeds the %d-byte cap", s.Name, i, n, maxCalldataPadBytes) + } + } + return nil +} + +// ValidateScenarios runs each scenario's Validate. It must be called after the +// config is loaded. +func (c *LoadConfig) ValidateScenarios() error { + for i := range c.Scenarios { + if err := c.Scenarios[i].Validate(); err != nil { + return err + } + } + return nil +} + // OperationMix is the relative weighting of the StorageRW read/write/rmw // operations. The weights need not sum to anything in particular; a per-tx draw // selects an operation in proportion to its weight over the total. An all-zero @@ -103,5 +137,7 @@ type OperationMix struct { Write uint64 `json:"write,omitempty"` Rmw uint64 `json:"rmw,omitempty"` - holder opStreamHolder + // stream is set by SetStream; nil draws from the unseeded global RNG. The + // pointer aliases on copy, matching GasPicker/Distribution. + stream *rng.Stream } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..f450886 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestScenarioValidateSizeBuckets: a negative pad length (makeslice panic on the +// hot path) and an over-cap pad length (OOM risk) are both rejected; a valid +// histogram, the cap boundary, and an empty/nil bucket list pass. +func TestScenarioValidateSizeBuckets(t *testing.T) { + t.Parallel() + t.Run("negative rejected", func(t *testing.T) { + s := Scenario{Name: "s", SizeBuckets: []int{0, -1}} + require.ErrorContains(t, s.Validate(), "negative") + }) + t.Run("over cap rejected", func(t *testing.T) { + s := Scenario{Name: "s", SizeBuckets: []int{maxCalldataPadBytes + 1}} + require.ErrorContains(t, s.Validate(), "cap") + }) + t.Run("valid accepted", func(t *testing.T) { + s := Scenario{Name: "s", SizeBuckets: []int{0, 64, maxCalldataPadBytes}} + require.NoError(t, s.Validate()) + }) + t.Run("empty accepted", func(t *testing.T) { + require.NoError(t, (&Scenario{Name: "s"}).Validate()) + }) +} diff --git a/config/operation.go b/config/operation.go index 910b516..8fefd24 100644 --- a/config/operation.go +++ b/config/operation.go @@ -17,15 +17,9 @@ const ( OpWrite ) -// stream is set by SetStream; nil means draw from the unseeded global RNG. The -// pointer aliases on copy, matching GasPicker/Distribution. -type opStreamHolder struct { - stream *rng.Stream -} - // SetStream binds the selector to a deterministic sub-stream (nil = unseeded // global RNG), mirroring GasPicker.SetStream and Distribution.SetStream. -func (m *OperationMix) SetStream(s *rng.Stream) { m.holder.stream = s } +func (m *OperationMix) SetStream(s *rng.Stream) { m.stream = s } // Select draws one operation in proportion to the configured weights. A zero // total (all weights zero) falls back to OpRmw so an empty mix is the scaffold @@ -36,8 +30,8 @@ func (m *OperationMix) Select() Operation { return OpRmw } var u uint64 - if m.holder.stream != nil { - u = m.holder.stream.Uint64N(total) + if m.stream != nil { + u = m.stream.Uint64N(total) } else { u = rand.Uint64N(total) } diff --git a/main.go b/main.go index 5baa309..483d3a7 100644 --- a/main.go +++ b/main.go @@ -419,6 +419,10 @@ func loadConfig(filename string) (*config.LoadConfig, error) { return nil, fmt.Errorf("no scenarios specified in config") } + if err := cfg.ValidateScenarios(); err != nil { + return nil, err + } + if err := cfg.ValidateFunding(); err != nil { return nil, err } From a541fba8ec6e68ef8609c3b60aca5df6a143951e Mon Sep 17 00:00:00 2001 From: bdchatham Date: Mon, 15 Jun 2026 16:34:42 -0700 Subject: [PATCH 3/3] docs(generator): comment-discipline sweep (PLT-465) De-changelog the doc.go StorageRW narrative (drop 'PLT-465 turns it into', 'PLT-461 scaffold byte-for-byte') to a timeless present-tense description, and replace the now-dangling era term 'scaffold' with 'default' in inline default-path notes. Keep load-bearing comments (FROZEN append-only stream-id ledger, EIP-2028 gas-per-byte why, nil-guard/default invariants, the no-underprovision note). Comment-only. Co-Authored-By: Claude Opus 4.8 --- config/config.go | 8 ++++---- config/operation.go | 6 +++--- config/operation_test.go | 2 +- generator/scenarios/StorageRW.go | 10 +++++----- generator/scenarios/StorageRW_test.go | 8 ++++---- generator/scenarios/doc.go | 18 +++++++++--------- 6 files changed, 26 insertions(+), 26 deletions(-) diff --git a/config/config.go b/config/config.go index db90fb1..8d341d3 100644 --- a/config/config.go +++ b/config/config.go @@ -85,14 +85,14 @@ type Scenario struct { SizeDistribution *Distribution `json:"sizeDistribution,omitempty"` // RecordCount is the keyspace size the KeyDistribution indexes into: the // per-tx slot is a draw in [0, RecordCount). Zero (the default) is the - // single-slot, 100%-conflict scaffold behavior. + // single-slot, 100%-conflict behavior. RecordCount uint64 `json:"recordCount,omitempty"` // SizeBuckets is the calldata-pad-length histogram the SizeDistribution // indexes into: the per-tx pad length is SizeBuckets[draw]. Empty (the - // default) is the empty-pad scaffold behavior. + // default) is the empty-pad behavior. SizeBuckets []int `json:"sizeBuckets,omitempty"` // Operations is the read/write/rmw selection mix. Nil (the default) is the - // all-rmw scaffold behavior. + // all-rmw behavior. Operations *OperationMix `json:"operations,omitempty"` } @@ -131,7 +131,7 @@ func (c *LoadConfig) ValidateScenarios() error { // OperationMix is the relative weighting of the StorageRW read/write/rmw // operations. The weights need not sum to anything in particular; a per-tx draw // selects an operation in proportion to its weight over the total. An all-zero -// (or nil) mix falls back to rmw, the scaffold default. +// (or nil) mix falls back to rmw, the default. type OperationMix struct { Read uint64 `json:"read,omitempty"` Write uint64 `json:"write,omitempty"` diff --git a/config/operation.go b/config/operation.go index 8fefd24..d893dfb 100644 --- a/config/operation.go +++ b/config/operation.go @@ -11,7 +11,7 @@ type Operation uint8 const ( // OpRmw is the read-modify-write operation; it is the zero value so a - // zero/nil OperationMix selects rmw, matching the scaffold default. + // zero/nil OperationMix selects rmw, matching the default. OpRmw Operation = iota OpRead OpWrite @@ -22,8 +22,8 @@ const ( func (m *OperationMix) SetStream(s *rng.Stream) { m.stream = s } // Select draws one operation in proportion to the configured weights. A zero -// total (all weights zero) falls back to OpRmw so an empty mix is the scaffold -// default rather than a panic. +// total (all weights zero) falls back to OpRmw so an empty mix is the default +// rather than a panic. func (m *OperationMix) Select() Operation { total := m.Read + m.Write + m.Rmw if total == 0 { diff --git a/config/operation_test.go b/config/operation_test.go index 4cc2e6b..38b7c21 100644 --- a/config/operation_test.go +++ b/config/operation_test.go @@ -10,7 +10,7 @@ import ( ) // TestOperationMixEmptyFallsBackToRmw: a zero-weight (or nil-equivalent) mix -// selects rmw, the scaffold default, rather than panicking on a zero total. +// selects rmw, the default, rather than panicking on a zero total. func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { t.Parallel() var m config.OperationMix diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index a598a38..7fec0ff 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -29,7 +29,7 @@ const ( ) // storageRWDefaultSlot is the single slot every tx targets when no key -// distribution is configured (the scaffold's 100%-conflict default). +// distribution is configured — the 100%-conflict default. var storageRWDefaultSlot = big.NewInt(0) // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations @@ -91,7 +91,7 @@ func (s *StorageRWScenario) Attach(config *config.LoadConfig, address common.Add // CreateContractTransaction implements ContractDeployer interface - builds one // StorageRWv1 transaction whose slot (key contention), operation, and calldata // pad (tx size) are drawn from the configured distributions. With no -// distribution config it reproduces the scaffold's single-slot empty-pad rmw. +// distribution config it falls back to a single-slot empty-pad rmw. // See package doc for the gas rationale. func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { slot, err := s.pickSlot() @@ -119,7 +119,7 @@ func (s *StorageRWScenario) CreateContractTransaction(auth *bind.TransactOpts, s // pickSlot draws the storage slot from the key distribution over the configured // RecordCount keyspace. With no key distribution it returns the fixed default -// slot, preserving the scaffold's 100%-conflict behavior. +// slot — the 100%-conflict default. func (s *StorageRWScenario) pickSlot() (*big.Int, error) { cfg := s.scenarioConfig if cfg.KeyDistribution == nil || cfg.RecordCount == 0 { @@ -134,7 +134,7 @@ func (s *StorageRWScenario) pickSlot() (*big.Int, error) { // pickPad draws the calldata pad length from the size distribution over the // configured SizeBuckets histogram, on a sub-stream independent of the key draw. -// With no size distribution it returns an empty pad, preserving the scaffold. +// With no size distribution it returns an empty pad. func (s *StorageRWScenario) pickPad() ([]byte, error) { cfg := s.scenarioConfig if cfg.SizeDistribution == nil || len(cfg.SizeBuckets) == 0 { @@ -148,7 +148,7 @@ func (s *StorageRWScenario) pickPad() ([]byte, error) { } // pickOp selects read/write/rmw from the configured mix on its own independent -// sub-stream. With no mix it returns rmw, preserving the scaffold. +// sub-stream. With no mix it returns rmw. func (s *StorageRWScenario) pickOp() config.Operation { if s.scenarioConfig.Operations == nil { return config.OpRmw diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index a48141c..a8678a2 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -63,7 +63,7 @@ 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 + // Pin the fixed default-path 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. @@ -172,7 +172,7 @@ func TestStorageRWContentionSweep(t *testing.T) { t.Run("single_slot_full_collision", func(t *testing.T) { t.Parallel() - // No key distribution => scaffold single-slot default: every draw is slot 0. + // No key distribution => single-slot default: every draw is slot 0. gen := newConfiguredStorageRW(t, 1, config.Scenario{}) slots := drawSlots(t, gen, parsed, draws) for _, s := range slots { @@ -302,8 +302,8 @@ func TestStorageRWOpMix(t *testing.T) { } // TestStorageRWDefaultPathByteIdentical pins the additive guarantee: a scenario -// with no distribution config produces calldata byte-identical to the PLT-461 -// scaffold (fixed slot 0, empty pad, rmw) for a fixed sender/nonce. +// with no distribution config produces the fixed default-path calldata (slot 0, +// empty pad, rmw), byte-identical for a fixed sender/nonce. func TestStorageRWDefaultPathByteIdentical(t *testing.T) { t.Parallel() gen := newConfiguredStorageRW(t, 99, config.Scenario{}) diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 8808f5d..dc8d094 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -44,15 +44,15 @@ // # StorageRW // // StorageRW exercises the SLOAD + SSTORE storage path under load against -// StorageRWv1. PLT-465 turns it into the two customer-named axes: key contention -// and tx size. Per tx the scenario draws a slot from the key distribution over -// the configured RecordCount keyspace, an operation (read/write/rmw) from the -// configured mix, and a calldata-pad length from the size distribution over the -// configured SizeBuckets histogram. The three draws ride independent rng -// sub-streams (dist:i:key, dist:i:op, dist:i:size) so tuning any one axis leaves -// the others' sequences identical. Each field is nil-guarded exactly like the -// gas pickers: with no distribution config the scenario reproduces the PLT-461 -// scaffold byte-for-byte — single slot 0, empty pad, rmw. +// StorageRWv1 along two customer-named axes: key contention and tx size. Per tx +// the scenario draws a slot from the key distribution over the configured +// RecordCount keyspace, an operation (read/write/rmw) from the configured mix, +// and a calldata-pad length from the size distribution over the configured +// SizeBuckets histogram. The three draws ride independent rng sub-streams +// (dist:i:key, dist:i:op, dist:i:size) so tuning any one axis leaves the others' +// sequences identical. Each field is nil-guarded exactly like the gas pickers: +// with no distribution config the scenario degenerates to a single fixed slot 0, +// an empty pad, and rmw — the 100%-conflict baseline. // // 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