diff --git a/.golangci.yml b/.golangci.yml index e221766..70ea23f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -51,7 +51,6 @@ linters-settings: - localmodule # The rest of these break developer expections, in increasing order of # divergence, so are at the end to increase the chance of being seen. - - alias - dot - blank goheader: diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go index 73360e0..fa952bd 100644 --- a/blocks/blockstest/blocks.go +++ b/blocks/blockstest/blocks.go @@ -67,14 +67,34 @@ func WithReceipts(rs types.Receipts) EthBlockOption { }) } +// A BlockOption configures the default block properties created by [NewBlock]. +type BlockOption = options.Option[blockProperties] + // NewBlock constructs an SAE block, wrapping the raw Ethereum block. -func NewBlock(tb testing.TB, eth *types.Block, parent, lastSettled *blocks.Block) *blocks.Block { +func NewBlock(tb testing.TB, eth *types.Block, parent, lastSettled *blocks.Block, opts ...BlockOption) *blocks.Block { tb.Helper() - b, err := blocks.New(eth, parent, lastSettled, saetest.NewTBLogger(tb, logging.Warn)) + + props := options.ApplyTo(&blockProperties{}, opts...) + if props.logger == nil { + props.logger = saetest.NewTBLogger(tb, logging.Warn) + } + + b, err := blocks.New(eth, parent, lastSettled, props.logger) require.NoError(tb, err, "blocks.New()") return b } +type blockProperties struct { + logger logging.Logger +} + +// WithLogger overrides the logger passed to [blocks.New] by [NewBlock]. +func WithLogger(l logging.Logger) BlockOption { + return options.Func[blockProperties](func(p *blockProperties) { + p.logger = l + }) +} + // NewGenesis constructs a new [core.Genesis], writes it to the database, and // returns wraps [core.Genesis.ToBlock] with [NewBlock]. It assumes a nil // [triedb.Config] unless overridden by a [WithTrieDBConfig]. The block is diff --git a/blocks/blockstest/blocks_test.go b/blocks/blockstest/blocks_test.go index 53ef35f..cdf7978 100644 --- a/blocks/blockstest/blocks_test.go +++ b/blocks/blockstest/blocks_test.go @@ -74,9 +74,11 @@ func TestIntegration(t *testing.T) { txs = append(txs, tx) } } - b := build.NewBlock(t, txs, ModifyHeader(func(h *types.Header) { - h.GasLimit = 100e6 - })) + b := build.NewBlock(t, txs, WithEthBlockOptions( + ModifyHeader(func(h *types.Header) { + h.GasLimit = 100e6 + })), + ) receipts, _, _, err := stateProc.Process(b.EthBlock(), sdb, *bc.GetVMConfig()) require.NoError(t, err, "%T.Process(%T.NewBlock().EthBlock()...)", stateProc, build) diff --git a/blocks/blockstest/chain.go b/blocks/blockstest/chain.go index ef555bd..9a5a569 100644 --- a/blocks/blockstest/chain.go +++ b/blocks/blockstest/chain.go @@ -8,16 +8,23 @@ package blockstest import ( + "slices" + "sync" "testing" + "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/libevm/options" "github.com/ava-labs/strevm/blocks" ) // A ChainBuilder builds a chain of blocks, maintaining necessary invariants. type ChainBuilder struct { - chain []*blocks.Block + chain []*blocks.Block + blocksByHash sync.Map + + defaultOpts []ChainOption } // NewChainBuilder returns a new ChainBuilder starting from the provided block, @@ -28,13 +35,51 @@ func NewChainBuilder(genesis *blocks.Block) *ChainBuilder { } } +// A ChainOption configures [ChainBuilder.NewBlock]. +type ChainOption = options.Option[chainOptions] + +// SetDefaultOptions sets the default options upon which all +// additional options passed to [ChainBuilder.NewBlock] are appended. +func (cb *ChainBuilder) SetDefaultOptions(opts ...ChainOption) { + cb.defaultOpts = opts +} + +type chainOptions struct { + eth []EthBlockOption + sae []BlockOption +} + +// WithEthBlockOptions wraps the options that [ChainBuilder.NewBlock] propagates +// to [NewEthBlock]. +func WithEthBlockOptions(opts ...EthBlockOption) ChainOption { + return options.Func[chainOptions](func(co *chainOptions) { + co.eth = append(co.eth, opts...) + }) +} + +// WithBlockOptions wraps the options that [ChainBuilder.NewBlock] propagates to +// [NewBlock]. +func WithBlockOptions(opts ...BlockOption) ChainOption { + return options.Func[chainOptions](func(co *chainOptions) { + co.sae = append(co.sae, opts...) + }) +} + // NewBlock constructs and returns a new block in the chain. -func (cb *ChainBuilder) NewBlock(tb testing.TB, txs []*types.Transaction, opts ...EthBlockOption) *blocks.Block { +func (cb *ChainBuilder) NewBlock(tb testing.TB, txs []*types.Transaction, opts ...ChainOption) *blocks.Block { tb.Helper() + + allOpts := new(chainOptions) + options.ApplyTo(allOpts, cb.defaultOpts...) + options.ApplyTo(allOpts, opts...) + last := cb.Last() - eth := NewEthBlock(last.EthBlock(), txs, opts...) - cb.chain = append(cb.chain, NewBlock(tb, eth, last, nil)) // TODO(arr4n) support last-settled blocks - return cb.Last() + eth := NewEthBlock(last.EthBlock(), txs, allOpts.eth...) + b := NewBlock(tb, eth, last, nil, allOpts.sae...) // TODO(arr4n) support last-settled blocks + cb.chain = append(cb.chain, b) + cb.blocksByHash.Store(b.Hash(), b) + + return b } // Last returns the last block to be built by the builder, which MAY be the @@ -42,3 +87,26 @@ func (cb *ChainBuilder) NewBlock(tb testing.TB, txs []*types.Transaction, opts . func (cb *ChainBuilder) Last() *blocks.Block { return cb.chain[len(cb.chain)-1] } + +// AllBlocks returns all blocks, including the genesis passed to +// [NewChainBuilder]. +func (cb *ChainBuilder) AllBlocks() []*blocks.Block { + return slices.Clone(cb.chain) +} + +// AllExceptGenesis returns all blocks created with [ChainBuilder.NewBlock]. +func (cb *ChainBuilder) AllExceptGenesis() []*blocks.Block { + return slices.Clone(cb.chain[1:]) +} + +// GetBlock returns the block with specified hash and height, and a flag +// indicating if it was found. If either argument does not match, it returns +// `nil, false`. +func (cb *ChainBuilder) GetBlock(h common.Hash, num uint64) (*blocks.Block, bool) { + ifc, _ := cb.blocksByHash.Load(h) + b, ok := ifc.(*blocks.Block) + if !ok || b.NumberU64() != num { + return nil, false + } + return b, true +} diff --git a/blocks/export.go b/blocks/export.go index 80541e7..1f4e4c6 100644 --- a/blocks/export.go +++ b/blocks/export.go @@ -37,6 +37,9 @@ func (b *Block) BuildTime() uint64 { return b.b.Time() } // Hash returns [types.Block.Hash] from the wrapped [types.Block]. func (b *Block) Hash() common.Hash { return b.b.Hash() } +// Header returns [types.Block.Header] from the wrapped [types.Block]. +func (b *Block) Header() *types.Header { return b.b.Header() } + // ParentHash returns [types.Block.ParentHash] from the wrapped [types.Block]. func (b *Block) ParentHash() common.Hash { return b.b.ParentHash() } @@ -45,3 +48,6 @@ func (b *Block) NumberU64() uint64 { return b.b.NumberU64() } // Number returns [types.Block.Number] from the wrapped [types.Block]. func (b *Block) Number() *big.Int { return b.b.Number() } + +// Transactions returns [types.Block.Transactions] from the wrapped [types.Block]. +func (b *Block) Transactions() types.Transactions { return b.b.Transactions() } diff --git a/cmputils/types.go b/cmputils/types.go new file mode 100644 index 0000000..d82ae07 --- /dev/null +++ b/cmputils/types.go @@ -0,0 +1,35 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package cmputils + +import ( + "math/big" + + "github.com/ava-labs/libevm/core/types" + "github.com/google/go-cmp/cmp" +) + +// BigInts returns a [cmp.Comparer] for [big.Int] pointers. A nil pointer is not +// equal to zero. +func BigInts() cmp.Option { + return ComparerWithNilCheck(func(a, b *big.Int) bool { + return a.Cmp(b) == 0 + }) +} + +// BlocksByHash returns a [cmp.Comparer] for [types.Block] pointers, equating +// them by hash alone. +func BlocksByHash() cmp.Option { + return ComparerWithNilCheck(func(b, c *types.Block) bool { + return b.Hash() == c.Hash() + }) +} + +// ReceiptsByTxHash returns a [cmp.Comparer] for [types.Receipt] pointers, +// equating them by transaction hash alone. +func ReceiptsByTxHash() cmp.Option { + return ComparerWithNilCheck(func(r, s *types.Receipt) bool { + return r.TxHash == s.TxHash + }) +} diff --git a/gastime/gastime.go b/gastime/gastime.go index 167ea31..4c663c3 100644 --- a/gastime/gastime.go +++ b/gastime/gastime.go @@ -58,6 +58,13 @@ func New(unixSeconds uint64, target, startingExcess gas.Gas) *Time { // TargetToRate is the ratio between [Time.Target] and [proxytime.Time.Rate]. const TargetToRate = 2 +// TargetToExcessScaling is the ratio between [Time.Target] and the reciprocal +// of the [Time.Excess] coefficient used in calculating [Time.Price]. In +// [ACP-176] this is the K variable. +// +// [ACP-176]: https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/176-dynamic-evm-gas-limit-and-price-discovery-updates +const TargetToExcessScaling = 87 + // MaxTarget is the maximum allowable [Time.Target] to avoid overflows of the // associated [proxytime.Time.Rate]. Values above this are silently clamped. const MaxTarget = gas.Gas(math.MaxUint64 / TargetToRate) @@ -91,14 +98,11 @@ func (tm *Time) Price() gas.Price { // excessScalingFactor returns the K variable of ACP-103/176, i.e. 87*T, capped // at [math.MaxUint64]. func (tm *Time) excessScalingFactor() gas.Gas { - const ( - targetToK = 87 - overflowThreshold = math.MaxUint64 / targetToK - ) + const overflowThreshold = math.MaxUint64 / TargetToExcessScaling if tm.target > overflowThreshold { return math.MaxUint64 } - return targetToK * tm.target + return TargetToExcessScaling * tm.target } // BaseFee is equivalent to [Time.Price], returning the result as a uint256 for diff --git a/go.mod b/go.mod index 0fd7b42..9f0bca3 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,10 @@ go 1.24.8 require ( github.com/ava-labs/avalanchego v1.13.2 github.com/ava-labs/libevm v1.13.15-0.20251112182915-1ec8741af98f - github.com/google/go-cmp v0.6.0 + github.com/google/go-cmp v0.7.0 github.com/holiman/uint256 v1.2.4 github.com/stretchr/testify v1.10.0 + go.uber.org/goleak v1.3.0 golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b ) diff --git a/go.sum b/go.sum index 38e13de..c3389b7 100644 --- a/go.sum +++ b/go.sum @@ -166,8 +166,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= diff --git a/hook/hook.go b/hook/hook.go new file mode 100644 index 0000000..aca21d8 --- /dev/null +++ b/hook/hook.go @@ -0,0 +1,59 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package hook defines points in an SAE block's lifecycle at which common or +// user-injected behaviour needs to be performed. Functions in this package +// SHOULD be called by all code dealing with a block at the respective point in +// its lifecycle, be that during validation, execution, or otherwise. +package hook + +import ( + "fmt" + + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/params" + + "github.com/ava-labs/strevm/blocks" + "github.com/ava-labs/strevm/gastime" + "github.com/ava-labs/strevm/intmath" + saeparams "github.com/ava-labs/strevm/params" +) + +// Points define user-injected hook points. +type Points interface { + GasTarget(parent *types.Block) gas.Gas + SubSecondBlockTime(*types.Block) gas.Gas + BeforeBlock(params.Rules, *state.StateDB, *types.Block) error + AfterBlock(*state.StateDB, *types.Block, types.Receipts) +} + +// BeforeBlock is intended to be called before processing a block, with the gas +// target sourced from [Points]. +func BeforeBlock(pts Points, rules params.Rules, sdb *state.StateDB, b *blocks.Block, clock *gastime.Time) error { + clock.FastForwardTo( + b.BuildTime(), + pts.SubSecondBlockTime(b.EthBlock()), + ) + target := pts.GasTarget(b.ParentBlock().EthBlock()) + if err := clock.SetTarget(target); err != nil { + return fmt.Errorf("%T.SetTarget() before block: %w", clock, err) + } + return pts.BeforeBlock(rules, sdb, b.EthBlock()) +} + +// AfterBlock is intended to be called after processing a block, with the gas +// sourced from [types.Block.GasUsed] or equivalent. +func AfterBlock(pts Points, sdb *state.StateDB, b *types.Block, clock *gastime.Time, used gas.Gas, rs types.Receipts) { + clock.Tick(used) + pts.AfterBlock(sdb, b, rs) +} + +// MinimumGasConsumption MUST be used as the implementation for the respective +// method on [params.RulesHooks]. The concrete type implementing the hooks MUST +// propagate incoming and return arguments unchanged. +func MinimumGasConsumption(txLimit uint64) uint64 { + _ = (params.RulesHooks)(nil) // keep the import to allow [] doc links + return intmath.CeilDiv(txLimit, saeparams.Lambda) +} diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go new file mode 100644 index 0000000..611c70f --- /dev/null +++ b/hook/hookstest/stub.go @@ -0,0 +1,39 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package hookstest provides a test double for SAE's [hook] package. +package hookstest + +import ( + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/params" + + "github.com/ava-labs/strevm/hook" +) + +// Stub implements [hook.Points]. +type Stub struct { + Target gas.Gas +} + +var _ hook.Points = (*Stub)(nil) + +// GasTarget ignores its argument and always returns [Stub.Target]. +func (s *Stub) GasTarget(parent *types.Block) gas.Gas { + return s.Target +} + +// SubSecondBlockTime time ignores its argument and always returns 0. +func (*Stub) SubSecondBlockTime(*types.Block) gas.Gas { + return 0 +} + +// BeforeBlock is a no-op that always returns nil. +func (*Stub) BeforeBlock(params.Rules, *state.StateDB, *types.Block) error { + return nil +} + +// AfterBlock is a no-op. +func (*Stub) AfterBlock(*state.StateDB, *types.Block, types.Receipts) {} diff --git a/params/params.go b/params/params.go new file mode 100644 index 0000000..6b650a6 --- /dev/null +++ b/params/params.go @@ -0,0 +1,12 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package params declares [Streaming Asynchronous Execution] (SAE) parameters. +// +// [Streaming Asynchronous Execution]: https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/194-streaming-asynchronous-execution +package params + +// Lambda is the denominator for computing the minimum gas consumed per +// transaction. For a transaction with gas limit `g`, the minimum consumption is +// ceil(g/Lambda). +const Lambda = 2 diff --git a/proxytime/proxytime_test.go b/proxytime/proxytime_test.go index eeb7523..8231bc7 100644 --- a/proxytime/proxytime_test.go +++ b/proxytime/proxytime_test.go @@ -10,10 +10,9 @@ import ( "testing" "time" + gocmp "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - gocmp "github.com/google/go-cmp/cmp" ) func frac(num, den uint64) FractionalSecond[uint64] { diff --git a/saetest/escrow/escrow.go b/saetest/escrow/escrow.go new file mode 100644 index 0000000..0f4efd1 --- /dev/null +++ b/saetest/escrow/escrow.go @@ -0,0 +1,68 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// The above copyright and licensing exclude the original Escrow.sol contract +// and compiled artefacts, which are licensed under the following: +// +// Copyright 2024 Divergence Tech Ltd. + +// Package escrow provides bytecode and helpers for the [Escrow.sol] contract +// deployed to 0x370F21541173E8B773571c135e3b5617d7f38C54 on Ethereum mainnet. +// +// [Escrow.sol]: https://github.com/ARR4N/SWAP2/blob/fe724e87bdc998c3b497c16e35fed354e53dc3e9/src/Escrow.sol +package escrow + +import ( + "slices" + + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/crypto" + "github.com/holiman/uint256" +) + +const ( + creation = "0x6080806040523460155761029e908161001a8239f35b5f80fdfe6040608081526004361015610012575f80fd5b5f3560e01c80633ccfd60b1461017757806351cff8d914610148578063837b2d1d1461010e578063e3d670d7146100d35763f340fa0114610051575f80fd5b60203660031901126100cf576004356001600160a01b03811691908290036100cf57815f525f602052805f209182543481018091116100bb577fe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c93558151908152346020820152a1005b634e487b7160e01b5f52601160045260245ffd5b5f80fd5b50346100cf5760203660031901126100cf576004356001600160a01b03811691908290036100cf576020915f525f8252805f20549051908152f35b50346100cf575f3660031901126100cf57602090517fe3f9c77ea5446c989d214acc27cefc902862791ee093b44540c8790a484451828152f35b346100cf5760203660031901126100cf576004356001600160a01b03811681036100cf576101759061018c565b005b346100cf575f3660031901126100cf57610175335b60018060a01b0316805f525f602052604090815f2054801561027a57815f525f6020525f83812055804710610263575f80808084865af13d1561025e5767ffffffffffffffff3d81811161024a57855191601f8201601f19908116603f011683019081118382101761024a57865281525f60203d92013e5b1561023957825191825260208201527f7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b659190a1565b8251630a12f52160e11b8152600490fd5b634e487b7160e01b5f52604160045260245ffd5b610204565b825163cd78605960e01b8152306004820152602490fd5b5060249151906316b4356760e31b82526004820152fdfea164736f6c6343000819000a" + deployed = "0x6040608081526004361015610012575f80fd5b5f3560e01c80633ccfd60b1461017757806351cff8d914610148578063837b2d1d1461010e578063e3d670d7146100d35763f340fa0114610051575f80fd5b60203660031901126100cf576004356001600160a01b03811691908290036100cf57815f525f602052805f209182543481018091116100bb577fe1fffcc4923d04b559f4d29a8bfc6cda04eb5b0d3c460751c2402c5c5cc9109c93558151908152346020820152a1005b634e487b7160e01b5f52601160045260245ffd5b5f80fd5b50346100cf5760203660031901126100cf576004356001600160a01b03811691908290036100cf576020915f525f8252805f20549051908152f35b50346100cf575f3660031901126100cf57602090517fe3f9c77ea5446c989d214acc27cefc902862791ee093b44540c8790a484451828152f35b346100cf5760203660031901126100cf576004356001600160a01b03811681036100cf576101759061018c565b005b346100cf575f3660031901126100cf57610175335b60018060a01b0316805f525f602052604090815f2054801561027a57815f525f6020525f83812055804710610263575f80808084865af13d1561025e5767ffffffffffffffff3d81811161024a57855191601f8201601f19908116603f011683019081118382101761024a57865281525f60203d92013e5b1561023957825191825260208201527f7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b659190a1565b8251630a12f52160e11b8152600490fd5b634e487b7160e01b5f52604160045260245ffd5b610204565b825163cd78605960e01b8152306004820152602490fd5b5060249151906316b4356760e31b82526004820152fdfea164736f6c6343000819000a" +) + +// CreationCode returns the EVM bytecode for deploying the Escrow.sol contract. +func CreationCode() []byte { + return common.FromHex(creation) +} + +// ByteCode returns the deployed EVM bytecode of the Escrow.sol contract. +func ByteCode() []byte { + return common.FromHex(deployed) +} + +// CallDataToDeposit returns the transaction call data to deposit native token +// for the given recipient. +func CallDataToDeposit(recipient common.Address) []byte { + return callDataWithAddr("deposit(address)", recipient) +} + +// CallDataForBalance returns the transaction call data to retrieve the balance +// in escrow for the given beneficiary. +func CallDataForBalance(beneficiary common.Address) []byte { + return callDataWithAddr("balance(address)", beneficiary) +} + +func callDataWithAddr(sig string, addr common.Address) []byte { + return slices.Concat( + crypto.Keccak256([]byte(sig))[:4], + make([]byte, 12), addr[:], + ) +} + +// DepositEvent returns the [types.Log] emitted by a successful transaction with +// [CallDataToDeposit] data. +func DepositEvent(recipient common.Address, amount *uint256.Int) *types.Log { + return &types.Log{ + Topics: []common.Hash{crypto.Keccak256Hash([]byte("Deposit(address,uint256)"))}, + Data: slices.Concat( + make([]byte, 12), recipient[:], + amount.PaddedBytes(32), + ), + } +} diff --git a/saetest/logging.go b/saetest/logging.go index ca57e8b..eb7d2c8 100644 --- a/saetest/logging.go +++ b/saetest/logging.go @@ -4,6 +4,7 @@ package saetest import ( + "context" "runtime" "slices" "testing" @@ -104,24 +105,39 @@ func (l *LogRecorder) AtLeast(lvl logging.Level) []*LogRecord { return l.Filter(func(r *LogRecord) bool { return r.Level >= lvl }) } -// NewTBLogger constructs a logger that propagates logs to the [testing.TB]. -// WARNING and ERROR logs are sent to [testing.TB.Errorf] while FATAL is sent to +// NewTBLogger constructs a logger that propagates logs to [testing.TB]. WARNING +// and ERROR logs are sent to [testing.TB.Errorf] while FATAL is sent to // [testing.TB.Fatalf]. All other logs are sent to [testing.TB.Logf]. Although // the level can be configured, it is silently capped at [logging.Warn]. // //nolint:thelper // The outputs include the logging site while the TB site is most useful if here -func NewTBLogger(tb testing.TB, level logging.Level) logging.Logger { - return &logger{ +func NewTBLogger(tb testing.TB, level logging.Level) *TBLogger { + l := &TBLogger{tb: tb} + l.logger = &logger{ + handler: l, // TODO(arr4n) remove the recursion here and in [LogRecorder] level: min(level, logging.Warn), - handler: &tbLogger{tb: tb}, } + return l +} + +// TBLogger is a [logging.Logger] that propagates logs to [testing.TB]. +type TBLogger struct { + *logger + tb testing.TB + onError []context.CancelFunc } -type tbLogger struct { - tb testing.TB +// CancelOnError pipes `ctx` to and from [context.WithCancel], calling the +// [context.CancelFunc] after logs >= [logging.Error], and during [testing.TB] +// cleanup. +func (l *TBLogger) CancelOnError(ctx context.Context) context.Context { + ctx, cancel := context.WithCancel(ctx) + l.onError = append(l.onError, cancel) + l.tb.Cleanup(cancel) + return ctx } -func (l *tbLogger) log(lvl logging.Level, msg string, fields ...zap.Field) { +func (l *TBLogger) log(lvl logging.Level, msg string, fields ...zap.Field) { var to func(string, ...any) switch { case lvl == logging.Warn || lvl == logging.Error: // because @ARR4N says warnings in tests are errors @@ -132,6 +148,15 @@ func (l *tbLogger) log(lvl logging.Level, msg string, fields ...zap.Field) { to = l.tb.Logf } + defer func() { + if lvl < logging.Error { + return + } + for _, fn := range l.onError { + fn() + } + }() + enc := zapcore.NewMapObjectEncoder() for _, f := range fields { f.AddTo(enc) diff --git a/saetest/saetest.go b/saetest/saetest.go index b0cc7d6..e7125c9 100644 --- a/saetest/saetest.go +++ b/saetest/saetest.go @@ -8,7 +8,11 @@ package saetest import ( + "slices" + "sync" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/event" "github.com/ava-labs/libevm/trie" "github.com/google/go-cmp/cmp" ) @@ -27,3 +31,64 @@ func MerkleRootsEqual[T types.DerivableList](a, b T) bool { func CmpByMerkleRoots[T types.DerivableList]() cmp.Option { return cmp.Comparer(MerkleRootsEqual[T]) } + +// An EventCollector collects all events received from an [event.Subscription]. +// All methods are safe for concurrent use. +type EventCollector[T any] struct { + ch chan T + done chan struct{} + sub event.Subscription + + all []T + cond *sync.Cond +} + +// NewEventCollector returns a new [EventCollector], subscribing via the +// provided function. [EventCollector.Unsubscribe] must be called to release +// resources. +func NewEventCollector[T any](subscribe func(chan<- T) event.Subscription) *EventCollector[T] { + c := &EventCollector[T]{ + ch: make(chan T), + done: make(chan struct{}), + cond: sync.NewCond(&sync.Mutex{}), + } + c.sub = subscribe(c.ch) + go c.collect() + return c +} + +func (c *EventCollector[T]) collect() { + defer close(c.done) + for x := range c.ch { + c.cond.L.Lock() + c.all = append(c.all, x) + c.cond.L.Unlock() + c.cond.Broadcast() + } +} + +// All returns all events received thus far. +func (c *EventCollector[T]) All() []T { + c.cond.L.Lock() + defer c.cond.L.Unlock() + return slices.Clone(c.all) +} + +// Unsubscribe unsubscribes from the subscription and returns the error, +// possibly nil, received on [event.Subscription.Err]. +func (c *EventCollector[T]) Unsubscribe() error { + c.sub.Unsubscribe() + err := <-c.sub.Err() + close(c.ch) + <-c.done + return err +} + +// WaitForAtLeast blocks until at least `n` events have been received. +func (c *EventCollector[T]) WaitForAtLeast(n int) { + c.cond.L.Lock() + defer c.cond.L.Unlock() + for len(c.all) < n { + c.cond.Wait() + } +} diff --git a/saexec/context.go b/saexec/context.go new file mode 100644 index 0000000..eca9dd5 --- /dev/null +++ b/saexec/context.go @@ -0,0 +1,41 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package saexec + +import ( + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/libevm/common" + "github.com/ava-labs/libevm/consensus" + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/types" + + "github.com/ava-labs/strevm/blocks" +) + +// A BlockSource returns a block that matches both a hash and number, or nil +// if not found. +type BlockSource func(hash common.Hash, number uint64) *blocks.Block + +var _ core.ChainContext = (*chainContext)(nil) + +type chainContext struct { + blocks BlockSource + log logging.Logger +} + +func (c *chainContext) GetHeader(h common.Hash, n uint64) *types.Header { + b := c.blocks(h, n) + if b == nil { + return nil + } + return b.Header() +} + +func (c *chainContext) Engine() consensus.Engine { + // This is serious enough that it needs to be investigated immediately, but + // not enough to be fatal. It will also cause tests to fail if ever called, + // so we can catch it early. + c.log.Error("ChainContext.Engine() called unexpectedly") + return nil +} diff --git a/saexec/execution.go b/saexec/execution.go new file mode 100644 index 0000000..68df2b7 --- /dev/null +++ b/saexec/execution.go @@ -0,0 +1,191 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package saexec + +import ( + "context" + "errors" + "fmt" + "math" + "time" + + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/core/vm" + "go.uber.org/zap" + + "github.com/ava-labs/strevm/blocks" + "github.com/ava-labs/strevm/hook" +) + +var errExecutorClosed = errors.New("saexec.Executor closed") + +// Enqueue pushes a new block to the FIFO queue. If [Executor.Close] is called +// before [blocks.Block.Executed] returns true then there is no guarantee that +// the block will be executed. +func (e *Executor) Enqueue(ctx context.Context, block *blocks.Block) error { + warnAfter := time.Millisecond + for { + select { + case e.queue <- block: + return nil + + case <-ctx.Done(): + return ctx.Err() + + case <-e.quit: + return errExecutorClosed + case <-e.done: + // `e.done` can also close due to [Executor.execute] errors. + return errExecutorClosed + + case <-time.After(warnAfter): + // If this happens then increase the channel's buffer size. + e.log.Warn( + "Execution queue buffer too small", + zap.Duration("wait", warnAfter), + zap.Uint64("block_height", block.Height()), + ) + warnAfter *= 2 + } + } +} + +func (e *Executor) processQueue() { + defer close(e.done) + + for { + select { + case <-e.quit: + return + + case block := <-e.queue: + logger := e.log.With( + zap.Uint64("block_height", block.Height()), + zap.Uint64("block_time", block.BuildTime()), + zap.Stringer("block_hash", block.Hash()), + zap.Int("tx_count", len(block.Transactions())), + ) + + if err := e.execute(block, logger); err != nil { + logger.Fatal( + "Block execution failed; see emergency playbook", + zap.Error(err), + zap.String("playbook", "https://github.com/ava-labs/strevm/issues/28"), + ) + return + } + } + } +} + +func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { + logger.Debug("Executing block") + + // Since `b` hasn't been executed, it definitely hasn't been settled, so we + // are guaranteed to have a non-nil parent available. + parent := b.ParentBlock() + // If the VM were to encounter an error after enqueuing the block, we would + // receive the same block twice for execution should consensus retry + // acceptance. + if last := e.lastExecuted.Load().Hash(); last != parent.Hash() { + return fmt.Errorf("executing block built on parent %#x when last executed %#x", parent.Hash(), last) + } + + stateDB, err := state.New(parent.PostExecutionStateRoot(), e.stateCache, e.snaps) + if err != nil { + return fmt.Errorf("state.New(%#x, ...): %v", parent.PostExecutionStateRoot(), err) + } + + rules := e.chainConfig.Rules(b.Number(), true /*isMerge*/, b.BuildTime()) + gasClock := parent.ExecutedByGasTime().Clone() + if err := hook.BeforeBlock(e.hooks, rules, stateDB, b, gasClock); err != nil { + return fmt.Errorf("before-block hook: %v", err) + } + perTxClock := gasClock.Time.Clone() + + header := types.CopyHeader(b.Header()) + header.BaseFee = gasClock.BaseFee().ToBig() + + gasPool := core.GasPool(math.MaxUint64) // required by geth but irrelevant so max it out + var blockGasConsumed gas.Gas + + receipts := make(types.Receipts, len(b.Transactions())) + for ti, tx := range b.Transactions() { + stateDB.SetTxContext(tx.Hash(), ti) + + receipt, err := core.ApplyTransaction( + e.chainConfig, + e.chainContext, + &header.Coinbase, + &gasPool, + stateDB, + header, + tx, + (*uint64)(&blockGasConsumed), + vm.Config{}, + ) + if err != nil { + logger.Fatal( + "Transaction execution errored (not reverted); see emergency playbook", + zap.Int("tx_index", ti), + zap.Stringer("tx_hash", tx.Hash()), + zap.String("playbook", "https://github.com/ava-labs/strevm/issues/28"), + zap.Error(err), + ) + return err + } + + perTxClock.Tick(gas.Gas(receipt.GasUsed)) + b.SetInterimExecutionTime(perTxClock) + // TODO(arr4n) investigate calling the same method on pending blocks in + // the queue. It's only worth it if [blocks.LastToSettleAt] regularly + // returns false, meaning that execution is blocking consensus. + + // The [types.Header] that we pass to [core.ApplyTransaction] is + // modified to reduce gas price from the worst-case value agreed by + // consensus. This changes the hash, which is what is copied to receipts + // and logs. + receipt.BlockHash = b.Hash() + for _, l := range receipt.Logs { + l.BlockHash = b.Hash() + } + + // TODO(arr4n) add a receipt cache to the [executor] to allow API calls + // to access them before the end of the block. + receipts[ti] = receipt + } + endTime := time.Now() + hook.AfterBlock(e.hooks, stateDB, b.EthBlock(), gasClock, blockGasConsumed, receipts) + if gasClock.Time.Compare(perTxClock) != 0 { + return fmt.Errorf("broken invariant: block-resolution clock @ %s does not match tx-resolution clock @ %s", gasClock.String(), perTxClock.String()) + } + + logger.Debug( + "Block execution complete", + zap.Uint64("gas_consumed", uint64(blockGasConsumed)), + zap.Time("gas_time", gasClock.AsTime()), + zap.Time("wall_time", endTime), + ) + + root, err := stateDB.Commit(b.NumberU64(), true) + if err != nil { + return fmt.Errorf("%T.Commit() at end of block %d: %w", stateDB, b.NumberU64(), err) + } + // The strict ordering of the next 3 calls guarantees invariants that MUST + // NOT be broken: + // + // 1. [blocks.Block.MarkExecuted] guarantees disk then in-memory changes. + // 2. Internal indicator of last executed MUST follow in-memory change. + // 3. External indicator of last executed MUST follow internal indicator. + if err := b.MarkExecuted(e.db, gasClock.Clone(), endTime, header.BaseFee, receipts, root); err != nil { + return err + } + e.lastExecuted.Store(b) // (2) + e.sendPostExecutionEvents(b.EthBlock(), receipts) // (3) + return nil +} diff --git a/saexec/saexec.go b/saexec/saexec.go new file mode 100644 index 0000000..a07deb9 --- /dev/null +++ b/saexec/saexec.go @@ -0,0 +1,129 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package saexec provides the execution module of [Streaming Asynchronous +// Execution] (SAE). +// +// [Streaming Asynchronous Execution]: https://github.com/avalanche-foundation/ACPs/tree/main/ACPs/194-streaming-asynchronous-execution +package saexec + +import ( + "sync/atomic" + + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/state" + "github.com/ava-labs/libevm/core/state/snapshot" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/ethdb" + "github.com/ava-labs/libevm/event" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/libevm/triedb" + "go.uber.org/zap" + + "github.com/ava-labs/strevm/blocks" + "github.com/ava-labs/strevm/hook" +) + +// An Executor accepts and executes a [blocks.Block] FIFO queue. +type Executor struct { + quit, done chan struct{} + log logging.Logger + hooks hook.Points + + queue chan *blocks.Block + lastExecuted atomic.Pointer[blocks.Block] + + headEvents event.FeedOf[core.ChainHeadEvent] + chainEvents event.FeedOf[core.ChainEvent] + logEvents event.FeedOf[[]*types.Log] + + chainContext core.ChainContext + chainConfig *params.ChainConfig + db ethdb.Database + stateCache state.Database + // snaps MUST NOT be accessed by any methods other than [Executor.execute] + // and [Executor.Close]. + snaps *snapshot.Tree +} + +// New constructs and starts a new [Executor]. Call [Executor.Close] to release +// resources created by this constructor. +// +// The last-executed block MAY be the genesis block for an always-SAE chain, the +// last pre-SAE synchronous block during transition, or the last asynchronously +// executed block after shutdown and recovery. +func New( + lastExecuted *blocks.Block, + blockSrc BlockSource, + chainConfig *params.ChainConfig, + db ethdb.Database, + triedbConfig *triedb.Config, + hooks hook.Points, + log logging.Logger, +) (*Executor, error) { + cache := state.NewDatabaseWithConfig(db, triedbConfig) + snapConf := snapshot.Config{ + CacheSize: 128, // MB + AsyncBuild: true, + } + snaps, err := snapshot.New(snapConf, db, cache.TrieDB(), lastExecuted.PostExecutionStateRoot()) + if err != nil { + return nil, err + } + + e := &Executor{ + quit: make(chan struct{}), // closed by [Executor.Close] + done: make(chan struct{}), // closed by [Executor.processQueue] after `quit` is closed + log: log, + hooks: hooks, + queue: make(chan *blocks.Block, 4096), // arbitrarily sized + chainContext: &chainContext{blockSrc, log}, + chainConfig: chainConfig, + db: db, + stateCache: cache, + snaps: snaps, + } + e.lastExecuted.Store(lastExecuted) + + go e.processQueue() + return e, nil +} + +// Close shuts down the [Executor], waits for the currently executing block +// to complete, and then releases all resources. +func (e *Executor) Close() { + close(e.quit) + <-e.done + + // We don't use [snapshot.Tree.Journal] because re-orgs are impossible under + // SAE so we don't mind flattening all snapshot layers to disk. Note that + // calling `Cap([disk root], 0)` returns an error when it's actually a + // no-op, so we ignore it. + if root := e.LastExecuted().PostExecutionStateRoot(); root != e.snaps.DiskRoot() { + if err := e.snaps.Cap(root, 0); err != nil { + e.log.Warn( + "snapshot.Tree.Cap([last post-execution state root], 0)", + zap.Stringer("root", root), + zap.Error(err), + ) + } + } + + e.snaps.Release() +} + +// ChainConfig returns the config originally passed to [New]. +func (e *Executor) ChainConfig() *params.ChainConfig { + return e.chainConfig +} + +// StateCache returns caching database underpinning execution. +func (e *Executor) StateCache() state.Database { + return e.stateCache +} + +// LastExecuted returns the last-executed block in a threadsafe manner. +func (e *Executor) LastExecuted() *blocks.Block { + return e.lastExecuted.Load() +} diff --git a/saexec/saexec_test.go b/saexec/saexec_test.go new file mode 100644 index 0000000..617c7b5 --- /dev/null +++ b/saexec/saexec_test.go @@ -0,0 +1,726 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package saexec + +import ( + "context" + "encoding/binary" + "math" + "math/big" + "math/rand/v2" + "slices" + "testing" + + "github.com/ava-labs/avalanchego/utils/logging" + "github.com/ava-labs/avalanchego/vms/components/gas" + "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/types" + "github.com/ava-labs/libevm/core/vm" + "github.com/ava-labs/libevm/crypto" + "github.com/ava-labs/libevm/libevm" + libevmhookstest "github.com/ava-labs/libevm/libevm/hookstest" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/libevm/triedb" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/holiman/uint256" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "github.com/ava-labs/strevm/blocks" + "github.com/ava-labs/strevm/blocks/blockstest" + "github.com/ava-labs/strevm/cmputils" + "github.com/ava-labs/strevm/gastime" + "github.com/ava-labs/strevm/hook" + saehookstest "github.com/ava-labs/strevm/hook/hookstest" + "github.com/ava-labs/strevm/proxytime" + "github.com/ava-labs/strevm/saetest" + "github.com/ava-labs/strevm/saetest/escrow" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain( + m, + goleak.IgnoreCurrent(), + // Despite the call to [snapshot.Tree.Disable] in [Executor.Close], this + // still leaks at shutdown. This is acceptable as we only ever have one + // [Executor], which we expect to be running for the entire life of the + // process. + goleak.IgnoreTopFunction("github.com/ava-labs/libevm/core/state/snapshot.(*diskLayer).generate"), + ) +} + +// SUT is the system under test, primarily the [Executor]. +type SUT struct { + *Executor + chain *blockstest.ChainBuilder + wallet *saetest.Wallet + logger logging.Logger +} + +// newSUT returns a new SUT. Any >= [logging.Error] on the logger will also +// cancel the returned context, which is useful when waiting for blocks that +// can never finish execution because of an error. +func newSUT(tb testing.TB, hooks hook.Points) (context.Context, SUT) { + tb.Helper() + + logger := saetest.NewTBLogger(tb, logging.Warn) + ctx := logger.CancelOnError(tb.Context()) + + config := params.AllDevChainProtocolChanges + db := rawdb.NewMemoryDatabase() + tdbConfig := &triedb.Config{} + + wallet := saetest.NewUNSAFEWallet(tb, 1, types.LatestSigner(config)) + alloc := saetest.MaxAllocFor(wallet.Addresses()...) + genesis := blockstest.NewGenesis(tb, db, config, alloc, blockstest.WithTrieDBConfig(tdbConfig)) + + chain := blockstest.NewChainBuilder(genesis) + chain.SetDefaultOptions(blockstest.WithBlockOptions( + blockstest.WithLogger(logger), + )) + src := BlockSource(func(h common.Hash, n uint64) *blocks.Block { + b, ok := chain.GetBlock(h, n) + if !ok { + return nil + } + return b + }) + + e, err := New(genesis, src, config, db, tdbConfig, hooks, logger) + require.NoError(tb, err, "New()") + tb.Cleanup(e.Close) + + return ctx, SUT{ + Executor: e, + chain: chain, + wallet: wallet, + logger: logger, + } +} + +func defaultHooks() *saehookstest.Stub { + return &saehookstest.Stub{Target: 1e6} +} + +func TestImmediateShutdownNonBlocking(t *testing.T) { + newSUT(t, defaultHooks()) // calls [Executor.Close] in test cleanup +} + +func TestExecutionSynchronisation(t *testing.T) { + ctx, sut := newSUT(t, defaultHooks()) + e, chain := sut.Executor, sut.chain + + for range 10 { + b := chain.NewBlock(t, nil) + require.NoError(t, e.Enqueue(ctx, b), "Enqueue()") + } + + final := chain.Last() + require.NoErrorf(t, final.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted() on last-enqueued block", final) + assert.Equal(t, final.NumberU64(), e.LastExecuted().NumberU64(), "Last-executed atomic pointer holds last-enqueued block") + + for _, b := range chain.AllBlocks() { + assert.Truef(t, b.Executed(), "%T[%d].Executed()", b, b.NumberU64()) + } +} + +func TestReceiptPropagation(t *testing.T) { + ctx, sut := newSUT(t, defaultHooks()) + e, chain, wallet := sut.Executor, sut.chain, sut.wallet + + var want [][]*types.Receipt + for range 10 { + var ( + txs types.Transactions + receipts types.Receipts + ) + + for range 5 { + tx := wallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + Gas: 1e5, + GasPrice: big.NewInt(1), + }) + txs = append(txs, tx) + receipts = append(receipts, &types.Receipt{TxHash: tx.Hash()}) + } + want = append(want, receipts) + + b := chain.NewBlock(t, txs) + require.NoError(t, e.Enqueue(ctx, b), "Enqueue()") + } + require.NoErrorf(t, chain.Last().WaitUntilExecuted(ctx), "%T.WaitUntilExecuted() on last-enqueued block", chain.Last()) + + var got [][]*types.Receipt + for _, b := range chain.AllExceptGenesis() { + got = append(got, b.Receipts()) + } + if diff := cmp.Diff(want, got, cmputils.ReceiptsByTxHash()); diff != "" { + t.Errorf("%T diff (-want +got):\n%s", got, diff) + } +} + +func TestSubscriptions(t *testing.T) { + ctx, sut := newSUT(t, defaultHooks()) + e, chain, wallet := sut.Executor, sut.chain, sut.wallet + + precompile := common.Address{'p', 'r', 'e'} + stub := &libevmhookstest.Stub{ + PrecompileOverrides: map[common.Address]libevm.PrecompiledContract{ + precompile: vm.NewStatefulPrecompile(func(env vm.PrecompileEnvironment, input []byte) (ret []byte, err error) { + env.StateDB().AddLog(&types.Log{ + Address: precompile, + }) + return nil, nil + }), + }, + } + stub.Register(t) + + gotChainHeadEvents := saetest.NewEventCollector(e.SubscribeChainHeadEvent) + gotChainEvents := saetest.NewEventCollector(e.SubscribeChainEvent) + gotLogsEvents := saetest.NewEventCollector(e.SubscribeLogsEvent) + var ( + wantChainHeadEvents []core.ChainHeadEvent + wantChainEvents []core.ChainEvent + wantLogsEvents [][]*types.Log + ) + + for range 10 { + tx := wallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + To: &precompile, + GasPrice: big.NewInt(1), + Gas: 1e6, + }) + + b := chain.NewBlock(t, types.Transactions{tx}) + require.NoError(t, e.Enqueue(ctx, b), "Enqueue()") + + wantChainHeadEvents = append(wantChainHeadEvents, core.ChainHeadEvent{ + Block: b.EthBlock(), + }) + logs := []*types.Log{{ + Address: precompile, + BlockNumber: b.NumberU64(), + TxHash: tx.Hash(), + BlockHash: b.Hash(), + }} + wantChainEvents = append(wantChainEvents, core.ChainEvent{ + Block: b.EthBlock(), + Hash: b.Hash(), + Logs: logs, + }) + wantLogsEvents = append(wantLogsEvents, logs) + } + + opt := cmputils.BlocksByHash() + t.Run("ChainHeadEvents", func(t *testing.T) { + testEvents(t, gotChainHeadEvents, wantChainHeadEvents, opt) + }) + t.Run("ChainEvents", func(t *testing.T) { + testEvents(t, gotChainEvents, wantChainEvents, opt) + }) + t.Run("LogsEvents", func(t *testing.T) { + testEvents(t, gotLogsEvents, wantLogsEvents) + }) +} + +func testEvents[T any](tb testing.TB, got *saetest.EventCollector[T], want []T, opts ...cmp.Option) { + tb.Helper() + // There is an invariant that stipulates [blocks.Block.MarkExecuted] MUST + // occur before sending external events, which means that we can't rely on + // [blocks.Block.WaitUntilExecuted] to avoid races. + got.WaitForAtLeast(len(want)) + + require.NoError(tb, got.Unsubscribe()) + if diff := cmp.Diff(want, got.All(), opts...); diff != "" { + tb.Errorf("Collecting %T from event.Subscription; diff (-want +got):\n%s", want, diff) + } +} + +func TestExecution(t *testing.T) { + ctx, sut := newSUT(t, defaultHooks()) + wallet := sut.wallet + eoa := wallet.Addresses()[0] + + var ( + txs types.Transactions + want types.Receipts + ) + deploy := wallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + Data: escrow.CreationCode(), + GasPrice: big.NewInt(1), + Gas: 1e7, + }) + contract := crypto.CreateAddress(eoa, deploy.Nonce()) + txs = append(txs, deploy) + want = append(want, &types.Receipt{ + TxHash: deploy.Hash(), + ContractAddress: contract, + }) + + rng := rand.New(rand.NewPCG(0, 0)) //nolint:gosec // Reproducibility is useful for tests + var wantEscrowBalance uint64 + for range 10 { + val := rng.Uint64N(100_000) + tx := wallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + To: &contract, + Value: new(big.Int).SetUint64(val), + GasPrice: big.NewInt(1), + Gas: 1e6, + Data: escrow.CallDataToDeposit(eoa), + }) + wantEscrowBalance += val + t.Logf("Depositing %d", val) + + txs = append(txs, tx) + ev := escrow.DepositEvent(eoa, uint256.NewInt(val)) + ev.Address = contract + ev.TxHash = tx.Hash() + want = append(want, &types.Receipt{ + TxHash: tx.Hash(), + Logs: []*types.Log{ev}, + }) + } + + b := sut.chain.NewBlock(t, txs) + + var logIndex uint + for i, r := range want { + ui := uint(i) //nolint:gosec // Known to not overflow + + r.Status = 1 + r.TransactionIndex = ui + r.BlockHash = b.Hash() + r.BlockNumber = big.NewInt(1) + + for _, l := range r.Logs { + l.TxIndex = ui + l.BlockHash = b.Hash() + l.BlockNumber = 1 + l.Index = logIndex + logIndex++ + } + } + + e := sut.Executor + require.NoError(t, e.Enqueue(ctx, b), "Enqueue()") + require.NoErrorf(t, b.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted()", b) + + opts := cmp.Options{ + cmpopts.IgnoreFields( + types.Receipt{}, + "GasUsed", "CumulativeGasUsed", + "Bloom", + ), + cmputils.BigInts(), + } + if diff := cmp.Diff(want, b.Receipts(), opts); diff != "" { + t.Errorf("%T.Receipts() diff (-want +got):\n%s", b, diff) + } + + t.Run("committed_state", func(t *testing.T) { + sdb, err := state.New(b.PostExecutionStateRoot(), e.StateCache(), nil) + require.NoErrorf(t, err, "state.New(%T.PostExecutionStateRoot(), %T.StateCache(), nil)", b, e) + + if got, want := sdb.GetBalance(contract).ToBig(), new(big.Int).SetUint64(wantEscrowBalance); got.Cmp(want) != 0 { + t.Errorf("After Escrow deposits, got contract balance %v; want %v", got, want) + } + + enablePUSH0 := vm.BlockContext{ + BlockNumber: big.NewInt(1), + Time: 1, + Random: &common.Hash{}, + } + evm := vm.NewEVM(enablePUSH0, vm.TxContext{}, sdb, e.ChainConfig(), vm.Config{}) + + got, _, err := evm.StaticCall(vm.AccountRef(eoa), contract, escrow.CallDataForBalance(eoa), 1e6) + require.NoErrorf(t, err, "%T.Call([Escrow contract], [balance(eoa)])", evm) + if got, want := new(uint256.Int).SetBytes(got), uint256.NewInt(wantEscrowBalance); !got.Eq(want) { + t.Errorf("Escrow.balance([eoa]) got %v; want %v", got, want) + } + }) +} + +func TestGasAccounting(t *testing.T) { + hooks := &saehookstest.Stub{} + ctx, sut := newSUT(t, hooks) + + const gasPerTx = gas.Gas(params.TxGas) + at := func(blockTime, txs uint64, rate gas.Gas) *proxytime.Time[gas.Gas] { + tm := proxytime.New[gas.Gas](blockTime, rate) + tm.Tick(gas.Gas(txs) * gasPerTx) + return tm + } + + // If this fails then all of the tests need to be adjusted. This is cleaner + // than polluting the test cases with a repetitive identifier. + require.Equal(t, 2, gastime.TargetToRate, "gastime.TargetToRate assumption") + + // Steps are _not_ independent, so the execution time of one is the starting + // time of the next. + steps := []struct { + target gas.Gas + blockTime uint64 + numTxs int + wantExecutedBy *proxytime.Time[gas.Gas] + // Because of the 2:1 ratio between Rate and Target, gas consumption + // increases excess by half of the amount consumed, while + // fast-forwarding reduces excess by half of the amount skipped. + wantExcessAfter gas.Gas + wantPriceAfter gas.Price + }{ + { + target: 5 * gasPerTx, + blockTime: 2, + numTxs: 3, + wantExecutedBy: at(2, 3, 10*gasPerTx), + wantExcessAfter: 3 * gasPerTx / 2, + wantPriceAfter: 1, // Excess isn't high enough so price is effectively e^0 + }, + { + target: 5 * gasPerTx, + blockTime: 3, // fast-forward + numTxs: 12, + wantExecutedBy: at(4, 2, 10*gasPerTx), + wantExcessAfter: 12 * gasPerTx / 2, + wantPriceAfter: 1, + }, + { + target: 5 * gasPerTx, + blockTime: 4, // no fast-forward so starts at last execution time + numTxs: 20, + wantExecutedBy: at(6, 2, 10*gasPerTx), + wantExcessAfter: (12 + 20) * gasPerTx / 2, + wantPriceAfter: 1, + }, + { + target: 5 * gasPerTx, + blockTime: 7, // fast-forward equivalent of 8 txs + numTxs: 16, + wantExecutedBy: at(8, 6, 10*gasPerTx), + wantExcessAfter: (12 + 20 - 8 + 16) * gasPerTx / 2, + wantPriceAfter: 1, + }, + { + target: 10 * gasPerTx, // double gas/block --> halve ticking rate + blockTime: 8, // no fast-forward + numTxs: 4, + wantExecutedBy: at(8, (6*2)+4, 20*gasPerTx), // starting point scales + wantExcessAfter: (2*(12+20-8+16) + 4) * gasPerTx / 2, + wantPriceAfter: 1, + }, + { + target: 5 * gasPerTx, // back to original + blockTime: 8, + numTxs: 5, + wantExecutedBy: at(8, 6+(4/2)+5, 10*gasPerTx), + wantExcessAfter: ((12 + 20 - 8 + 16) + 4/2 + 5) * gasPerTx / 2, + wantPriceAfter: 1, + }, + { + target: 5 * gasPerTx, + blockTime: 20, // more than double the last executed-by time, reduces excess to 0 + numTxs: 1, + wantExecutedBy: at(20, 1, 10*gasPerTx), + wantExcessAfter: gasPerTx / 2, + wantPriceAfter: 1, + }, + { + target: 5 * gasPerTx, + blockTime: 21, // fast-forward so excess is 0 + numTxs: 30 * gastime.TargetToExcessScaling, // deliberate, see below + wantExecutedBy: at(21, 30*gastime.TargetToExcessScaling, 10*gasPerTx), + wantExcessAfter: 3 * ((5 * gasPerTx /*T*/) * gastime.TargetToExcessScaling /* == K */), + // Excess is now 3·K so the price is e^3 + wantPriceAfter: gas.Price(math.Floor(math.Pow(math.E, 3 /* <----- NB */))), + }, + { + target: 5 * gasPerTx, + blockTime: 22, // no fast-forward + numTxs: 10 * gastime.TargetToExcessScaling, + wantExecutedBy: at(21, 40*gastime.TargetToExcessScaling, 10*gasPerTx), + wantExcessAfter: 4 * ((5 * gasPerTx /*T*/) * gastime.TargetToExcessScaling /* == K */), + wantPriceAfter: gas.Price(math.Floor(math.Pow(math.E, 4 /* <----- NB */))), + }, + } + + e, chain, wallet := sut.Executor, sut.chain, sut.wallet + + for i, step := range steps { + hooks.Target = step.target + + txs := make(types.Transactions, step.numTxs) + for i := range txs { + txs[i] = wallet.SetNonceAndSign(t, 0, &types.DynamicFeeTx{ + To: &common.Address{}, + Gas: params.TxGas, + GasTipCap: big.NewInt(0), + GasFeeCap: big.NewInt(100), + }) + } + + b := chain.NewBlock(t, txs, blockstest.WithEthBlockOptions( + blockstest.ModifyHeader(func(h *types.Header) { + h.Time = step.blockTime + }), + )) + require.NoError(t, e.Enqueue(ctx, b), "Enqueue()") + require.NoErrorf(t, b.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted()", b) + + opt := proxytime.CmpOpt[gas.Gas](proxytime.IgnoreRateInvariants) + if diff := cmp.Diff(step.wantExecutedBy, b.ExecutedByGasTime().Time, opt); diff != "" { + t.Errorf("%T.ExecutedByGasTime().Time diff (-want +got):\n%s", b, diff) + } + + t.Run("CumulativeGasUsed", func(t *testing.T) { + for i, r := range b.Receipts() { + ui := uint64(i + 1) //nolint:gosec // Known to not overflow + assert.Equalf(t, ui*params.TxGas, r.CumulativeGasUsed, "%T.Receipts()[%d]", b, i) + } + }) + + if t.Failed() { + // Future steps / tests may be corrupted and false-positive errors + // aren't helpful. + break + } + + t.Run("gas_price", func(t *testing.T) { + tm := b.ExecutedByGasTime().Clone() + assert.Equalf(t, step.wantExcessAfter, tm.Excess(), "%T.Excess()", tm) + assert.Equalf(t, step.wantPriceAfter, tm.Price(), "%T.Price()", tm) + + wantBaseFee := gas.Price(1) + if i > 0 { + wantBaseFee = steps[i-1].wantPriceAfter + } + require.Truef(t, b.BaseFee().IsUint64(), "%T.BaseFee().IsUint64()", b) + assert.Equalf(t, wantBaseFee, gas.Price(b.BaseFee().Uint64()), "%T.BaseFee().Uint64()", b) + }) + } + if t.Failed() { + t.Fatal("Chain in unexpected state") + } + + t.Run("BASEFEE_op_code", func(t *testing.T) { + finalPrice := uint64(steps[len(steps)-1].wantPriceAfter) + + tx := wallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + To: nil, // runs call data as a constructor + Gas: 100e6, + GasPrice: new(big.Int).SetUint64(finalPrice), + Data: asBytes(logTopOfStackAfter(vm.BASEFEE)...), + }) + + b := chain.NewBlock(t, types.Transactions{tx}) + require.NoError(t, e.Enqueue(ctx, b), "Enqueue()") + require.NoErrorf(t, b.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted()", b) + require.Lenf(t, b.Receipts(), 1, "%T.Receipts()", b) + require.Lenf(t, b.Receipts()[0].Logs, 1, "%T.Receipts()[0].Logs", b) + + got := b.Receipts()[0].Logs[0].Topics[0] + want := common.BytesToHash(binary.BigEndian.AppendUint64(nil, finalPrice)) + assert.Equal(t, want, got) + }) +} + +// logTopOfStackAfter returns contract bytecode that logs the value on the top +// of the stack after executing `pre`. +func logTopOfStackAfter(pre ...vm.OpCode) []vm.OpCode { + return slices.Concat(pre, []vm.OpCode{vm.PUSH0, vm.PUSH0, vm.LOG1}) +} + +func asBytes(ops ...vm.OpCode) []byte { + buf := make([]byte, len(ops)) + for i, op := range ops { + buf[i] = byte(op) + } + return buf +} + +func TestContextualOpCodes(t *testing.T) { + ctx, sut := newSUT(t, defaultHooks()) + + chain := sut.chain + for range 5 { + // Historical blocks, required to already be in `chain`, for testing + // BLOCKHASH. + b := chain.NewBlock(t, nil) + require.NoErrorf(t, sut.Enqueue(ctx, b), "Enqueue([empty block])") + } + + bigToHash := func(b *big.Int) common.Hash { + return uint256.MustFromBig(b).Bytes32() + } + + // For specific tests. + const txValueSend = 42 + saveBlockNum := &blockNumSaver{} + + tests := []struct { + name string + code []vm.OpCode + header func(*types.Header) + wantTopic common.Hash + wantTopicFn func() common.Hash // if non-nil, overrides `wantTopic` + }{ + { + name: "BALANCE_of_ADDRESS", + code: logTopOfStackAfter(vm.ADDRESS, vm.BALANCE), + wantTopic: common.Hash{31: txValueSend}, + }, + { + name: "CALLVALUE", + code: logTopOfStackAfter(vm.CALLVALUE), + wantTopic: common.Hash{31: txValueSend}, + }, + { + name: "SELFBALANCE", + code: logTopOfStackAfter(vm.SELFBALANCE), + wantTopic: common.Hash{31: txValueSend}, + }, + { + name: "ORIGIN", + code: logTopOfStackAfter(vm.ORIGIN), + wantTopic: common.BytesToHash( + sut.wallet.Addresses()[0].Bytes(), + ), + }, + { + name: "CALLER", + code: logTopOfStackAfter(vm.CALLER), + wantTopic: common.BytesToHash( + sut.wallet.Addresses()[0].Bytes(), + ), + }, + { + name: "BLOCKHASH_genesis", + code: logTopOfStackAfter(vm.PUSH0, vm.BLOCKHASH), + wantTopic: chain.AllBlocks()[0].Hash(), + }, + { + name: "BLOCKHASH_arbitrary", + code: logTopOfStackAfter(vm.PUSH1, 3, vm.BLOCKHASH), + wantTopic: chain.AllBlocks()[3].Hash(), + }, + { + name: "NUMBER", + code: logTopOfStackAfter(vm.NUMBER), + header: saveBlockNum.store, + wantTopicFn: func() common.Hash { + return bigToHash(saveBlockNum.num) + }, + }, + { + name: "COINBASE_arbitrary", + code: logTopOfStackAfter(vm.COINBASE), + header: func(h *types.Header) { + h.Coinbase = common.Address{17: 0xC0, 18: 0xFF, 19: 0xEE} + }, + wantTopic: common.BytesToHash([]byte{0xC0, 0xFF, 0xEE}), + }, + { + name: "COINBASE_zero", + code: logTopOfStackAfter(vm.COINBASE), + }, + { + name: "TIMESTAMP", + code: logTopOfStackAfter(vm.TIMESTAMP), + header: func(h *types.Header) { + h.Time = 0xDECAFBAD + }, + wantTopic: common.BytesToHash([]byte{0xDE, 0xCA, 0xFB, 0xAD}), + }, + { + name: "PREVRANDAO", + code: logTopOfStackAfter(vm.PREVRANDAO), + }, + { + name: "GASLIMIT", + code: logTopOfStackAfter(vm.GASLIMIT), + header: func(h *types.Header) { + h.GasLimit = 0xA11CEB0B + }, + wantTopic: common.BytesToHash([]byte{0xA1, 0x1C, 0xEB, 0x0B}), + }, + { + name: "CHAINID", + code: logTopOfStackAfter(vm.CHAINID), + wantTopic: bigToHash(sut.ChainConfig().ChainID), + }, + // BASEFEE is tested in [TestGasAccounting] because getting the clock + // excess to a specific value is complicated. + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tx := sut.wallet.SetNonceAndSign(t, 0, &types.LegacyTx{ + To: nil, // contract creation runs the call data (one sneaky trick blockchain developers don't want you to know) + GasPrice: big.NewInt(1), + Gas: 100e6, + Data: asBytes(tt.code...), + Value: big.NewInt(txValueSend), + }) + + var opts []blockstest.ChainOption + if tt.header != nil { + opts = append(opts, blockstest.WithEthBlockOptions( + blockstest.ModifyHeader(tt.header), + )) + } + + b := sut.chain.NewBlock(t, types.Transactions{tx}, opts...) + require.NoError(t, sut.Enqueue(ctx, b), "Enqueue()") + require.NoErrorf(t, b.WaitUntilExecuted(ctx), "%T.WaitUntilExecuted()", b) + require.Lenf(t, b.Receipts(), 1, "%T.Receipts()", b) + + got := b.Receipts()[0] + diffopts := cmp.Options{ + cmpopts.IgnoreFields( + types.Receipt{}, + "Bloom", "ContractAddress", "CumulativeGasUsed", "GasUsed", + ), + cmpopts.IgnoreFields( + types.Log{}, + "Address", + ), + cmputils.BigInts(), + } + wantTopic := tt.wantTopic + if tt.wantTopicFn != nil { + wantTopic = tt.wantTopicFn() + } + want := &types.Receipt{ + Status: types.ReceiptStatusSuccessful, + BlockHash: b.Hash(), + BlockNumber: b.Number(), + TxHash: tx.Hash(), + Logs: []*types.Log{{ + Topics: []common.Hash{wantTopic}, + BlockHash: b.Hash(), + BlockNumber: b.NumberU64(), + TxHash: tx.Hash(), + }}, + } + if diff := cmp.Diff(want, got, diffopts); diff != "" { + t.Errorf("%T diff (-want +got):\n%s", got, diff) + } + }) + } +} + +type blockNumSaver struct { + num *big.Int +} + +var _ = blockstest.ModifyHeader((*blockNumSaver)(nil).store) + +func (e *blockNumSaver) store(h *types.Header) { + e.num = new(big.Int).Set(h.Number) +} diff --git a/saexec/subscription.go b/saexec/subscription.go new file mode 100644 index 0000000..24e6052 --- /dev/null +++ b/saexec/subscription.go @@ -0,0 +1,43 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package saexec + +import ( + "github.com/ava-labs/libevm/core" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/event" +) + +func (e *Executor) sendPostExecutionEvents(b *types.Block, receipts types.Receipts) { + e.headEvents.Send(core.ChainHeadEvent{Block: b}) + + var logs []*types.Log + for _, r := range receipts { + logs = append(logs, r.Logs...) + } + e.chainEvents.Send(core.ChainEvent{ + Block: b, + Hash: b.Hash(), + Logs: logs, + }) + e.logEvents.Send(logs) +} + +// SubscribeChainHeadEvent returns a new subscription for each +// [core.ChainHeadEvent] emitted after execution of a [blocks.Block]. +func (e *Executor) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription { + return e.headEvents.Subscribe(ch) +} + +// SubscribeChainEvent returns a new subscription for each [core.ChainEvent] +// emitted after execution of a [blocks.Block]. +func (e *Executor) SubscribeChainEvent(ch chan<- core.ChainEvent) event.Subscription { + return e.chainEvents.Subscribe(ch) +} + +// SubscribeLogsEvent returns a new subscription for logs emitted after +// execution of a [blocks.Block]. +func (e *Executor) SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscription { + return e.logEvents.Subscribe(ch) +}