Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 32 additions & 11 deletions generator/scenarios/StorageRW.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
28 changes: 26 additions & 2 deletions generator/scenarios/StorageRW_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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,
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
36 changes: 36 additions & 0 deletions generator/scenarios/gasestimate_test_helper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
22 changes: 22 additions & 0 deletions registry/chains/arctic-1.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
67 changes: 60 additions & 7 deletions registry/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] This assertion can never fire: Chain.validate already rejects a zero codeHash (registry/registry.go:185), so any chain file that reached Load without erroring has a non-zero hash on every contract. The test therefore pins the addresses but leaves the code hashes entirely unpinned β€” the one field whose corruption makes every cell reading this image fail Verify at startup can be edited without any test changing, which is the drift this test's doc comment says it exists to prevent.

Pin the exact hashes alongside the addresses, e.g. widen want to a struct:

want := map[string]struct{ address, codeHash string }{
	"defi-amm": {
		"0x225af59603bb554686adfbb2869af4cec12488a1",
		"0xd3b745d66f41b203732768f63c3f58a08be56a4c007d4ed88686d5230d7d54cd",
	},
	// ...
}

and compare strings.ToLower(contract.CodeHash.Hex()) against it.

t.Errorf("%s has no code hash, so Verify would have nothing to check "+
"the address against", name)
}
}
}

Expand Down
Loading