From 2f640b52c69f8c9d242fa95e40817f4b3840c603 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Wed, 14 Jan 2026 19:31:50 +0000 Subject: [PATCH 01/12] feat: `sae.SinceGenesis.Initialize()` --- blocks/block_test.go | 7 ++- blocks/blockstest/blocks.go | 14 +----- blocks/settlement.go | 29 ++++++++++-- blocks/settlement_test.go | 6 +-- hook/hookstest/stub.go | 2 - sae/always.go | 52 ++++++++++++++++++--- sae/blocks.go | 14 +++--- sae/dbconv.go | 15 ++++++ sae/vm.go | 68 ++++++++++++++------------- sae/vm_test.go | 93 +++++++++++++++++++++---------------- sae/worstcase_test.go | 79 +++++++++++++++++++------------ 11 files changed, 239 insertions(+), 140 deletions(-) create mode 100644 sae/dbconv.go diff --git a/blocks/block_test.go b/blocks/block_test.go index 54e961f4..b555352f 100644 --- a/blocks/block_test.go +++ b/blocks/block_test.go @@ -9,6 +9,7 @@ import ( "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/ethdb" "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -34,7 +35,7 @@ func newBlock(tb testing.TB, eth *types.Block, parent, lastSettled *Block) *Bloc return b } -func newChain(tb testing.TB, startHeight, total uint64, lastSettledAtHeight map[uint64]uint64) []*Block { +func newChain(tb testing.TB, db ethdb.Database, startHeight, total uint64, lastSettledAtHeight map[uint64]uint64) []*Block { tb.Helper() var ( @@ -65,7 +66,9 @@ func newChain(tb testing.TB, startHeight, total uint64, lastSettledAtHeight map[ byNum[n] = b blocks = append(blocks, b) if synchronous { - require.NoError(tb, b.MarkSynchronous(), "MarkSynchronous()") + // The target and excess are irrelevant for the purposes of + // [newChain]. + require.NoError(tb, b.MarkSynchronous(db, 1, 0), "MarkSynchronous()") } parent = byNum[n] diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go index fb346159..fd4b5ba7 100644 --- a/blocks/blockstest/blocks.go +++ b/blocks/blockstest/blocks.go @@ -11,9 +11,7 @@ import ( "math" "math/big" "slices" - "sync/atomic" "testing" - "time" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/vms/components/gas" @@ -29,7 +27,6 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/strevm/blocks" - "github.com/ava-labs/strevm/gastime" "github.com/ava-labs/strevm/saetest" ) @@ -124,16 +121,7 @@ func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, al require.NoErrorf(tb, tdb.Commit(hash, true), "%T.Commit(core.SetupGenesisBlock(...))", tdb) b := NewBlock(tb, gen.ToBlock(), nil, nil) - require.NoErrorf(tb, b.MarkExecuted( - db, - gastime.New(gen.Timestamp, conf.gasTarget, conf.gasExcess), - time.Time{}, - new(big.Int), - nil, - b.SettledStateRoot(), - new(atomic.Pointer[blocks.Block]), - ), "%T.MarkExecuted()", b) - require.NoErrorf(tb, b.MarkSynchronous(), "%T.MarkSynchronous()", b) + require.NoErrorf(tb, b.MarkSynchronous(db, conf.gasTarget, conf.gasExcess), "%T.MarkSynchronous()", b) return b } diff --git a/blocks/settlement.go b/blocks/settlement.go index 1f0deb42..76ffa080 100644 --- a/blocks/settlement.go +++ b/blocks/settlement.go @@ -7,13 +7,16 @@ import ( "context" "errors" "fmt" + "math/big" "slices" "sync/atomic" "time" "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/ethdb" "go.uber.org/zap" + "github.com/ava-labs/strevm/gastime" "github.com/ava-labs/strevm/hook" "github.com/ava-labs/strevm/proxytime" ) @@ -60,10 +63,13 @@ func (b *Block) markSettled(lastSettled *atomic.Pointer[Block]) error { return nil } -// MarkSynchronous is a special case of [Block.MarkSettled], reserved for the -// last pre-SAE block, which MAY be the genesis block. These are, by definition, -// self-settling so require special treatment as such behaviour is impossible -// under SAE rules. +// MarkSynchronous combines [Block.MarkExecuted] and [Block.MarkSettled], and is +// reserved for the last pre-SAE block, which MAY be the genesis block. These +// blocks are, by definition, self-settling so require special treatment as such +// behaviour is impossible under SAE rules. +// +// Arguments required by [Block.MarkExecuted] but not accepted by +// MarkSynchronous are derived from the block to maintain invariants. // // MarkSynchronous and [Block.Synchronous] are not safe for concurrent use. This // method MUST therefore be called *before* instantiating the SAE VM. @@ -71,7 +77,20 @@ func (b *Block) markSettled(lastSettled *atomic.Pointer[Block]) error { // Wherever MarkSynchronous results in different behaviour to // [Block.MarkSettled], the respective methods are documented as such. They can // otherwise be considered identical. -func (b *Block) MarkSynchronous() error { +func (b *Block) MarkSynchronous(db ethdb.Database, gasTargetOfBlock, excessAfter gas.Gas) error { + gt := gastime.New(b.BuildTime(), gasTargetOfBlock, excessAfter) + ethB := b.EthBlock() + baseFee := ethB.BaseFee() + if baseFee == nil { // genesis blocks + baseFee = new(big.Int) + } + // Receipts of a synchronous block have already been "settled" by the block + // itself. As the only reason to pass receipts here is for later settlement + // in another block, there is no need to pass anything meaningful as it + // would also require them to be received as an argument to MarkSynchronous. + if err := b.MarkExecuted(db, gt, time.Time{}, baseFee, nil /*receipts*/, ethB.Root(), new(atomic.Pointer[Block])); err != nil { + return err + } b.synchronous = true return b.markSettled(nil) } diff --git a/blocks/settlement_test.go b/blocks/settlement_test.go index a9db822f..7d335f4c 100644 --- a/blocks/settlement_test.go +++ b/blocks/settlement_test.go @@ -146,7 +146,7 @@ func TestSettles(t *testing.T) { 8: nil, 9: {4, 5, 6, 7}, } - blocks := newChain(t, 0, 10, lastSettledAtHeight) + blocks := newChain(t, rawdb.NewMemoryDatabase(), 0, 10, lastSettledAtHeight) numsToBlocks := func(nums ...uint64) []*Block { bs := make([]*Block, len(nums)) @@ -207,7 +207,8 @@ func TestSettles(t *testing.T) { } func TestLastToSettleAt(t *testing.T) { - blocks := newChain(t, 0, 30, nil) + db := rawdb.NewMemoryDatabase() + blocks := newChain(t, db, 0, 30, nil) t.Run("helper_invariants", func(t *testing.T) { for i, b := range blocks { require.Equal(t, uint64(i), b.Height()) //nolint:gosec // Slice index won't overflow @@ -215,7 +216,6 @@ func TestLastToSettleAt(t *testing.T) { } }) - db := rawdb.NewMemoryDatabase() tm := gastime.New(0, 5 /*target*/, 0) require.Equal(t, gas.Gas(10), tm.Rate()) diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 546569e8..ad59f942 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -7,7 +7,6 @@ package hookstest import ( "encoding/binary" "math/big" - "testing" "time" "github.com/ava-labs/avalanchego/vms/components/gas" @@ -25,7 +24,6 @@ type Stub struct { Now func() time.Time Target gas.Gas Ops []hook.Op - TB testing.TB } var _ hook.Points = (*Stub)(nil) diff --git a/sae/always.go b/sae/always.go index c758c572..4f2f6214 100644 --- a/sae/always.go +++ b/sae/always.go @@ -5,10 +5,14 @@ package sae import ( "context" + "encoding/json" + "fmt" "github.com/ava-labs/avalanchego/database" "github.com/ava-labs/avalanchego/snow" snowcommon "github.com/ava-labs/avalanchego/snow/engine/common" + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/triedb" "github.com/ava-labs/strevm/adaptor" "github.com/ava-labs/strevm/blocks" @@ -19,21 +23,57 @@ var _ adaptor.ChainVM[*blocks.Block] = (*SinceGenesis)(nil) // SinceGenesis is a harness around a [VM], providing an `Initialize` method // that treats the chain as being asynchronous since genesis. type SinceGenesis struct { - *VM + *VM // created by [SinceGenesis.Initialize] + + config Config +} + +func NewSinceGenesis(c Config) *SinceGenesis { + return &SinceGenesis{config: c} } // Initialize initializes the VM. func (vm *SinceGenesis) Initialize( ctx context.Context, - chainCtx *snow.Context, - db database.Database, + snowCtx *snow.Context, + avaDB database.Database, genesisBytes []byte, upgradeBytes []byte, configBytes []byte, fxs []*snowcommon.Fx, appSender snowcommon.AppSender, ) error { - // TODO(arr4n) when implementing this, also consolidate [NewVM] and - // [VM.Init] so a freshly constructed [VM] is ready to use. - return errUnimplemented + db := newEthDB(avaDB) + tdb := triedb.NewDatabase(db, vm.config.TrieDBConfig) + + genesis := new(core.Genesis) + if err := json.Unmarshal(genesisBytes, genesis); err != nil { + return fmt.Errorf("json.Unmarshal(%T): %v", genesis, err) + } + config, _, err := core.SetupGenesisBlock(db, tdb, genesis) + if err != nil { + return fmt.Errorf("core.SetupGenesisBlock(...): %v", err) + } + + genBlock, err := blocks.New(genesis.ToBlock(), nil, nil, snowCtx.Log) + if err != nil { + return fmt.Errorf("blocks.New(%T.ToBlock(), ...): %v", genesis, err) + } + if err := genBlock.MarkSynchronous(db, 1e6, 0); err != nil { + return fmt.Errorf("%T{genesis}.MarkSynchronous(): %v", genBlock, err) + } + + inner, err := NewVM(vm.config, snowCtx, config, db, genBlock, appSender) + if err != nil { + return err + } + vm.VM = inner + return nil +} + +func (vm *SinceGenesis) Shutdown(ctx context.Context) error { + if vm.VM == nil { + return nil + } + return vm.VM.Shutdown(ctx) } diff --git a/sae/blocks.go b/sae/blocks.go index bbbfa546..9315a387 100644 --- a/sae/blocks.go +++ b/sae/blocks.go @@ -67,7 +67,7 @@ func (vm *VM) BuildBlock(ctx context.Context, bCtx *block.Context) (*blocks.Bloc bCtx, vm.preference.Load(), vm.mempool.TransactionsByPriority, - vm.hooks, + vm.hooks(), ) } @@ -106,8 +106,8 @@ func (vm *VM) buildBlock( ) } - bTime := blocks.PreciseTime(vm.hooks, hdr) - pTime := blocks.PreciseTime(vm.hooks, parent.Header()) + bTime := blocks.PreciseTime(vm.hooks(), hdr) + pTime := blocks.PreciseTime(vm.hooks(), parent.Header()) // It is allowed for [hook.Points] to further constrain the allowed block // times. However, every block MUST at least satisfy these basic sanity @@ -124,7 +124,7 @@ func (vm *VM) buildBlock( } // Underflow of Add(-tau) is prevented by the above check. - lastSettled, ok, err := blocks.LastToSettleAt(vm.hooks, bTime.Add(-saeparams.Tau), parent) + lastSettled, ok, err := blocks.LastToSettleAt(vm.hooks(), bTime.Add(-saeparams.Tau), parent) if err != nil { return nil, err } @@ -138,7 +138,7 @@ func (vm *VM) buildBlock( zap.Stringer("last_settled_hash", lastSettled.Hash()), ) - state, err := worstcase.NewState(vm.hooks, vm.exec.ChainConfig(), vm.exec.StateCache(), lastSettled) + state, err := worstcase.NewState(vm.hooks(), vm.exec.ChainConfig(), vm.exec.StateCache(), lastSettled) if err != nil { log.Warn("Worst-case state not able to be created", zap.Error(err), @@ -168,7 +168,7 @@ func (vm *VM) buildBlock( return nil, fmt.Errorf("applying tx %#x in block %d to worst-case state: %v", tx.Hash(), b.Height(), err) } } - for i, op := range vm.hooks.EndOfBlockOps(b.EthBlock()) { + for i, op := range vm.hooks().EndOfBlockOps(b.EthBlock()) { if err := state.Apply(op); err != nil { log.Warn("Could not apply op during historical worst-case calculation", zap.Int("op_index", i), @@ -329,7 +329,7 @@ func (vm *VM) VerifyBlock(ctx context.Context, bCtx *block.Context, b *blocks.Bl bCtx, parent, func(f txpool.PendingFilter) []*txgossip.LazyTransaction { return txs }, - vm.hooks.BlockRebuilderFrom(b.EthBlock()), + vm.hooks().BlockRebuilderFrom(b.EthBlock()), ) if err != nil { return err diff --git a/sae/dbconv.go b/sae/dbconv.go new file mode 100644 index 00000000..c4017807 --- /dev/null +++ b/sae/dbconv.go @@ -0,0 +1,15 @@ +// Copyright (C) 2026, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package sae + +import ( + "github.com/ava-labs/avalanchego/database" + evmdb "github.com/ava-labs/avalanchego/vms/evm/database" + "github.com/ava-labs/libevm/core/rawdb" + "github.com/ava-labs/libevm/ethdb" +) + +func newEthDB(db database.Database) ethdb.Database { + return rawdb.NewDatabase(evmdb.New(db)) +} diff --git a/sae/vm.go b/sae/vm.go index 331e5ed4..68f18a21 100644 --- a/sae/vm.go +++ b/sae/vm.go @@ -18,6 +18,7 @@ import ( "github.com/ava-labs/avalanchego/utils" "github.com/ava-labs/avalanchego/utils/bloom" "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/version" "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core" "github.com/ava-labs/libevm/core/rawdb" @@ -44,7 +45,6 @@ type VM struct { config Config snowCtx *snow.Context - hooks hook.Points metrics *prometheus.Registry db ethdb.Database @@ -65,38 +65,40 @@ type VM struct { // A Config configures construction of a new [VM]. type Config struct { + Hooks hook.Points MempoolConfig legacypool.Config + TrieDBConfig *triedb.Config Now func() time.Time // defaults to [time.Now] if nil } +// hooks is a convenience wrapper for accessing the VM's hooks. +func (vm *VM) hooks() hook.Points { + return vm.config.Hooks +} + // NewVM returns a new [VM] on which the [VM.Init] method MUST be called before // other use. This deferment allows the [VM] to be constructed before // `Initialize` has been called on the harness. -func NewVM(c Config) *VM { - if c.Now == nil { - c.Now = time.Now - } - return &VM{ - config: c, - blocks: newSMap[common.Hash, *blocks.Block](), - } -} - -// Init initializes the [VM], similarly to the `Initialize` method of -// [snowcommon.VM]. -func (vm *VM) Init( +func NewVM( + c Config, snowCtx *snow.Context, - hooks hook.Points, chainConfig *params.ChainConfig, db ethdb.Database, - triedbConfig *triedb.Config, lastSynchronous *blocks.Block, sender snowcommon.AppSender, -) error { - vm.snowCtx = snowCtx - vm.hooks = hooks - vm.db = db +) (*VM, error) { + if c.Now == nil { + c.Now = time.Now + } + + vm := &VM{ + config: c, + snowCtx: snowCtx, + metrics: prometheus.NewRegistry(), + db: db, + blocks: newSMap[common.Hash, *blocks.Block](), + } vm.blocks.Store(lastSynchronous.Hash(), lastSynchronous) // Disk @@ -106,6 +108,7 @@ func (vm *VM) Init( } { fn(db, lastSynchronous.Hash()) } + rawdb.WriteCanonicalHash(db, lastSynchronous.Hash(), lastSynchronous.NumberU64()) // Internal indicators for _, ptr := range []*atomic.Pointer[blocks.Block]{ &vm.preference, @@ -115,9 +118,8 @@ func (vm *VM) Init( ptr.Store(lastSynchronous) } - vm.metrics = prometheus.NewRegistry() if err := snowCtx.Metrics.Register("sae", vm.metrics); err != nil { - return err + return nil, err } { // ========== Executor ========== @@ -126,12 +128,12 @@ func (vm *VM) Init( vm.blockSource, chainConfig, db, - triedbConfig, - hooks, + vm.config.TrieDBConfig, + vm.hooks(), snowCtx.Log, ) if err != nil { - return fmt.Errorf("saexec.New(...): %v", err) + return nil, fmt.Errorf("saexec.New(...): %v", err) } vm.exec = exec vm.toClose = append(vm.toClose, exec.Close) @@ -144,18 +146,18 @@ func (vm *VM) Init( } txPool, err := txpool.New(0, bc, pools) if err != nil { - return fmt.Errorf("txpool.New(...): %v", err) + return nil, fmt.Errorf("txpool.New(...): %v", err) } vm.toClose = append(vm.toClose, txPool.Close) metrics, err := bloom.NewMetrics("mempool", vm.metrics) if err != nil { - return err + return nil, err } conf := gossip.BloomSetConfig{Metrics: metrics} pool, err := txgossip.NewSet(snowCtx.Log, txPool, conf) if err != nil { - return err + return nil, err } vm.mempool = pool vm.signalNewTxsToEngine() @@ -164,7 +166,7 @@ func (vm *VM) Init( { // ========== P2P Gossip ========== network, peers, validatorPeers, err := newNetwork(snowCtx, sender, vm.metrics) if err != nil { - return fmt.Errorf("newNetwork(...): %v", err) + return nil, fmt.Errorf("newNetwork(...): %v", err) } const pullGossipPeriod = time.Second @@ -182,10 +184,10 @@ func (vm *VM) Init( }, ) if err != nil { - return fmt.Errorf("gossip.NewSystem(...): %v", err) + return nil, fmt.Errorf("gossip.NewSystem(...): %v", err) } if err := network.AddHandler(p2p.TxGossipHandlerID, handler); err != nil { - return fmt.Errorf("network.AddHandler(...): %v", err) + return nil, fmt.Errorf("network.AddHandler(...): %v", err) } var ( @@ -213,7 +215,7 @@ func (vm *VM) Init( }) } - return nil + return vm, nil } // signalNewTxsToEngine subscribes to the [txpool.TxPool] to unblock @@ -297,7 +299,7 @@ func (vm *VM) Shutdown(context.Context) error { // Version reports the VM's version. func (vm *VM) Version(context.Context) (string, error) { - return "", errUnimplemented + return version.Current.String(), nil } func (vm *VM) log() logging.Logger { diff --git a/sae/vm_test.go b/sae/vm_test.go index dc96c87b..538b6570 100644 --- a/sae/vm_test.go +++ b/sae/vm_test.go @@ -5,6 +5,7 @@ package sae import ( "context" + "encoding/json" "flag" "math/big" "math/rand/v2" @@ -13,6 +14,7 @@ import ( "testing" "time" + "github.com/ava-labs/avalanchego/database/memdb" "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/snow" "github.com/ava-labs/avalanchego/snow/consensus/snowman" @@ -24,6 +26,7 @@ import ( "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/utils/set" "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core" "github.com/ava-labs/libevm/core/rawdb" "github.com/ava-labs/libevm/core/state" "github.com/ava-labs/libevm/core/txpool" @@ -34,7 +37,6 @@ import ( "github.com/ava-labs/libevm/libevm/options" "github.com/ava-labs/libevm/params" "github.com/ava-labs/libevm/rpc" - "github.com/ava-labs/libevm/triedb" "github.com/holiman/uint256" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -43,6 +45,7 @@ import ( "github.com/ava-labs/strevm/adaptor" "github.com/ava-labs/strevm/blocks" "github.com/ava-labs/strevm/blocks/blockstest" + "github.com/ava-labs/strevm/hook" "github.com/ava-labs/strevm/hook/hookstest" saeparams "github.com/ava-labs/strevm/params" "github.com/ava-labs/strevm/saetest" @@ -74,7 +77,7 @@ type SUT struct { genesis *blocks.Block wallet *saetest.Wallet db ethdb.Database - hooks *hookstest.Stub + hooks hook.Points logger *saetest.TBLogger validators *validatorstest.State @@ -83,12 +86,9 @@ type SUT struct { type ( sutConfig struct { - vmConfig Config - chainConfig *params.ChainConfig - hooks *hookstest.Stub - logLevel logging.Level - alloc types.GenesisAlloc - genesisOptions []blockstest.GenesisOption + vmConfig Config + logLevel logging.Level + genesis core.Genesis } sutOption = options.Option[sutConfig] ) @@ -107,29 +107,26 @@ func newSUT(tb testing.TB, numAccounts uint, opts ...sutOption) (context.Context conf := options.ApplyTo(&sutConfig{ vmConfig: Config{ MempoolConfig: mempoolConf, - }, - chainConfig: saetest.ChainConfig(), - hooks: &hookstest.Stub{ - Target: 100e6, - TB: tb, + Hooks: &hookstest.Stub{ + Target: 100e6, + }, }, logLevel: logging.Debug, - alloc: saetest.MaxAllocFor(keys.Addresses()...), - genesisOptions: []blockstest.GenesisOption{ - blockstest.WithTimestamp(saeparams.TauSeconds), + genesis: core.Genesis{ + Config: saetest.ChainConfig(), + Alloc: saetest.MaxAllocFor(keys.Addresses()...), + Timestamp: saeparams.TauSeconds, + Difficulty: big.NewInt(0), // irrelevant but required }, }, opts...) - vm := NewVM(conf.vmConfig) - snow := adaptor.Convert(&SinceGenesis{VM: vm}) + vm := NewSinceGenesis(conf.vmConfig) + snow := adaptor.Convert(vm) tb.Cleanup(func() { ctx := context.WithoutCancel(tb.Context()) require.NoError(tb, snow.Shutdown(ctx), "Shutdown()") }) - db := rawdb.NewMemoryDatabase() - genesis := blockstest.NewGenesis(tb, db, conf.chainConfig, conf.alloc, conf.genesisOptions...) - logger := saetest.NewTBLogger(tb, conf.logLevel) ctx := logger.CancelOnError(tb.Context()) snowCtx := snowtest.Context(tb, chainID) @@ -141,9 +138,17 @@ func newSUT(tb testing.TB, numAccounts uint, opts ...sutOption) (context.Context }, } - // TODO(arr4n) change this to use [SinceGenesis.Initialize] via the `snow` variable. - require.NoError(tb, vm.Init(snowCtx, conf.hooks, conf.chainConfig, db, &triedb.Config{}, genesis, sender), "Init()") - _ = snow.Initialize + mdb := memdb.New() + require.NoError(tb, snow.Initialize( + ctx, + snowCtx, + mdb, + marshalJSON(tb, conf.genesis), + nil, // upgrade bytes + nil, // config bytes (not ChainConfig) + nil, // Fxs + sender, + ), "Initialize()") handlers, err := snow.CreateHandlers(ctx) require.NoErrorf(tb, err, "%T.CreateHandlers()", snow) @@ -160,14 +165,14 @@ func newSUT(tb testing.TB, numAccounts uint, opts ...sutOption) (context.Context ChainVM: snow, Client: client, rpcClient: rpcClient, - rawVM: vm, - genesis: genesis, + rawVM: vm.VM, + genesis: vm.last.settled.Load(), wallet: saetest.NewWalletWithKeyChain( keys, - types.LatestSigner(conf.chainConfig), + types.LatestSigner(conf.genesis.Config), ), - db: db, - hooks: conf.hooks, + db: newEthDB(mdb), + hooks: conf.vmConfig.Hooks, logger: logger, validators: validators, @@ -175,6 +180,13 @@ func newSUT(tb testing.TB, numAccounts uint, opts ...sutOption) (context.Context } } +func marshalJSON(tb testing.TB, v any) []byte { + tb.Helper() + buf, err := json.Marshal(v) + require.NoErrorf(tb, err, "json.Marshal(%T)", v) + return buf +} + // CallContext propagates its arguments to and from [SUT.rpcClient.CallContext]. // Embedding both the [ethclient.Client] and the underlying [rpc.Client] isn't // possible due to a name conflict, so this method is manually exposed. @@ -183,8 +195,10 @@ func (s *SUT) CallContext(ctx context.Context, result any, method string, args . } // stubbedTime returns an option to configure a new SUT's "now" function along -// with a function to set the time at nanosecond resolution. -func stubbedTime() (_ sutOption, setTime func(time.Time)) { +// with a function to set the time. +func stubbedTime(tb testing.TB) (_ sutOption, setTime func(time.Time)) { + tb.Helper() + var now time.Time set := func(n time.Time) { now = n @@ -196,18 +210,15 @@ func stubbedTime() (_ sutOption, setTime func(time.Time)) { // TODO(StephenButtolph) unify the time functions provided in the config // and the hooks. c.vmConfig.Now = get - c.hooks.Now = get + + h, ok := c.vmConfig.Hooks.(*hookstest.Stub) + require.Truef(tb, ok, "%T.vmConfig.Hooks of type %T is not %T", c, c.vmConfig.Hooks, h) + h.Now = get }) return opt, set } -func withGenesisOpts(opts ...blockstest.GenesisOption) sutOption { - return options.Func[sutConfig](func(c *sutConfig) { - c.genesisOptions = append(c.genesisOptions, opts...) - }) -} - func (s *SUT) nodeID() ids.NodeID { return s.rawVM.snowCtx.NodeID } @@ -479,7 +490,7 @@ func TestAcceptBlock(t *testing.T) { require.Zero(t, blocks.InMemoryBlockCount(), "initial in-memory block count") }, 100*time.Millisecond, time.Millisecond) - opt, setTime := stubbedTime() + opt, setTime := stubbedTime(t) now := time.Unix(0, 0) fastForward := func(by time.Duration) { now = now.Add(by) @@ -534,7 +545,9 @@ func TestAcceptBlock(t *testing.T) { func TestSemanticBlockChecks(t *testing.T) { const now = 1e6 - ctx, sut := newSUT(t, 1, withGenesisOpts(blockstest.WithTimestamp(now))) + ctx, sut := newSUT(t, 1, options.Func[sutConfig](func(c *sutConfig) { + c.genesis.Timestamp = now + })) sut.rawVM.config.Now = func() time.Time { return time.Unix(now, 0) } diff --git a/sae/worstcase_test.go b/sae/worstcase_test.go index f94d0991..9f1658ca 100644 --- a/sae/worstcase_test.go +++ b/sae/worstcase_test.go @@ -21,7 +21,6 @@ import ( "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/core/vm" "github.com/ava-labs/libevm/libevm" - "github.com/ava-labs/libevm/libevm/hookstest" "github.com/ava-labs/libevm/libevm/options" "github.com/ava-labs/libevm/params" "github.com/holiman/uint256" @@ -60,45 +59,67 @@ func createWorstCaseFuzzFlags(set *flag.FlagSet) { set.Uint64Var(&fs.rngSeed, name("rng_seed"), 0, "Seed for random-number generator; ignored if zero") } +type guzzler struct { + params.NOOPHooks `json:"-"` + Addr common.Address `json:"guzzler_address"` +} + +func (*guzzler) register(tb testing.TB) params.ExtraPayloads[*guzzler, *guzzler] { + tb.Helper() + tb.Cleanup(params.TestOnlyClearRegisteredExtras) + return params.RegisterExtras(params.Extras[*guzzler, *guzzler]{ + NewRules: func(_ *params.ChainConfig, _ *params.Rules, g *guzzler, _ *big.Int, _ bool, _ uint64) *guzzler { + return g + }, + }) +} + +func (g *guzzler) ActivePrecompiles(active []common.Address) []common.Address { + return append(active, g.Addr) +} + +func (g *guzzler) PrecompileOverride(a common.Address) (libevm.PrecompiledContract, bool) { + if a != g.Addr { + return nil, false + } + return vm.NewStatefulPrecompile(g.guzzle), true +} + +func (g *guzzler) guzzle(env vm.PrecompileEnvironment, input []byte) ([]byte, error) { + switch len(input) { + case 0: + env.UseGas(env.Gas()) + case 8: + // We don't know the intrinsic gas that has already been spent, so + // intepreting the calldata as the amount of gas to consume in total + // would be impossible without some ugly closures. + keep := binary.BigEndian.Uint64(input) + use := intmath.BoundedSubtract(env.Gas(), keep, 0) + env.UseGas(use) + default: + panic("bad test setup; calldata MUST be empty or an 8-byte slice") + } + return nil, nil +} + //nolint:tparallel // Why should we call t.Parallel at the top level by default? func TestWorstCase(t *testing.T) { flags := worstCaseFuzzFlags t.Logf("Flags: %+v", flags) - guzzler := vm.NewStatefulPrecompile(func(env vm.PrecompileEnvironment, input []byte) ([]byte, error) { - switch len(input) { - case 0: - env.UseGas(env.Gas()) - case 8: - // We don't know the intrinsic gas that has already been spent, so - // intepreting the calldata as the amount of gas to consume in total - // would be impossible without some ugly closures. - keep := binary.BigEndian.Uint64(input) - use := intmath.BoundedSubtract(env.Gas(), keep, 0) - env.UseGas(use) - default: - panic("bad test setup; calldata MUST be empty or an 8-byte slice") - } - return nil, nil - }) guzzle := common.Address{'g', 'u', 'z', 'z', 'l', 'e'} - - evmHooks := &hookstest.Stub{ - PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{ - guzzle: guzzler, - }, - } - extras := evmHooks.Register(t) + g := &guzzler{Addr: guzzle} + extras := g.register(t) sutOpt := options.Func[sutConfig](func(c *sutConfig) { // Avoid polluting a global [params.ChainConfig] with our hooks. - config := *c.chainConfig - c.chainConfig = &config - extras.ChainConfig.Set(c.chainConfig, evmHooks) + config := *c.genesis.Config + c.genesis.Config = &config + extras.ChainConfig.Set(&config, g) c.logLevel = logging.Warn - for _, acc := range c.alloc { + for _, acc := range c.genesis.Alloc { // Note that `acc` isn't a pointer, but `Balance` is. acc.Balance.Set(flags.balance.ToBig()) } @@ -161,7 +182,7 @@ func TestWorstCase(t *testing.T) { t.Run("fuzz", func(t *testing.T) { t.Parallel() - timeOpt, setTime := stubbedTime() + timeOpt, setTime := stubbedTime(t) now := time.Unix(saeparams.TauSeconds, 0) fastForward := func(by time.Duration) { now = now.Add(by) From 81c6942337ccdbe0844093e4154d0f8dfd095477 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Sat, 24 Jan 2026 21:04:13 +0000 Subject: [PATCH 02/12] doc: comments on top-level identifiers --- sae/always.go | 2 ++ sae/dbconv.go | 3 +++ sae/vm.go | 5 ++--- sae/worstcase_test.go | 8 ++++++++ 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/sae/always.go b/sae/always.go index 4f2f6214..5778ab16 100644 --- a/sae/always.go +++ b/sae/always.go @@ -28,6 +28,7 @@ type SinceGenesis struct { config Config } +// NewSinceGenesis constructs a new [SinceGenesis]. func NewSinceGenesis(c Config) *SinceGenesis { return &SinceGenesis{config: c} } @@ -71,6 +72,7 @@ func (vm *SinceGenesis) Initialize( return nil } +// Shutdown gracefully closes the VM. func (vm *SinceGenesis) Shutdown(ctx context.Context) error { if vm.VM == nil { return nil diff --git a/sae/dbconv.go b/sae/dbconv.go index c4017807..5bdcdcbe 100644 --- a/sae/dbconv.go +++ b/sae/dbconv.go @@ -3,6 +3,9 @@ package sae +// This single function is in a standalone file to reduce confusion because +// every required import has something to do with a database! + import ( "github.com/ava-labs/avalanchego/database" evmdb "github.com/ava-labs/avalanchego/vms/evm/database" diff --git a/sae/vm.go b/sae/vm.go index 68f18a21..bd8c8e20 100644 --- a/sae/vm.go +++ b/sae/vm.go @@ -77,9 +77,8 @@ func (vm *VM) hooks() hook.Points { return vm.config.Hooks } -// NewVM returns a new [VM] on which the [VM.Init] method MUST be called before -// other use. This deferment allows the [VM] to be constructed before -// `Initialize` has been called on the harness. +// NewVM returns a new [VM] that is ready for use immediately upon return. +// [VM.Shutdown] MUST be called to release resources. func NewVM( c Config, snowCtx *snow.Context, diff --git a/sae/worstcase_test.go b/sae/worstcase_test.go index 9f1658ca..37bb2560 100644 --- a/sae/worstcase_test.go +++ b/sae/worstcase_test.go @@ -59,6 +59,11 @@ func createWorstCaseFuzzFlags(set *flag.FlagSet) { set.Uint64Var(&fs.rngSeed, name("rng_seed"), 0, "Seed for random-number generator; ignored if zero") } +// A guzzler is both a [params.ChainConfigHooks] and [params.RulesHooks]. When +// registered as libevm extras they result in the [guzzler.guzzle] method being +// a [vm.PrecompiledStatefulContract] instantiated at the address specified in +// the `Addr` field. Furthermore, a guzzler can be JSON round-tripped, allowing +// it to be included in a chain's genesis. type guzzler struct { params.NOOPHooks `json:"-"` Addr common.Address `json:"guzzler_address"` @@ -85,6 +90,9 @@ func (g *guzzler) PrecompileOverride(a common.Address) (libevm.PrecompiledContra return vm.NewStatefulPrecompile(g.guzzle), true } +// guzzle consumes an amount of gas configurable via its input (call data), +// which MUST either be an empty slice or a big-endian uint64 indicating the +// amount of gas to _keep_ (not consume). func (g *guzzler) guzzle(env vm.PrecompileEnvironment, input []byte) ([]byte, error) { switch len(input) { case 0: From a0558a73d31c5af1a6166e4b9ae5fde7fe370698 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Sat, 24 Jan 2026 21:04:49 +0000 Subject: [PATCH 03/12] refactor: move `sae.VM.hooks()` alongside other convenience wrappers --- sae/vm.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/sae/vm.go b/sae/vm.go index bd8c8e20..1ccf3cd0 100644 --- a/sae/vm.go +++ b/sae/vm.go @@ -72,11 +72,6 @@ type Config struct { Now func() time.Time // defaults to [time.Now] if nil } -// hooks is a convenience wrapper for accessing the VM's hooks. -func (vm *VM) hooks() hook.Points { - return vm.config.Hooks -} - // NewVM returns a new [VM] that is ready for use immediately upon return. // [VM.Shutdown] MUST be called to release resources. func NewVM( @@ -305,6 +300,10 @@ func (vm *VM) log() logging.Logger { return vm.snowCtx.Log } +func (vm *VM) hooks() hook.Points { + return vm.config.Hooks +} + func (vm *VM) signerForBlock(b *types.Block) types.Signer { return types.MakeSigner(vm.exec.ChainConfig(), b.Number(), b.Time()) } From 634f4b19598ee524ff0aaa0e3349bb08d10dc848 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Sun, 25 Jan 2026 16:39:09 +0000 Subject: [PATCH 04/12] feat!: `MarkSynchronous()` accepts sub-second block time + correct gas target --- blocks/block_test.go | 4 +-- blocks/blockstest/blocks.go | 12 +++++--- blocks/execution_test.go | 4 +-- blocks/settlement.go | 14 ++++++--- blocks/settlement_test.go | 4 +-- blocks/time.go | 6 +++- gastime/acp176_test.go | 8 +++-- gastime/gastime.go | 10 ++++-- gastime/gastime_test.go | 61 ++++++++++++++++++++++++++++++++++--- hook/hookstest/stub.go | 2 +- sae/always.go | 3 +- 11 files changed, 99 insertions(+), 29 deletions(-) diff --git a/blocks/block_test.go b/blocks/block_test.go index b555352f..cb82585d 100644 --- a/blocks/block_test.go +++ b/blocks/block_test.go @@ -67,8 +67,8 @@ func newChain(tb testing.TB, db ethdb.Database, startHeight, total uint64, lastS blocks = append(blocks, b) if synchronous { // The target and excess are irrelevant for the purposes of - // [newChain]. - require.NoError(tb, b.MarkSynchronous(db, 1, 0), "MarkSynchronous()") + // [newChain], and sub-second time for genesis is unnecessary. + require.NoError(tb, b.MarkSynchronous(db, 0, 1, 0), "MarkSynchronous()") } parent = byNum[n] diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go index fd4b5ba7..02c7afa3 100644 --- a/blocks/blockstest/blocks.go +++ b/blocks/blockstest/blocks.go @@ -12,6 +12,7 @@ import ( "math/big" "slices" "testing" + "time" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/vms/components/gas" @@ -121,15 +122,16 @@ func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, al require.NoErrorf(tb, tdb.Commit(hash, true), "%T.Commit(core.SetupGenesisBlock(...))", tdb) b := NewBlock(tb, gen.ToBlock(), nil, nil) - require.NoErrorf(tb, b.MarkSynchronous(db, conf.gasTarget, conf.gasExcess), "%T.MarkSynchronous()", b) + require.NoErrorf(tb, b.MarkSynchronous(db, conf.subSecondBlockTime, conf.gasTarget, conf.gasExcess), "%T.MarkSynchronous()", b) return b } type genesisConfig struct { - tdbConfig *triedb.Config - timestamp uint64 - gasTarget gas.Gas - gasExcess gas.Gas + tdbConfig *triedb.Config + timestamp uint64 + subSecondBlockTime time.Duration + gasTarget gas.Gas + gasExcess gas.Gas } // A GenesisOption configures [NewGenesis]. diff --git a/blocks/execution_test.go b/blocks/execution_test.go index 7236bf67..d35b01f0 100644 --- a/blocks/execution_test.go +++ b/blocks/execution_test.go @@ -52,7 +52,7 @@ func TestMarkExecuted(t *testing.T) { rawdb.WriteBlock(db, ethB) settles := newBlock(t, newEthBlock(0, 0, nil), nil, nil) - settles.markExecutedForTests(t, db, gastime.New(0, 1, 0)) + settles.markExecutedForTests(t, db, gastime.New(time.Unix(0, 0), 1, 0)) b := newBlock(t, ethB, nil, settles) t.Run("before_MarkExecuted", func(t *testing.T) { @@ -85,7 +85,7 @@ func TestMarkExecuted(t *testing.T) { } }) - gasTime := gastime.New(42, 1e6, 42) + gasTime := gastime.New(time.Unix(42, 0), 1e6, 42) wallTime := time.Unix(42, 100) stateRoot := common.Hash{'s', 't', 'a', 't', 'e'} baseFee := big.NewInt(314159) diff --git a/blocks/settlement.go b/blocks/settlement.go index 76ffa080..c46cd608 100644 --- a/blocks/settlement.go +++ b/blocks/settlement.go @@ -69,7 +69,9 @@ func (b *Block) markSettled(lastSettled *atomic.Pointer[Block]) error { // behaviour is impossible under SAE rules. // // Arguments required by [Block.MarkExecuted] but not accepted by -// MarkSynchronous are derived from the block to maintain invariants. +// MarkSynchronous are derived from the block to maintain invariants. The +// `subSecondBlockTime` argument MUST follow the same constraints as the +// respective [hook.Points] method. // // MarkSynchronous and [Block.Synchronous] are not safe for concurrent use. This // method MUST therefore be called *before* instantiating the SAE VM. @@ -77,18 +79,22 @@ func (b *Block) markSettled(lastSettled *atomic.Pointer[Block]) error { // Wherever MarkSynchronous results in different behaviour to // [Block.MarkSettled], the respective methods are documented as such. They can // otherwise be considered identical. -func (b *Block) MarkSynchronous(db ethdb.Database, gasTargetOfBlock, excessAfter gas.Gas) error { - gt := gastime.New(b.BuildTime(), gasTargetOfBlock, excessAfter) +func (b *Block) MarkSynchronous(db ethdb.Database, subSecondBlockTime time.Duration, gasTargetAfterBlock, excessAfter gas.Gas) error { ethB := b.EthBlock() baseFee := ethB.BaseFee() if baseFee == nil { // genesis blocks baseFee = new(big.Int) } + execTime := gastime.New( + preciseTime(b.Header(), subSecondBlockTime), + gasTargetAfterBlock, + excessAfter, + ) // Receipts of a synchronous block have already been "settled" by the block // itself. As the only reason to pass receipts here is for later settlement // in another block, there is no need to pass anything meaningful as it // would also require them to be received as an argument to MarkSynchronous. - if err := b.MarkExecuted(db, gt, time.Time{}, baseFee, nil /*receipts*/, ethB.Root(), new(atomic.Pointer[Block])); err != nil { + if err := b.MarkExecuted(db, execTime, time.Time{}, baseFee, nil /*receipts*/, ethB.Root(), new(atomic.Pointer[Block])); err != nil { return err } b.synchronous = true diff --git a/blocks/settlement_test.go b/blocks/settlement_test.go index 7d335f4c..09f193af 100644 --- a/blocks/settlement_test.go +++ b/blocks/settlement_test.go @@ -58,7 +58,7 @@ func TestSettlementInvariants(t *testing.T) { db := rawdb.NewMemoryDatabase() for _, b := range []*Block{b, parent, lastSettled} { - b.markExecutedForTests(t, db, gastime.New(b.BuildTime(), 1, 0)) + b.markExecutedForTests(t, db, gastime.New(preciseTime(b.Header(), 0), 1, 0)) } t.Run("before_MarkSettled", func(t *testing.T) { @@ -216,7 +216,7 @@ func TestLastToSettleAt(t *testing.T) { } }) - tm := gastime.New(0, 5 /*target*/, 0) + tm := gastime.New(time.Unix(0, 0), 5 /*target*/, 0) require.Equal(t, gas.Gas(10), tm.Rate()) requireTime := func(t *testing.T, sec uint64, numerator gas.Gas) { diff --git a/blocks/time.go b/blocks/time.go index 4b0c6774..decff4c0 100644 --- a/blocks/time.go +++ b/blocks/time.go @@ -18,9 +18,13 @@ import ( // the value, combined with the regular timestamp to provide a full-resolution // block time. func PreciseTime(hooks hook.Points, hdr *types.Header) time.Time { + return preciseTime(hdr, hooks.SubSecondBlockTime(hdr)) +} + +func preciseTime(hdr *types.Header, subSec time.Duration) time.Time { return time.Unix( int64(hdr.Time), //nolint:gosec // Won't overflow for a few millennia - int64(hooks.SubSecondBlockTime(hdr)), + subSec.Nanoseconds(), ) } diff --git a/gastime/acp176_test.go b/gastime/acp176_test.go index 59cdff1e..6588481e 100644 --- a/gastime/acp176_test.go +++ b/gastime/acp176_test.go @@ -4,6 +4,7 @@ package gastime import ( + "math" "testing" "time" @@ -23,7 +24,7 @@ func TestTargetUpdateTiming(t *testing.T) { initialTarget gas.Gas = 1_600_000 initialExcess = 1_234_567_890 ) - tm := New(initialTime, initialTarget, initialExcess) + tm := New(time.Unix(initialTime, 0), initialTarget, initialExcess) initialRate := tm.Rate() const ( @@ -73,8 +74,9 @@ func FuzzWorstCasePrice(f *testing.F) { ) { initTarget = max(initTarget, 1) - worstcase := New(initTimestamp, gas.Gas(initTarget), gas.Gas(initExcess)) - actual := New(initTimestamp, gas.Gas(initTarget), gas.Gas(initExcess)) + initUnix := int64(min(initTimestamp, math.MaxInt64)) //nolint:gosec // I can't believe I have to be explicit about this! + worstcase := New(time.Unix(initUnix, 0), gas.Gas(initTarget), gas.Gas(initExcess)) + actual := New(time.Unix(initUnix, 0), gas.Gas(initTarget), gas.Gas(initExcess)) blocks := []struct { time uint64 diff --git a/gastime/gastime.go b/gastime/gastime.go index 63b30a22..3e172cb4 100644 --- a/gastime/gastime.go +++ b/gastime/gastime.go @@ -50,12 +50,16 @@ func (tm *Time) establishInvariants() { tm.Time.SetRateInvariants(&tm.target, &tm.excess) } -// New returns a new [Time], set from a Unix timestamp. The consumption of +// New returns a new [Time], derived from a [time.Time]. The consumption of // `target` * [TargetToRate] units of [gas.Gas] is equivalent to a tick of 1 // second. Targets are clamped to [MaxTarget]. -func New(unixSeconds uint64, target, startingExcess gas.Gas) *Time { +func New(at time.Time, target, startingExcess gas.Gas) *Time { target = clampTarget(target) - return makeTime(proxytime.New(unixSeconds, rateOf(target)), target, startingExcess) + tm := proxytime.Of[gas.Gas](at) + // [proxytime.Time.SetRate] is documented as never returning an error when + // no invariants have been registered. + _ = tm.SetRate(rateOf(target)) + return makeTime(tm, target, startingExcess) } // SubSecond scales the value returned by [hook.Points.SubSecondBlockTime] to diff --git a/gastime/gastime_test.go b/gastime/gastime_test.go index 4711168d..84285eee 100644 --- a/gastime/gastime_test.go +++ b/gastime/gastime_test.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "testing" + "time" "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/google/go-cmp/cmp" @@ -26,7 +27,7 @@ func (tm *Time) cloneViaCanotoRoundTrip(tb testing.TB) *Time { } func TestClone(t *testing.T) { - tm := New(42, 1e6, 1e5) + tm := New(time.Unix(42, 0), 1e6, 1e5) tm.Tick(1) if diff := cmp.Diff(tm, tm.Clone(), CmpOpt()); diff != "" { @@ -66,6 +67,56 @@ func (tm *Time) requireState(tb testing.TB, desc string, want state, opts ...cmp } } +func TestNew(t *testing.T) { + frac := func(num, den gas.Gas) (f proxytime.FractionalSecond[gas.Gas]) { + f.Numerator = num + f.Denominator = den + return + } + + ignore := cmpopts.IgnoreFields(state{}, "Rate", "Price") + + tests := []struct { + name string + unix, nanos int64 + target, excess gas.Gas + want state + }{ + { + name: "rate at nanosecond resolution", + unix: 42, + nanos: 123_456, + target: 1e9 / TargetToRate, + want: state{ + UnixTime: 42, + ConsumedThisSecond: frac(123_456, 1e9), + Target: 1e9 / TargetToRate, + }, + }, + { + name: "scaling in constructor not applied to starting excess", + unix: 100, + nanos: TargetToRate, + target: 50 / TargetToRate, + excess: 987_654, + want: state{ + UnixTime: 100, + ConsumedThisSecond: frac(1, 50), + Target: 50 / TargetToRate, + Excess: 987_654, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tm := time.Unix(tt.unix, tt.nanos) + got := New(tm, tt.target, tt.excess) + got.requireState(t, fmt.Sprintf("New(%v, %d, %d)", tm, tt.target, tt.excess), tt.want, ignore) + }) + } +} + func (tm *Time) mustSetRate(tb testing.TB, rate gas.Gas) { tb.Helper() require.NoErrorf(tb, tm.SetRate(rate), "%T.%T.SetRate(%d)", tm, TimeMarshaler{}, rate) @@ -78,7 +129,7 @@ func (tm *Time) mustSetTarget(tb testing.TB, target gas.Gas) { func TestScaling(t *testing.T) { const initExcess = gas.Gas(1_234_567_890) - tm := New(42, 1.6e6, initExcess) + tm := New(time.Unix(42, 0), 1.6e6, initExcess) // The initial price isn't important in this test; what we care about is // that it's invariant under scaling of the target etc. @@ -154,7 +205,7 @@ func TestScaling(t *testing.T) { func TestExcess(t *testing.T) { const rate = gas.Gas(3.2e6) - tm := New(42, rate/2, 0) + tm := New(time.Unix(42, 0), rate/2, 0) frac := func(num gas.Gas) (f proxytime.FractionalSecond[gas.Gas]) { f.Numerator = num @@ -298,7 +349,7 @@ func TestExcessScalingFactor(t *testing.T) { {max, max}, } - tm := New(0, 1, 0) + tm := New(time.Unix(0, 0), 1, 0) for _, tt := range tests { require.NoErrorf(t, tm.SetTarget(tt.target), "%T.SetTarget(%v)", tm, tt.target) assert.Equalf(t, tt.want, tm.excessScalingFactor(), "T = %d", tt.target) @@ -306,7 +357,7 @@ func TestExcessScalingFactor(t *testing.T) { } func TestTargetClamping(t *testing.T) { - tm := New(0, MaxTarget+1, 0) + tm := New(time.Unix(0, 0), MaxTarget+1, 0) require.Equal(t, MaxTarget, tm.Target(), "tm.Target() clamped by constructor") tests := []struct { diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index ad59f942..65bf3fb4 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -63,7 +63,7 @@ func (s *Stub) BlockRebuilderFrom(b *types.Block) hook.BlockBuilder { Now: func() time.Time { return time.Unix( int64(b.Time()), //nolint:gosec // Won't overflow for a few millennia - int64(s.SubSecondBlockTime(b.Header())), + s.SubSecondBlockTime(b.Header()).Nanoseconds(), ) }, } diff --git a/sae/always.go b/sae/always.go index 5778ab16..fa519dd7 100644 --- a/sae/always.go +++ b/sae/always.go @@ -60,7 +60,8 @@ func (vm *SinceGenesis) Initialize( if err != nil { return fmt.Errorf("blocks.New(%T.ToBlock(), ...): %v", genesis, err) } - if err := genBlock.MarkSynchronous(db, 1e6, 0); err != nil { + tgt := vm.config.Hooks.GasTargetAfter(genBlock.Header()) + if err := genBlock.MarkSynchronous(db, 0 /*sub-second time*/, tgt, 0 /*gas excess*/); err != nil { return fmt.Errorf("%T{genesis}.MarkSynchronous(): %v", genBlock, err) } From bb6915ca8449725451ab6991866ac7564c2f119a Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Sun, 25 Jan 2026 20:30:11 +0000 Subject: [PATCH 05/12] chore: placate the linter --- sae/worstcase_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sae/worstcase_test.go b/sae/worstcase_test.go index 37bb2560..d9329c89 100644 --- a/sae/worstcase_test.go +++ b/sae/worstcase_test.go @@ -66,7 +66,7 @@ func createWorstCaseFuzzFlags(set *flag.FlagSet) { // it to be included in a chain's genesis. type guzzler struct { params.NOOPHooks `json:"-"` - Addr common.Address `json:"guzzler_address"` + Addr common.Address `json:"guzzlerAddress"` } func (*guzzler) register(tb testing.TB) params.ExtraPayloads[*guzzler, *guzzler] { From 09d917c0080be8745b6510e5480bec0ad51e7952 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Sun, 25 Jan 2026 20:42:44 +0000 Subject: [PATCH 06/12] refactor: `MarkSynchronous()` accepts `hook.Points` and derives original arguments --- blocks/block_test.go | 7 +++++-- blocks/blockstest/blocks.go | 14 +++++++------- blocks/execution.go | 4 +++- blocks/settlement.go | 6 +++--- sae/always.go | 3 +-- 5 files changed, 19 insertions(+), 15 deletions(-) diff --git a/blocks/block_test.go b/blocks/block_test.go index cb82585d..26713e26 100644 --- a/blocks/block_test.go +++ b/blocks/block_test.go @@ -14,6 +14,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/ava-labs/strevm/hook/hookstest" "github.com/ava-labs/strevm/saetest" ) @@ -67,8 +68,10 @@ func newChain(tb testing.TB, db ethdb.Database, startHeight, total uint64, lastS blocks = append(blocks, b) if synchronous { // The target and excess are irrelevant for the purposes of - // [newChain], and sub-second time for genesis is unnecessary. - require.NoError(tb, b.MarkSynchronous(db, 0, 1, 0), "MarkSynchronous()") + // [newChain], and non-zero sub-second time for genesis is + // unnecessary. + h := &hookstest.Stub{Target: 1} + require.NoError(tb, b.MarkSynchronous(h, db, 0), "MarkSynchronous()") } parent = byNum[n] diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go index 02c7afa3..d0dd17f9 100644 --- a/blocks/blockstest/blocks.go +++ b/blocks/blockstest/blocks.go @@ -12,7 +12,6 @@ import ( "math/big" "slices" "testing" - "time" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/vms/components/gas" @@ -28,6 +27,7 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/strevm/blocks" + "github.com/ava-labs/strevm/hook/hookstest" "github.com/ava-labs/strevm/saetest" ) @@ -122,16 +122,16 @@ func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, al require.NoErrorf(tb, tdb.Commit(hash, true), "%T.Commit(core.SetupGenesisBlock(...))", tdb) b := NewBlock(tb, gen.ToBlock(), nil, nil) - require.NoErrorf(tb, b.MarkSynchronous(db, conf.subSecondBlockTime, conf.gasTarget, conf.gasExcess), "%T.MarkSynchronous()", b) + h := &hookstest.Stub{Target: conf.gasTarget} + require.NoErrorf(tb, b.MarkSynchronous(h, db, conf.gasExcess), "%T.MarkSynchronous()", b) return b } type genesisConfig struct { - tdbConfig *triedb.Config - timestamp uint64 - subSecondBlockTime time.Duration - gasTarget gas.Gas - gasExcess gas.Gas + tdbConfig *triedb.Config + timestamp uint64 + gasTarget gas.Gas + gasExcess gas.Gas } // A GenesisOption configures [NewGenesis]. diff --git a/blocks/execution.go b/blocks/execution.go index d3fb5a54..d03e4044 100644 --- a/blocks/execution.go +++ b/blocks/execution.go @@ -48,7 +48,9 @@ type executionResults struct { } // MarkExecuted marks the block as having been executed at the specified time(s) -// and with the specified results. It also sets the chain's head block to b. +// and with the specified results. It also sets the chain's head block to b. The +// [gastime.Time] MUST have already been scaled to the target applicable after +// the block, as defined by the relevant [hook.Points]. // // MarkExecuted guarantees that state is persisted to the database before // in-memory indicators of execution are updated. [Block.Executed] returning diff --git a/blocks/settlement.go b/blocks/settlement.go index c46cd608..78a7dfe7 100644 --- a/blocks/settlement.go +++ b/blocks/settlement.go @@ -79,15 +79,15 @@ func (b *Block) markSettled(lastSettled *atomic.Pointer[Block]) error { // Wherever MarkSynchronous results in different behaviour to // [Block.MarkSettled], the respective methods are documented as such. They can // otherwise be considered identical. -func (b *Block) MarkSynchronous(db ethdb.Database, subSecondBlockTime time.Duration, gasTargetAfterBlock, excessAfter gas.Gas) error { +func (b *Block) MarkSynchronous(hooks hook.Points, db ethdb.Database, excessAfter gas.Gas) error { ethB := b.EthBlock() baseFee := ethB.BaseFee() if baseFee == nil { // genesis blocks baseFee = new(big.Int) } execTime := gastime.New( - preciseTime(b.Header(), subSecondBlockTime), - gasTargetAfterBlock, + PreciseTime(hooks, b.Header()), + hooks.GasTargetAfter(b.Header()), // target _after_ is a requirement of [Block.MarkExecuted] excessAfter, ) // Receipts of a synchronous block have already been "settled" by the block diff --git a/sae/always.go b/sae/always.go index fa519dd7..69aedb73 100644 --- a/sae/always.go +++ b/sae/always.go @@ -60,8 +60,7 @@ func (vm *SinceGenesis) Initialize( if err != nil { return fmt.Errorf("blocks.New(%T.ToBlock(), ...): %v", genesis, err) } - tgt := vm.config.Hooks.GasTargetAfter(genBlock.Header()) - if err := genBlock.MarkSynchronous(db, 0 /*sub-second time*/, tgt, 0 /*gas excess*/); err != nil { + if err := genBlock.MarkSynchronous(vm.config.Hooks, db, 0 /*gas excess*/); err != nil { return fmt.Errorf("%T{genesis}.MarkSynchronous(): %v", genBlock, err) } From 773ccf64c86720aa3da843295d2d55084b665e03 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Sun, 25 Jan 2026 20:45:57 +0000 Subject: [PATCH 07/12] refactor: use typed-nil instead of `/* argument explanation */` --- blocks/settlement.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/blocks/settlement.go b/blocks/settlement.go index 78a7dfe7..c38cf956 100644 --- a/blocks/settlement.go +++ b/blocks/settlement.go @@ -13,6 +13,7 @@ import ( "time" "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/ethdb" "go.uber.org/zap" @@ -94,7 +95,8 @@ func (b *Block) MarkSynchronous(hooks hook.Points, db ethdb.Database, excessAfte // itself. As the only reason to pass receipts here is for later settlement // in another block, there is no need to pass anything meaningful as it // would also require them to be received as an argument to MarkSynchronous. - if err := b.MarkExecuted(db, execTime, time.Time{}, baseFee, nil /*receipts*/, ethB.Root(), new(atomic.Pointer[Block])); err != nil { + var rs types.Receipts + if err := b.MarkExecuted(db, execTime, time.Time{}, baseFee, rs, ethB.Root(), new(atomic.Pointer[Block])); err != nil { return err } b.synchronous = true From 76a5b90be27ce40de308596cbbc64001cc440555 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Thu, 29 Jan 2026 15:15:49 +0000 Subject: [PATCH 08/12] refactor: replace `Tick(1)` call with constructor arg --- gastime/gastime_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/gastime/gastime_test.go b/gastime/gastime_test.go index 84285eee..21de44fc 100644 --- a/gastime/gastime_test.go +++ b/gastime/gastime_test.go @@ -27,8 +27,7 @@ func (tm *Time) cloneViaCanotoRoundTrip(tb testing.TB) *Time { } func TestClone(t *testing.T) { - tm := New(time.Unix(42, 0), 1e6, 1e5) - tm.Tick(1) + tm := New(time.Unix(42, 1), 1e6, 1e5) if diff := cmp.Diff(tm, tm.Clone(), CmpOpt()); diff != "" { t.Errorf("%T.Clone() diff (-want +got):\n%s", tm, diff) From 08be21d1c021f2a6a1c3f47ddd7d3bf00b707bb1 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg <519948+ARR4N@users.noreply.github.com> Date: Thu, 29 Jan 2026 15:18:05 +0000 Subject: [PATCH 09/12] refactor: remove unused receiver variable Co-authored-by: Stephen Buttolph Signed-off-by: Arran Schlosberg <519948+ARR4N@users.noreply.github.com> --- sae/vm.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sae/vm.go b/sae/vm.go index 1ccf3cd0..b8bfd264 100644 --- a/sae/vm.go +++ b/sae/vm.go @@ -292,7 +292,7 @@ func (vm *VM) Shutdown(context.Context) error { } // Version reports the VM's version. -func (vm *VM) Version(context.Context) (string, error) { +func (*VM) Version(context.Context) (string, error) { return version.Current.String(), nil } From 20ec0aa22d4e37192db6846db7594fa43a70454b Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Thu, 29 Jan 2026 15:25:26 +0000 Subject: [PATCH 10/12] test: empty `ChainConfig` can start and build blocks --- sae/vm_test.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/sae/vm_test.go b/sae/vm_test.go index f37aad05..b7f2d35a 100644 --- a/sae/vm_test.go +++ b/sae/vm_test.go @@ -495,6 +495,17 @@ func TestIntegration(t *testing.T) { }) } +func TestEmptyChainConfig(t *testing.T) { + _, sut := newSUT(t, 1, options.Func[sutConfig](func(c *sutConfig) { + c.genesis.Config = ¶ms.ChainConfig{ + ChainID: big.NewInt(42), + } + })) + for range 5 { + sut.runConsensusLoop(t, sut.lastAcceptedBlock(t)) + } +} + func TestSyntacticBlockChecks(t *testing.T) { ctx, sut := newSUT(t, 0) From 45d764c8a7964e34ac76997260053e9dc358806c Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Thu, 29 Jan 2026 15:31:55 +0000 Subject: [PATCH 11/12] test: `sae.SinceGenesis` before `Initialize()` --- sae/always_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 sae/always_test.go diff --git a/sae/always_test.go b/sae/always_test.go new file mode 100644 index 00000000..263d7c70 --- /dev/null +++ b/sae/always_test.go @@ -0,0 +1,22 @@ +// Copyright (C) 2026, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package sae + +import ( + "testing" + + "github.com/ava-labs/avalanchego/version" + "github.com/stretchr/testify/require" +) + +func TestSinceGenesisBeforeInit(t *testing.T) { + ctx := t.Context() + sut := NewSinceGenesis(Config{}) + t.Run("Version", func(t *testing.T) { + got, err := sut.Version(ctx) + require.NoError(t, err) + require.Equal(t, version.Current.String(), got) + }) + require.NoErrorf(t, sut.Shutdown(t.Context()), "%T.Shutdown()") +} From c14d5dc815976ef6bd907f3889db1bd31d2513b0 Mon Sep 17 00:00:00 2001 From: Arran Schlosberg Date: Thu, 29 Jan 2026 15:34:00 +0000 Subject: [PATCH 12/12] chore: placate the linter --- sae/always_test.go | 5 +++-- sae/vm_test.go | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/sae/always_test.go b/sae/always_test.go index 263d7c70..d53b7f26 100644 --- a/sae/always_test.go +++ b/sae/always_test.go @@ -4,6 +4,7 @@ package sae import ( + "fmt" "testing" "github.com/ava-labs/avalanchego/version" @@ -13,10 +14,10 @@ import ( func TestSinceGenesisBeforeInit(t *testing.T) { ctx := t.Context() sut := NewSinceGenesis(Config{}) - t.Run("Version", func(t *testing.T) { + t.Run(fmt.Sprintf("%T.Version", sut), func(t *testing.T) { got, err := sut.Version(ctx) require.NoError(t, err) require.Equal(t, version.Current.String(), got) }) - require.NoErrorf(t, sut.Shutdown(t.Context()), "%T.Shutdown()") + require.NoErrorf(t, sut.Shutdown(t.Context()), "%T.Shutdown()", sut) } diff --git a/sae/vm_test.go b/sae/vm_test.go index b7f2d35a..4110f36b 100644 --- a/sae/vm_test.go +++ b/sae/vm_test.go @@ -217,6 +217,7 @@ func (t *vmTime) advance(d time.Duration) { // withVMTime returns an option to configure a new SUT's "now" function along // with a struct to access and set the time at nanosecond resolution. func withVMTime(tb testing.TB, startTime time.Time) (sutOption, *vmTime) { + tb.Helper() t := &vmTime{ Time: startTime, }