From a773910f0826e58275f39b63236a686db341cb19 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 19:42:46 -0700 Subject: [PATCH 1/2] feat(registry): record arctic-1's deployed contracts Three contracts deployed to arctic-1 from a local run, so a deployment binds them instead of deploying its own on every restart. defi-amm 0x225af59603bb554686adfbb2869af4cec12488a1 tokenops-erc20 0xe66344c8ed6dbde610725cd7e3359b1fe4d7ff26 tokenops-erc721 0x815299db5f8e3c6c42356655429cf2701e502bea The run recorded 3 deployed and 0 copied unverified, so nothing here is carried through from a file this run did not check. Verified independently of the run that wrote it. Every address holds code, the recorded hash is keccak256 of the code the chain serves, and that code is byte-identical to what the pinned compiler produces locally. So these are our contracts, not merely addresses that hold something. Verified end to end: a binary carrying this file bound all three and sent no deployment, and the transactions it then sent moved the contracts' state. arctic-1's AMM reserve is no longer at its floor and its ERC721 token 1 has an owner, so the transactions executed rather than reverting. The tripwire test asserting an empty compiled-in registry said a chain file needs its own test naming it. It has one. Three mutations, three caught: a changed address, a changed genesis hash, a dropped contract. Co-Authored-By: Claude Opus 5 (1M context) --- registry/chains/arctic-1.json | 22 ++++++++++++ registry/registry_test.go | 67 +++++++++++++++++++++++++++++++---- 2 files changed, 82 insertions(+), 7 deletions(-) create mode 100644 registry/chains/arctic-1.json 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) + } } } From b0e54d43c97c2240fea23a170a2c8790cc116230 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Fri, 28 Aug 2026 20:17:59 -0700 Subject: [PATCH 2/2] fix(scenarios): cover read's peak with a constant, not with a tunable Review found that StorageRW's doc claimed the margin absorbs the gap between its priced calls and read's expensive shape. Settings.Validate accepts a GasMargin of 1, and at 1 nothing absorbs it: the first read of a written slot would land in a block having burned its whole limit, which is the failure this sizing exists to remove. read costs most against a slot that already holds a value, and every call the scenario prices reads an untouched one, so no measurement reaches that shape. What the write and rmw models miss is a cold slot read, which EIP-2929 prices at 2,100 and which is not one of the costs Sei moves. The scenario adds twice that as a constant, so correctness no longer depends on how an operator sets a knob. Two mutations, two caught: the headroom removed, and the headroom shrunk below a cold read. Also moved gasProbeSlot out of CreateContractTransaction's doc comment. Inserting it there left the function undocumented and turned a warning about that function's PRNG draw order into commentary on a package-level big.Int. Co-Authored-By: Claude Opus 5 (1M context) --- generator/scenarios/StorageRW.go | 43 ++++++++++++++----- generator/scenarios/StorageRW_test.go | 28 +++++++++++- .../scenarios/gasestimate_test_helper_test.go | 36 ++++++++++++++++ 3 files changed, 94 insertions(+), 13 deletions(-) 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") +}