diff --git a/generator/scenarios/StorageRW.go b/generator/scenarios/StorageRW.go index 5f2c1c4..57c85a5 100644 --- a/generator/scenarios/StorageRW.go +++ b/generator/scenarios/StorageRW.go @@ -22,6 +22,27 @@ const ( // storageRWWriteValue is the constant value write stores. The load contract // never asserts on it. storageRWWriteValue = 1 + + // storageRWReadHeadroom covers the shape no priced call can reach. + // + // read costs most when its target slot already holds a value and the + // accumulator does not: it pays a cold read and then a write from zero. A + // probe against an untouched slot reads zero and leaves the accumulator + // unchanged, which is the cheap shape, so nothing this scenario prices pays + // the expensive one. write and rmw both carry the write from zero that + // dominates it. What they miss is the cold read, which EIP-2929 prices at + // 2,100. + // + // That cost is not one Sei moves. The fork's chain config carries a single + // Sei-specific gas field and it is the zero-to-value store cost, so this + // stays stock wherever a run points. Doubling it leaves room for the + // accounting to shift without making the limit meaningfully looser. + // + // It is a constant rather than margin because gasMargin is configurable and + // Validate accepts 1. At 1 nothing would absorb this, and the first read of + // a written slot would land in a block having burned its whole limit, which + // is the failure this scenario's sizing exists to remove. + storageRWReadHeadroom = 4_200 ) // storageRWDefaultSlot is the single slot every tx targets when no key @@ -80,15 +101,6 @@ func (s *StorageRWScenario) SetContract(contract *bindings.StorageRWv1) { s.contract = contract } -// CreateContractTransaction implements ContractDeployer interface - builds one -// StorageRWv1 transaction whose slot (key contention), calldata pad (tx size), -// and operation are drawn from the scenario config. With none of the three -// configured it falls back to a single-slot empty-pad rmw and draws no -// randomness. See package doc for the gas rationale. -// -// The draws run in a fixed order: slot, then pad, then operation. That order -// must stay stable — all three share the run's single PRNG, so reordering them -// shifts every subsequent draw and diverges a replay at the same seed. // gasProbeSlot is the slot this scenario prices against. It sits outside any // keyspace a profile can configure, so the slot is untouched whatever a previous // run wrote, and write and rmw price their slot-from-zero shape. @@ -99,7 +111,7 @@ func (s *StorageRWScenario) SetContract(contract *bindings.StorageRWv1) { // produce: reading a zero slot leaves the accumulator unchanged, which is the // cheap shape. write and rmw both carry the slot-from-zero write that dominates // it, so the largest of the three covers read to within one cold read of its -// own peak, which the margin absorbs. +// own peak. storageRWReadHeadroom covers the rest. var gasProbeSlot = new(big.Int).Lsh(big.NewInt(1), 200) // GasEstimateCalls prices all three operations with an empty pad. The pad is @@ -113,6 +125,15 @@ func (s *StorageRWScenario) GasEstimateCalls() []GasEstimateCall { } } +// CreateContractTransaction implements ContractDeployer interface - builds one +// StorageRWv1 transaction whose slot (key contention), calldata pad (tx size), +// and operation are drawn from the scenario config. With none of the three +// configured it falls back to a single-slot empty-pad rmw and draws no +// randomness. See package doc for the gas rationale. +// +// The draws run in a fixed order: slot, then pad, then operation. That order +// must stay stable — all three share the run's single PRNG, so reordering them +// shifts every subsequent draw and diverges a replay at the same seed. func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bind.TransactOpts, scenario *types.TxScenario) (*ethtypes.Transaction, error) { slot, err := s.pickSlot(rng) if err != nil { @@ -150,7 +171,7 @@ func (s *StorageRWScenario) CreateContractTransaction(rng *mrand.Rand, auth *bin if err != nil { return nil, fmt.Errorf("storagerw: %w", err) } - auth.GasLimit = limit + auth.GasLimit = limit + storageRWReadHeadroom switch op { case config.OpRmw: diff --git a/generator/scenarios/StorageRW_test.go b/generator/scenarios/StorageRW_test.go index 556a6c1..7ccd727 100644 --- a/generator/scenarios/StorageRW_test.go +++ b/generator/scenarios/StorageRW_test.go @@ -96,6 +96,15 @@ func TestStorageRWDeployAndGenerate(t *testing.T) { // known address under mock deploy, mirroring generator.mockPrepareAll. 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() + gen, txs := newAttachedStorageRWUnpriced(t, sc) + priceGasCalls(t, gen) + return gen, txs +} + +// newAttachedStorageRWUnpriced leaves the scenario unpriced, for a test that +// needs to choose the margin its calls are priced at. +func newAttachedStorageRWUnpriced(t *testing.T, sc config.Scenario) (scenarios.TxGenerator, *types.TxScenario) { t.Helper() sc.Name = scenarios.StorageRW cfg := &config.LoadConfig{ @@ -109,7 +118,6 @@ func newAttachedStorageRW(t *testing.T, sc config.Scenario) (scenarios.TxGenerat gen := scenarios.CreateScenario(sc) require.NoError(t, gen.Ready(cfg)) require.NoError(t, gen.Binder()(nil, types.GenerateAccounts(1, false)[0].Address)) - priceGasCalls(t, gen) return gen, &types.TxScenario{ Name: scenarios.StorageRW, Nonce: 0, @@ -254,7 +262,7 @@ func TestStorageRWDefaultPathUnchanged(t *testing.T) { require.Equal(t, "rmw", method) require.Zero(t, slot) require.Zero(t, padLen) - requireGasMatchesModel(t, tx) + requireGasCoversModelPlusColdRead(t, tx, 1.2) requireGasCoversFloor(t, tx) } @@ -464,3 +472,19 @@ func TestStorageRWDefaultStampsItsDefaultOperation(t *testing.T) { require.Equal(t, config.OpRmw, txs.Operation) } } + +// TestStorageRWClearsReadsPeakAtTheLowestMargin guards the one shape this +// scenario cannot price, at the margin that gives it no help. +// +// read costs most against a slot that already holds a value, and every priced +// call reads an untouched one, so the largest measured model is short by a cold +// read. The scenario adds that back as a constant rather than leaning on +// gasMargin, because Validate accepts a margin of 1 and at 1 nothing absorbs it. +func TestStorageRWClearsReadsPeakAtTheLowestMargin(t *testing.T) { + gen, txs := newAttachedStorageRWUnpriced(t, config.Scenario{}) + priceGasCallsAtMargin(t, gen, 1) + + tx, err := gen.Generate(newTestRng(7), txs) + require.NoError(t, err) + requireGasCoversModelPlusColdRead(t, tx, 1) +} diff --git a/generator/scenarios/gasestimate_test_helper_test.go b/generator/scenarios/gasestimate_test_helper_test.go index 5e9d612..def9d52 100644 --- a/generator/scenarios/gasestimate_test_helper_test.go +++ b/generator/scenarios/gasestimate_test_helper_test.go @@ -45,3 +45,39 @@ func requireGasMatchesModel(t *testing.T, tx *ethtypes.Transaction) { require.Equal(t, want, tx.Gas(), "the limit does not match what the model derives from this transaction's own calldata, so the send path and the priced call have drifted apart") } + +// priceGasCallsAtMargin drives the pricing hand-off with a chosen margin, so a +// test can exercise the lowest one Settings.Validate accepts. +func priceGasCallsAtMargin(t *testing.T, gen scenarios.TxGenerator, margin float64) { + t.Helper() + price := gen.GasEstimateCaller() + if price == nil { + return + } + require.NoError(t, price(context.Background(), + func(context.Context, scenarios.GasEstimateCall) (scenarios.GasModel, error) { + return scenarios.GasModel{Exec: testGasExec, Margin: margin}, nil + })) +} + +// requireGasCoversModelPlusColdRead is requireGasMatchesModel for a scenario that +// adds fixed headroom on top of its measured model. +// +// StorageRW is the case: no call it prices reaches read's expensive shape, so it +// carries the difference as a constant. The limit must therefore exceed the +// model by at least a cold slot read, and not by so much that it stops +// describing the work. +func requireGasCoversModelPlusColdRead(t *testing.T, tx *ethtypes.Transaction, margin float64) { + t.Helper() + // EIP-2929's cold slot read, the gap between the priced shape and read's peak. + const coldSload = 2100 + + priced, err := scenarios.GasModel{Exec: testGasExec, Margin: margin}.Limit(tx.Data()) + require.NoError(t, err) + require.GreaterOrEqual(t, tx.Gas(), priced+coldSload, + "the limit does not clear read's expensive shape, so the first read of a "+ + "written slot burns its whole limit and reports as sent") + require.LessOrEqual(t, tx.Gas(), priced+4*coldSload, + "the limit is far past what any priced call needs, so it reserves block "+ + "space nothing spends") +} diff --git a/registry/chains/arctic-1.json b/registry/chains/arctic-1.json new file mode 100644 index 0000000..36bf890 --- /dev/null +++ b/registry/chains/arctic-1.json @@ -0,0 +1,22 @@ +{ + "chainId": 713715, + "chainName": "arctic-1", + "genesisHash": "8ef5b0c01c1cde65be22a0f501d1663c55b3a900b46ecb72a5ccd823040bf035", + "contracts": [ + { + "name": "defi-amm", + "address": "0x225af59603bb554686adfbb2869af4cec12488a1", + "codeHash": "0xd3b745d66f41b203732768f63c3f58a08be56a4c007d4ed88686d5230d7d54cd" + }, + { + "name": "tokenops-erc20", + "address": "0xe66344c8ed6dbde610725cd7e3359b1fe4d7ff26", + "codeHash": "0xa18365677f78d1ced93a0e2c4fa1bcbcb48ea2cf14e3c0482535bef1cbeebdfc" + }, + { + "name": "tokenops-erc721", + "address": "0x815299db5f8e3c6c42356655429cf2701e502bea", + "codeHash": "0xb6533beb6bc3c23f03769c83e191dcabd55a5f5ba454165fd7530320ab0799a5" + } + ] +} diff --git a/registry/registry_test.go b/registry/registry_test.go index c299697..158273f 100644 --- a/registry/registry_test.go +++ b/registry/registry_test.go @@ -27,18 +27,71 @@ const ( // TestLoadWithNoPathsReadsOnlyTheBinary asserts CDR-018: Load with no paths // returns the compiled-in registry, and reaches for nothing else. -// -// chains/ ships with no chain files, so the registry is empty today. The -// assertion is that Load succeeds and finds nothing, not that it finds nothing -// forever. func TestLoadWithNoPathsReadsOnlyTheBinary(t *testing.T) { r, err := registry.Load() if err != nil { t.Fatalf("Load(): %v", err) } - if got := len(r.Sources()); got != 0 { - t.Errorf("compiled-in registry holds %d chains, want 0. A chain file "+ - "in registry/chains/ needs its own test naming it.", got) + if got := len(r.Sources()); got != 1 { + t.Errorf("compiled-in registry holds %d chains, want 1. A chain file "+ + "added to or removed from registry/chains/ needs this test and "+ + "TestTheCompiledInRegistryNamesArctic1 to move with it.", got) + } +} + +// TestTheCompiledInRegistryNamesArctic1 names what the binary ships, so a chain +// file cannot change without a test changing with it. +// +// The addresses are the contracts a run binds instead of deploying, and the code +// hashes are what Verify checks them against. A wrong entry here is not a failed +// test in production: it is every cell reading this image failing at startup, or +// binding an address that holds something else. +func TestTheCompiledInRegistryNamesArctic1(t *testing.T) { + r, err := registry.Load() + if err != nil { + t.Fatalf("Load(): %v", err) + } + + const genesisHash = "8ef5b0c01c1cde65be22a0f501d1663c55b3a900b46ecb72a5ccd823040bf035" + chain, ok := r.Chain(713715, genesisHash) + if !ok { + t.Fatalf("no entry for arctic-1 at chain 713715 and its genesis hash. " + + "A run naming that hash would deploy its own contracts instead of " + + "binding the recorded ones.") + } + if chain.ChainName != "arctic-1" { + t.Errorf("chainName is %q, want arctic-1", chain.ChainName) + } + + // A chain id alone does not identify a chain instance. arctic-1 keeps its id + // across a re-genesis, so an entry that matched on the id alone would name + // addresses that no longer hold their contracts. + if _, ok := r.Chain(713715, "0000000000000000000000000000000000000000000000000000000000000000"); ok { + t.Error("a wrong genesis hash matched arctic-1, so the entry does not key on it") + } + + want := map[string]string{ + "defi-amm": "0x225af59603bb554686adfbb2869af4cec12488a1", + "tokenops-erc20": "0xe66344c8ed6dbde610725cd7e3359b1fe4d7ff26", + "tokenops-erc721": "0x815299db5f8e3c6c42356655429cf2701e502bea", + } + if len(chain.Contracts) != len(want) { + t.Fatalf("arctic-1 holds %d contracts, want %d", len(chain.Contracts), len(want)) + } + for name, address := range want { + contract, ok := chain.Contract(name) + if !ok { + t.Errorf("arctic-1 names no contract %q, so a profile using that "+ + "contractKey would deploy its own", name) + continue + } + if got := strings.ToLower(contract.Address.Hex()); got != address { + t.Errorf("%s is recorded at %s, want %s", name, got, address) + } + if contract.CodeHash == (common.Hash{}) { + t.Errorf("%s has no code hash, so Verify would have nothing to check "+ + "the address against", name) + } } }