From 8891ac4a7ef1989b0e8432151124fbb19421c4b3 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Wed, 19 Aug 2026 19:34:09 -0700 Subject: [PATCH] feat(generator): wire key, size, and operation distributions into StorageRW MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit StorageRW drew a fixed slot with an empty pad and always issued rmw. It now draws all three per transaction from the scenario config: the slot from KeyDistribution over a RecordCount keyspace, the pad length from SizeDistribution over the SizeBuckets histogram, and the method from the Operations mix. That turns contention into a continuum instead of a binary. Every axis is optional and every default reproduces the previous behavior: one fixed slot, empty pad, rmw. An unconfigured scenario draws no randomness, so a profile that does not use these fields keeps its exact workload — guarded by a test that asserts the RNG is left untouched. The pad's intrinsic EIP-2028 calldata cost is added on top of the 50k base limit, so an empty pad is exactly the previous 50k and a large pad cannot underprovision the transaction. Config gains RecordCount, SizeBuckets, and Operations, all omitempty. Scenario validation rejects a negative or over-cap pad length at load rather than panicking makeslice or OOMing on the hot path. Co-Authored-By: Claude Opus 5 (1M context) --- config/config.go | 42 ++++++ config/config_test.go | 53 ++++++++ config/operation.go | 48 +++++++ config/operation_test.go | 104 +++++++++++++++ generator/scenarios/StorageRW.go | 94 ++++++++++++-- generator/scenarios/StorageRW_test.go | 178 ++++++++++++++++++++++++++ generator/scenarios/doc.go | 38 ++++-- main.go | 4 + 8 files changed, 536 insertions(+), 25 deletions(-) create mode 100644 config/config_test.go create mode 100644 config/operation.go create mode 100644 config/operation_test.go diff --git a/config/config.go b/config/config.go index 7b0c6c4..e455845 100644 --- a/config/config.go +++ b/config/config.go @@ -84,4 +84,46 @@ 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 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 behavior. + SizeBuckets []int `json:"sizeBuckets,omitempty"` + // Operations is the read/write/rmw selection mix. Nil, the default, is + // all-rmw. + Operations *OperationMix `json:"operations,omitempty"` +} + +// maxCalldataPadBytes caps each SizeBuckets entry. It guards against a config +// typo — a stray extra digit would OOM the generator on the make([]byte, n) hot +// path — and is not a security boundary: configs are author-controlled. +const maxCalldataPadBytes = 1 << 20 // 1 MiB + +// Validate checks the per-scenario invariants that a malformed config would +// otherwise surface as a hot-path panic or an OOM. Call it 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. Call it once 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 } diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 0000000..e6568cf --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,53 @@ +package config + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestScenarioValidateSizeBuckets: a negative pad length would panic makeslice +// on the hot path and an over-cap length risks an OOM, so both are rejected at +// load. A valid histogram, the cap boundary, and an absent bucket list pass. +func TestScenarioValidateSizeBuckets(t *testing.T) { + t.Parallel() + + t.Run("negative rejected", func(t *testing.T) { + t.Parallel() + s := Scenario{Name: "s", SizeBuckets: []int{0, -1}} + require.ErrorContains(t, s.Validate(), "negative") + }) + + t.Run("over cap rejected", func(t *testing.T) { + t.Parallel() + s := Scenario{Name: "s", SizeBuckets: []int{maxCalldataPadBytes + 1}} + require.ErrorContains(t, s.Validate(), "cap") + }) + + t.Run("valid accepted", func(t *testing.T) { + t.Parallel() + s := Scenario{Name: "s", SizeBuckets: []int{0, 64, maxCalldataPadBytes}} + require.NoError(t, s.Validate()) + }) + + t.Run("absent accepted", func(t *testing.T) { + t.Parallel() + require.NoError(t, (&Scenario{Name: "s"}).Validate()) + }) +} + +// TestValidateScenariosReportsOffendingScenario: validation runs across every +// scenario and the error names the one that failed, so an operator can find it +// in a multi-scenario profile. +func TestValidateScenariosReportsOffendingScenario(t *testing.T) { + t.Parallel() + + cfg := LoadConfig{Scenarios: []Scenario{ + {Name: "good", SizeBuckets: []int{0, 32}}, + {Name: "bad", SizeBuckets: []int{-1}}, + }} + require.ErrorContains(t, cfg.ValidateScenarios(), `scenario "bad"`) + + cfg.Scenarios[1].SizeBuckets = []int{16} + require.NoError(t, cfg.ValidateScenarios()) +} diff --git a/config/operation.go b/config/operation.go new file mode 100644 index 0000000..281820a --- /dev/null +++ b/config/operation.go @@ -0,0 +1,48 @@ +package config + +import ( + mrand "math/rand/v2" +) + +// 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-weight or absent OperationMix selects rmw, matching the default. + OpRmw Operation = iota + OpRead + OpWrite +) + +// 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 +// mix falls back to rmw, the default. +type OperationMix struct { + Read uint64 `json:"read,omitempty"` + Write uint64 `json:"write,omitempty"` + Rmw uint64 `json:"rmw,omitempty"` +} + +// Select draws one operation in proportion to the configured weights. A zero +// total falls back to OpRmw, so an empty mix is the default rather than a +// division by zero. +// +// The comparison order (rmw, then read, then write) fixes which weight owns +// which sub-range of the draw. It is arbitrary but must stay stable, because +// changing it changes which operation a given draw selects. +func (m *OperationMix) Select(rng *mrand.Rand) Operation { + total := m.Read + m.Write + m.Rmw + if total == 0 { + return OpRmw + } + switch u := rng.Uint64N(total); { + case u < m.Rmw: + return OpRmw + case u < m.Rmw+m.Read: + return OpRead + default: + return OpWrite + } +} diff --git a/config/operation_test.go b/config/operation_test.go new file mode 100644 index 0000000..eb5074e --- /dev/null +++ b/config/operation_test.go @@ -0,0 +1,104 @@ +package config_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/sei-protocol/sei-load/config" +) + +// TestOperationMixEmptyFallsBackToRmw: a zero-weight mix selects rmw, the +// default, rather than dividing by a zero total. +func TestOperationMixEmptyFallsBackToRmw(t *testing.T) { + t.Parallel() + var m config.OperationMix + rng := newTestRng(1) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRmw, m.Select(rng)) + } +} + +// TestOperationMixEmptyDrawsNoRandomness: the zero-weight fallback returns +// before touching the RNG, so an unconfigured mix cannot perturb other draws +// that share the same stream. +func TestOperationMixEmptyDrawsNoRandomness(t *testing.T) { + t.Parallel() + var m config.OperationMix + rng := newTestRng(5) + for i := 0; i < 100; i++ { + m.Select(rng) + } + require.Equal(t, newTestRng(5).Uint64(), rng.Uint64()) +} + +// TestOperationMixHonorsWeights: a single-weighted op is selected exclusively, +// and a balanced mix reaches all three. +func TestOperationMixHonorsWeights(t *testing.T) { + t.Parallel() + + t.Run("single weight is exclusive", func(t *testing.T) { + t.Parallel() + m := config.OperationMix{Read: 1} + rng := newTestRng(1) + for i := 0; i < 100; i++ { + require.Equal(t, config.OpRead, m.Select(rng)) + } + }) + + t.Run("balanced mix reaches every op", func(t *testing.T) { + t.Parallel() + m := config.OperationMix{Read: 1, Write: 1, Rmw: 1} + rng := newTestRng(1) + seen := map[config.Operation]int{} + for i := 0; i < 3000; i++ { + seen[m.Select(rng)]++ + } + require.Positive(t, seen[config.OpRead]) + require.Positive(t, seen[config.OpWrite]) + require.Positive(t, seen[config.OpRmw]) + }) +} + +// TestOperationMixApproximatesWeights: over many draws the selection converges +// on the configured proportions, which is the property a weighted workload +// depends on. +func TestOperationMixApproximatesWeights(t *testing.T) { + t.Parallel() + const draws = 100_000 + m := config.OperationMix{Rmw: 5, Read: 3, Write: 2} + rng := newTestRng(9) + + seen := map[config.Operation]int{} + for i := 0; i < draws; i++ { + seen[m.Select(rng)]++ + } + + // Total weight is 10, so each op should land within a point or so of its + // tenth. The tolerance is loose enough not to flake and tight enough to + // catch a mis-ordered comparison chain. + for op, wantWeight := range map[config.Operation]float64{ + config.OpRmw: 5, + config.OpRead: 3, + config.OpWrite: 2, + } { + got := float64(seen[op]) / draws * 10 + require.InDelta(t, wantWeight, got, 0.15, "operation %d proportion", op) + } +} + +// TestOperationMixDeterminism: the same seed reproduces the selection sequence, +// so a run is repeatable when the RNG is seeded. +func TestOperationMixDeterminism(t *testing.T) { + t.Parallel() + draw := func() []config.Operation { + m := config.OperationMix{Read: 2, Write: 3, Rmw: 5} + rng := newTestRng(99) + out := make([]config.Operation, 256) + for i := range out { + out[i] = m.Select(rng) + } + return out + } + require.Equal(t, draw(), draw()) +} diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index fdfa7df..ac4b13d 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -16,12 +16,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 (a cold SLOAD plus a + // zero-to-nonzero SSTORE, ~44k) and the fixed calldata head, with headroom. + // The pad's intrinsic cost is added per-tx on top; 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 100%-conflict default. +var storageRWDefaultSlot = big.NewInt(0) + // StorageRWScenario implements the TxGenerator interface for StorageRWv1 contract operations type StorageRWScenario struct { *ContractScenarioBase[bindings.StorageRWv1] @@ -78,12 +89,73 @@ 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), calldata pad (tx size), +// and operation are drawn from the configured distributions. With no +// distribution config it falls back to a single-slot empty-pad rmw, consuming +// no randomness. See package doc for the gas rationale. +// +// The draws run in a fixed order — slot, pad, operation — because all three +// share one RNG, so the order is part of the reproducibility contract. func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, 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(rng) + if err != nil { + return nil, err + } + pad, err := s.pickPad(rng) + if err != nil { + return nil, err + } + + // The pad's intrinsic calldata cost is the only gas the base does not + // already cover, so a large pad cannot underprovision the tx. + auth.GasLimit = storageRWBaseGas + uint64(len(pad))*calldataZeroByteGas + + switch s.pickOp(rng) { + 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 and consumes no randomness. +func (s *StorageRWScenario) pickSlot(rng *mrand.Rand) (*big.Int, error) { + cfg := s.scenarioConfig + if cfg.KeyDistribution == nil || cfg.RecordCount == 0 { + return storageRWDefaultSlot, nil + } + idx, err := cfg.KeyDistribution.SampleIndex(rng, 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. With no size distribution it returns an +// empty pad and consumes no randomness. +func (s *StorageRWScenario) pickPad(rng *mrand.Rand) ([]byte, error) { + cfg := s.scenarioConfig + if cfg.SizeDistribution == nil || len(cfg.SizeBuckets) == 0 { + return nil, nil + } + bucket, err := cfg.SizeDistribution.SampleIndex(rng, uint64(len(cfg.SizeBuckets))) + if err != nil { + return nil, err + } + return make([]byte, cfg.SizeBuckets[bucket]), nil +} + +// pickOp selects read, write, or rmw from the configured mix. With no mix it +// returns rmw and consumes no randomness. +func (s *StorageRWScenario) pickOp(rng *mrand.Rand) config.Operation { + if s.scenarioConfig.Operations == nil { + return config.OpRmw + } + return s.scenarioConfig.Operations.Select(rng) } diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index a66de82..61476a9 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -1,6 +1,8 @@ package scenarios_test import ( + "encoding/json" + "math/big" mrand "math/rand/v2" "testing" @@ -80,3 +82,179 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { require.NoError(t, err) require.Equal(t, rmwSelector, parsed.Methods["rmw"].ID) } + +// newAttachedStorageRW builds a StorageRW scenario from sc and attaches it at a +// known address under mock deploy, mirroring generator.mockDeployAll. It returns +// the generator and a tx scenario carrying a funded sender. +func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { + t.Helper() + sc.Name = scenarios.StorageRW + cfg := &config.LoadConfig{ + ChainID: 7777, + MockDeploy: true, + Endpoints: []string{"http://localhost:8545"}, + } + gen := scenarios.CreateScenario(sc) + require.NoError(t, gen.Attach(cfg, types.GenerateAccounts(1, false)[0].Address)) + return gen, &types.TxScenario{ + Name: scenarios.StorageRW, + Nonce: 0, + Sender: types.GenerateAccounts(1, true)[0], + } +} + +// decodeStorageRW unpacks StorageRW calldata through the binding's own ABI, so +// the assertions below read against method names rather than byte offsets. The +// slot is the first argument of every method and the pad is the last. +func decodeStorageRW(t *testing.T, data []byte) (method string, slot uint64, padLen int) { + t.Helper() + parsed, err := bindings.StorageRWv1MetaData.GetAbi() + require.NoError(t, err) + require.GreaterOrEqual(t, len(data), 4) + m, err := parsed.MethodById(data[:4]) + require.NoError(t, err) + args, err := m.Inputs.Unpack(data[4:]) + require.NoError(t, err) + require.GreaterOrEqual(t, len(args), 2) + return m.Name, args[0].(*big.Int).Uint64(), len(args[len(args)-1].([]byte)) +} + +// uniformDist unmarshals a uniform Distribution the way a profile would, so the +// tests exercise the same wire path operators use. +func uniformDist(t *testing.T) *config.Distribution { + t.Helper() + var d config.Distribution + require.NoError(t, json.Unmarshal([]byte(`{"Name":"uniform"}`), &d)) + return &d +} + +// TestStorageRWContentionSweep pins the contention continuum at both ends: a +// single-slot keyspace is 100% conflict, and a large keyspace spreads draws +// across many distinct slots. +func TestStorageRWContentionSweep(t *testing.T) { + t.Run("single slot keyspace is total conflict", func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: 1, + }) + rng := newTestRng(7) + for i := 0; i < 64; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + _, slot, _ := decodeStorageRW(t, tx.Data()) + require.Zero(t, slot) + } + }) + + t.Run("large keyspace spreads draws", func(t *testing.T) { + const keyspace = 10000 + gen, txs := newAttachedStorageRW(t, config.Scenario{ + KeyDistribution: uniformDist(t), + RecordCount: keyspace, + }) + rng := newTestRng(7) + seen := map[uint64]struct{}{} + for i := 0; i < 512; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + _, slot, _ := decodeStorageRW(t, tx.Data()) + require.Less(t, slot, uint64(keyspace)) + seen[slot] = struct{}{} + } + // 512 uniform draws over 10k slots collide rarely; anything near 1 slot + // would mean the keyspace is not being indexed. + require.Greater(t, len(seen), 400) + }) +} + +// TestStorageRWSizeBuckets proves the size distribution selects calldata pad +// lengths from the configured histogram, and that gas scales with the pad. +func TestStorageRWSizeBuckets(t *testing.T) { + buckets := []int{0, 128, 1024} + gen, txs := newAttachedStorageRW(t, config.Scenario{ + SizeDistribution: uniformDist(t), + SizeBuckets: buckets, + }) + + rng := newTestRng(11) + seen := map[int]struct{}{} + for i := 0; i < 256; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + _, _, padLen := decodeStorageRW(t, tx.Data()) + require.Contains(t, buckets, padLen) + seen[padLen] = struct{}{} + // Gas covers the base plus the pad's intrinsic calldata cost. + require.Equal(t, uint64(50000+padLen*4), tx.Gas()) + } + require.Len(t, seen, len(buckets), "every bucket should be drawn over 256 samples") +} + +// TestStorageRWOpMix proves the operation selector honors the configured mix: a +// single-weighted op is selected exclusively, and a balanced mix reaches all +// three methods. +func TestStorageRWOpMix(t *testing.T) { + t.Run("single weight is exclusive", func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + Operations: &config.OperationMix{Write: 1}, + }) + rng := newTestRng(3) + for i := 0; i < 64; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + method, _, _ := decodeStorageRW(t, tx.Data()) + require.Equal(t, "write", method) + } + }) + + t.Run("balanced mix reaches every method", func(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{ + Operations: &config.OperationMix{Read: 1, Write: 1, Rmw: 1}, + }) + rng := newTestRng(3) + seen := map[string]int{} + for i := 0; i < 600; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + method, _, _ := decodeStorageRW(t, tx.Data()) + seen[method]++ + } + require.Positive(t, seen["read"]) + require.Positive(t, seen["write"]) + require.Positive(t, seen["rmw"]) + }) +} + +// TestStorageRWDefaultPathUnchanged pins the additive guarantee: a scenario with +// no distribution config produces exactly the pre-existing fixed rmw transaction +// and draws no randomness, so adding these fields cannot perturb an existing +// profile's workload. +func TestStorageRWDefaultPathUnchanged(t *testing.T) { + gen, txs := newAttachedStorageRW(t, config.Scenario{}) + + rng := newTestRng(42) + for i := 0; i < 64; i++ { + tx, err := gen.Generate(rng, txs) + require.NoError(t, err) + + method, slot, padLen := decodeStorageRW(t, tx.Data()) + require.Equal(t, "rmw", method) + require.Zero(t, slot) + require.Zero(t, padLen) + require.Equal(t, uint64(50000), tx.Gas()) + } + + // The default path must consume no randomness: an untouched RNG at the same + // seed is still in lockstep with the one the generator was handed. + require.Equal(t, newTestRng(42).Uint64(), rng.Uint64()) +} + +// TestStorageRWScenarioConfigAdditive proves the new fields are omitempty, so a +// profile that does not set them round-trips without gaining keys. +func TestStorageRWScenarioConfigAdditive(t *testing.T) { + encoded, err := json.Marshal(config.Scenario{Name: scenarios.StorageRW, Weight: 1}) + require.NoError(t, err) + require.NotContains(t, string(encoded), "recordCount") + require.NotContains(t, string(encoded), "sizeBuckets") + require.NotContains(t, string(encoded), "operations") +} diff --git a/generator/scenarios/doc.go b/generator/scenarios/doc.go index 8d1815c..c50d2ac 100644 --- a/generator/scenarios/doc.go +++ b/generator/scenarios/doc.go @@ -41,22 +41,32 @@ // 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. Each +// transaction draws three things independently from the scenario config: the +// storage slot from KeyDistribution over a RecordCount-wide keyspace, the +// calldata pad length from SizeDistribution over the SizeBuckets histogram, and +// the method — read, write, or rmw — from the Operations mix. +// +// Those three axes are what make contention a continuum rather than a binary. A +// wide keyspace drawn uniformly approaches zero conflict; a single slot is total +// conflict; a zipfian draw sits anywhere between. +// +// Every axis is optional and every default is the pre-distribution behavior: one +// fixed slot, an empty pad, and rmw. An unconfigured scenario draws no randomness +// at all, so adding these fields to a profile that does not use them cannot +// perturb its workload. The draws run in a fixed order — slot, pad, operation — +// because they share one RNG, which makes the order part of the reproducibility +// contract. // // 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 +// ~44k on a cold first touch, where the cold SLOAD and the zero-to-nonzero SSTORE +// both charge their higher rates. The base limit is 50k, covering the +// cold-first-touch case with headroom, and the pad's intrinsic calldata cost is +// added on top per transaction so a large pad cannot underprovision. That 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. +// 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 transaction never spends and throttles achievable throughput. package scenarios diff --git a/main.go b/main.go index d22fea0..b95f72a 100644 --- a/main.go +++ b/main.go @@ -434,5 +434,9 @@ func loadConfig(filename string) (*config.LoadConfig, error) { return nil, err } + if err := cfg.ValidateScenarios(); err != nil { + return nil, err + } + return &cfg, nil }