From facbbd69d9b6ade4457317a3594c27bb610b77c9 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 4 Dec 2025 14:42:22 -0500 Subject: [PATCH 01/35] Add Tau --- params/params.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/params/params.go b/params/params.go index 6b650a6e..db600753 100644 --- a/params/params.go +++ b/params/params.go @@ -6,7 +6,13 @@ // [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 +const ( + // 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). + Lambda = 2 + + // Tau is the number of seconds after a block has finished executing before + // it is settled. + Tau = 5 +) From 7a061e519927074f20980588168bd8abada1012f Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 4 Dec 2025 14:42:38 -0500 Subject: [PATCH 02/35] wip --- builder.go | 293 ++++++++++++++++++++++++++++++++++++++++ worstcase/state.go | 214 +++++++++++++++++++++++++++++ worstcase/state_test.go | 162 ++++++++++++++++++++++ 3 files changed, 669 insertions(+) create mode 100644 builder.go create mode 100644 worstcase/state.go create mode 100644 worstcase/state_test.go diff --git a/builder.go b/builder.go new file mode 100644 index 00000000..d8d7bf87 --- /dev/null +++ b/builder.go @@ -0,0 +1,293 @@ +package sae + +import ( + "context" + "errors" + "fmt" + "iter" + "math/big" + "slices" + + "github.com/arr4n/sink" + "github.com/ava-labs/avalanchego/snow/engine/snowman/block" + "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/strevm/blocks" + "github.com/ava-labs/strevm/hook" + "github.com/ava-labs/strevm/intmath" + "github.com/ava-labs/strevm/params" + "github.com/ava-labs/strevm/queue" + "github.com/ava-labs/strevm/worstcase" + "go.uber.org/zap" +) + +func (vm *VM) buildBlock(ctx context.Context, blockContext *block.Context, timestamp uint64, parent *blocks.Block) (*blocks.Block, error) { + block, err := sink.FromPriorityMutex( + ctx, vm.mempool, sink.MaxPriority, + func(_ <-chan sink.Priority, pool *queue.Priority[*pendingTx]) (*blocks.Block, error) { + block, err := vm.buildBlockWithCandidateTxs(timestamp, parent, pool, blockContext, vm.hooks.ConstructBlock) + if pool.Len() == 0 { + vm.mempoolHasTxs.Block() + } + return block, err + }, + ) + if err != nil { + return nil, err + } + vm.logger().Debug( + "Built block", + zap.Uint64("timestamp", timestamp), + zap.Uint64("height", block.Height()), + zap.Stringer("parent", parent.Hash()), + zap.Int("transactions", len(block.Transactions())), + ) + return block, nil +} + +var ( + errWaitingForExecution = errors.New("waiting for execution when building block") + errNoopBlock = errors.New("block does not settle state nor include transactions") +) + +func (vm *VM) buildBlockWithCandidateTxs( + timestamp uint64, + parent *blocks.Block, + candidateTxs queue.Queue[*pendingTx], + blockContext *block.Context, + constructBlock hook.ConstructBlock, +) (*blocks.Block, error) { + if timestamp < parent.Time() { + return nil, fmt.Errorf("block at time %d before parent at %d", timestamp, parent.Time()) + } + + toSettle, ok := vm.lastBlockToSettleAt(timestamp, parent) + if !ok { + vm.logger().Warn( + "Block building waiting for execution", + zap.Uint64("timestamp", timestamp), + zap.Stringer("parent", parent.Hash()), + ) + return nil, fmt.Errorf("%w: parent %#x at time %d", errWaitingForExecution, parent.Hash(), timestamp) + } + vm.logger().Debug( + "Settlement candidate", + zap.Uint64("timestamp", timestamp), + zap.Stringer("parent", parent.Hash()), + zap.Stringer("block_hash", toSettle.Hash()), + zap.Uint64("block_height", toSettle.Height()), + zap.Uint64("block_time", toSettle.Time()), + ) + + ethB, err := vm.buildBlockOnHistory( + toSettle, + parent, + timestamp, + candidateTxs, + blockContext, + constructBlock, + ) + if err != nil { + return nil, err + } + return blocks.New(ethB, parent, toSettle, vm.logger()) +} + +func (vm *VM) buildBlockOnHistory( + lastSettled, + parent *blocks.Block, + timestamp uint64, + candidateTxs queue.Queue[*pendingTx], + blockContext *block.Context, + constructBlock hook.ConstructBlock, +) (_ *types.Block, retErr error) { + var history []*blocks.Block + for b := parent; b.ID() != lastSettled.ID(); b = b.ParentBlock() { + history = append(history, b) + } + slices.Reverse(history) + + sdb, err := state.New(lastSettled.PostExecutionStateRoot(), vm.exec.StateCache(), nil) + if err != nil { + return nil, err + } + + checker := worstcase.NewState( + sdb, + vm.exec.ChainConfig(), + lastSettled.ExecutedByGasTime().Clone(), + 5, 2, // TODO(arr4n) what are the max queue and block seconds? + ) + + for _, b := range history { + checker.StartBlock(b.Header(), vm.hooks.GasTarget(b.ParentBlock().Block)) + for _, tx := range b.Transactions() { + if err := checker.ApplyTx(tx); err != nil { + vm.logger().Error( + "Transaction not included when replaying history", + zap.Stringer("block", b.Hash()), + zap.Stringer("tx", tx.Hash()), + zap.Error(err), + ) + return nil, err + } + } + + extraOps, err := vm.hooks.ExtraBlockOperations(context.TODO(), b.Block) + if err != nil { + vm.logger().Error( + "Unable to extract extra block operations when replaying history", + zap.Stringer("block", b.Hash()), + zap.Error(err), + ) + return nil, err + } + for i, op := range extraOps { + if err := checker.Apply(op); err != nil { + vm.logger().Error( + "Operation not applied when replaying history", + zap.Stringer("block", b.Hash()), + zap.Int("index", i), + zap.Error(err), + ) + return nil, err + } + } + } + + var ( + include []*pendingTx + delayed []*pendingTx + ) + defer func() { + for _, tx := range delayed { + candidateTxs.Push(tx) + } + if retErr != nil { + for _, tx := range include { + candidateTxs.Push(tx) + } + } + }() + + hdr := &types.Header{ + Number: new(big.Int).SetUint64(parent.NumberU64() + 1), + Time: timestamp, + } + checker.StartBlock(hdr, vm.hooks.GasTarget(parent.Block)) + + for full := false; !full && candidateTxs.Len() > 0; { + candidate := candidateTxs.Pop() + tx := candidate.tx + + switch err := checker.ApplyTx(tx); { + case err == nil: + include = append(include, candidate) + + case errIsOneOf(err, worstcase.ErrBlockTooFull, worstcase.ErrQueueTooFull): + delayed = append(delayed, candidate) + full = true + + // TODO(arr4n) handle all other errors. + + default: + vm.logger().Error( + "Unknown error from worst-case transaction checking", + zap.Error(err), + ) + + // TODO: It is not acceptable to return an error here, as all + // transactions that have been removed from the mempool will be + // dropped and never included. + return nil, err + } + } + + var ( + receipts []types.Receipts + gasUsed uint64 + ) + // We can never concurrently build and accept a block on the same parent, + // which guarantees that `parent` won't be settled, so the [Block] invariant + // means that `parent.lastSettled != nil`. + for _, b := range parent.IfChildSettles(lastSettled) { + brs := b.Receipts() + receipts = append(receipts, brs) + for _, r := range brs { + gasUsed += r.GasUsed + } + } + + // TODO(arr4n) setting `gasUsed` (i.e. historical) based on receipts and + // `gasLimit` (future-looking) based on the enqueued transactions follows + // naturally from all of the other changes. However it will be possible to + // have `gasUsed>gasLimit`, which may break some systems. + var gasLimit gas.Gas + txs := make(types.Transactions, len(include)) + for i, tx := range include { + txs[i] = tx.tx + gasLimit += gas.Gas(tx.tx.Gas()) + } + + header := &types.Header{ + ParentHash: parent.Hash(), + Root: lastSettled.PostExecutionStateRoot(), + Number: new(big.Int).Add(parent.Number(), big.NewInt(1)), + GasLimit: uint64(gasLimit), + GasUsed: gasUsed, + Time: timestamp, + BaseFee: nil, // TODO(arr4n) + } + ancestors := iterateUntilSettled(parent) + return constructBlock( + context.TODO(), + blockContext, + header, + parent.Block.Header(), + ancestors, + checker, + txs, + slices.Concat(receipts...), + ) +} + +func errIsOneOf(err error, targets ...error) bool { + for _, t := range targets { + if errors.Is(err, t) { + return true + } + } + return false +} + +func (vm *VM) lastBlockToSettleAt(timestamp uint64, parent *blocks.Block) (*blocks.Block, bool) { + settleAt := intmath.BoundedSubtract(timestamp, params.StateRootDelaySeconds, vm.last.synchronous.time) + return blocks.LastToSettleAt(settleAt, parent) +} + +// iterateUntilSettled returns an iterator which starts at the provided block +// and iterates up to but not including the most recently settled block. +// +// If the provided block is settled, then the returned iterator is empty. +func iterateUntilSettled(from *blocks.Block) iter.Seq[*types.Block] { + return func(yield func(*types.Block) bool) { + // Do not modify the `from` variable to support multiple iterations. + current := from + for { + next := current.ParentBlock() + // If the next block is nil, then the current block is settled. + if next == nil { + return + } + + // If the person iterating over this iterator broke out of the loop, + // we must not call yield again. + if !yield(current.Block) { + return + } + + current = next + } + } +} diff --git a/worstcase/state.go b/worstcase/state.go new file mode 100644 index 00000000..66f89ae0 --- /dev/null +++ b/worstcase/state.go @@ -0,0 +1,214 @@ +// Package worstcase is a pessimist, always seeing the glass as half empty. But +// where others see full glasses and opportunities, package worstcase sees DoS +// vulnerabilities. +package worstcase + +import ( + "errors" + "fmt" + "math" + "math/big" + + "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/state" + "github.com/ava-labs/libevm/core/txpool" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/strevm/gastime" + "github.com/ava-labs/strevm/hook" + "github.com/holiman/uint256" +) + +// A State assumes that every transaction will consume its stated +// gas limit, tracking worst-case gas costs under this assumption. +type State struct { + db *state.StateDB + + curr *types.Header + config *params.ChainConfig + rules params.Rules + signer types.Signer + + clock *gastime.Time + + maxQSeconds, maxBlockSeconds uint64 + qLength, maxQLength, blockSize, maxBlockSize gas.Gas +} + +// NewState constructs a new includer. +// +// The [state.StateDB] MUST be opened at the state immediately following the +// last-executed block upon which the includer is building. Similarly, the +// [gastime.Time] MUST be a clone of the gas clock at the same point. The +// StateDB will only be used as a scratchpad for tracking accounts, and will NOT +// be committed. +// +// [State.StartBlock] MUST be called before the first call to [State.Include]. +func NewState( + db *state.StateDB, + config *params.ChainConfig, + fromExecTime *gastime.Time, + maxQueueSeconds, maxBlockSeconds uint64, +) *State { + s := &State{ + db: db, + config: config, + clock: fromExecTime, + maxQSeconds: maxQueueSeconds, + maxBlockSeconds: maxBlockSeconds, + } + s.setMaxSizes() + return s +} + +func (s *State) setMaxSizes() { + s.maxQLength = s.clock.Rate() * gas.Gas(s.maxQSeconds) + s.maxBlockSize = s.clock.Rate() * gas.Gas(s.maxBlockSeconds) +} + +var errNonConsecutiveBlocks = errors.New("non-consecutive block numbers") + +// StartBlock calls [State.FinishBlock] and then fast-forwards the +// includer's [gastime.Time] to the new block's timestamp before updating the +// gas target. Only the block number and timestamp are required to be set in the +// header. +func (s *State) StartBlock(hdr *types.Header, target gas.Gas) error { + if c := s.curr; c != nil { + if num, next := c.Number.Uint64(), hdr.Number.Uint64(); next != num+1 { + return fmt.Errorf("%w: %d then %d", errNonConsecutiveBlocks, num, next) + } + } + + s.FinishBlock() + if err := hook.BeforeBlock(s.clock, hdr, target); err != nil { + return err + } + s.setMaxSizes() + s.curr = types.CopyHeader(hdr) + s.curr.GasLimit = uint64(min(s.maxQLength, s.maxBlockSize)) + + // For both rules and signer, we MUST use the block's timestamp, not the + // execution clock's, otherwise we might enable an upgrade too early. + s.rules = s.config.Rules(hdr.Number, true, hdr.Time) + s.signer = types.MakeSigner(s.config, hdr.Number, hdr.Time) + return nil +} + +// FinishBlock advances the includer's [gastime.Time] to account for all +// included transactions since the last call to FinishBlock. In the absence of +// intervening calls to [State.Include], calls to FinishBlock are +// idempotent. +// +// There is no need to call FinishBlock before a call to +// [State.StartBlock]. +func (s *State) FinishBlock() { + hook.AfterBlock(s.clock, s.blockSize) + s.blockSize = 0 +} + +// ErrQueueTooFull and ErrBlockTooFull are returned by +// [State.Include] if inclusion of the transaction would have +// caused the queue or block, respectively, to exceed their maximum allowed gas +// length. +var ( + ErrQueueTooFull = errors.New("queue too full") + ErrBlockTooFull = errors.New("block too full") +) + +// ApplyTx validates the transaction both intrinsically and in the context of +// worst-case gas assumptions of all previous operations. This provides an upper +// bound on the total cost of the transaction such that a nil error returned by +// ApplyTx guarantees that the sender of the transaction will have sufficient +// balance to cover its costs if consensus accepts the same operation set +// (and order) as was applied. +// +// If the transaction can not be applied, an error is returned and the state is +// not modified. +func (s *State) ApplyTx(tx *types.Transaction) error { + opts := &txpool.ValidationOptions{ + Config: s.config, + Accept: 0 | + 1< s.maxQLength-s.qLength: + return ErrQueueTooFull + case o.Gas > s.maxBlockSize-s.blockSize: + return ErrBlockTooFull + } + + // ----- GasPrice ----- + if min := s.clock.BaseFee(); o.GasPrice.Cmp(min) < 0 { + return core.ErrFeeCapTooLow + } + + // ----- From ----- + for from, ad := range o.From { + switch nonce, next := ad.Nonce, s.db.GetNonce(from); { + case nonce < next: + return fmt.Errorf("%w: %d < %d", core.ErrNonceTooLow, nonce, next) + case nonce > next: + return fmt.Errorf("%w: %d > %d", core.ErrNonceTooHigh, nonce, next) + case next == math.MaxUint64: + return core.ErrNonceMax + } + + if bal := s.db.GetBalance(from); bal.Cmp(&ad.Amount) < 0 { + return core.ErrInsufficientFunds + } + } + + // ----- Inclusion ----- + s.qLength += o.Gas + s.blockSize += o.Gas + + for from, ad := range o.From { + s.db.SetNonce(from, ad.Nonce+1) + s.db.SubBalance(from, &ad.Amount) + } + + for to, amount := range o.To { + s.db.AddBalance(to, &amount) + } + return nil +} diff --git a/worstcase/state_test.go b/worstcase/state_test.go new file mode 100644 index 00000000..4557f262 --- /dev/null +++ b/worstcase/state_test.go @@ -0,0 +1,162 @@ +package worstcase + +import ( + "math/big" + "testing" + + "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/crypto" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/strevm/gastime" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +func newDB(tb testing.TB) *state.StateDB { + tb.Helper() + db, err := state.New(types.EmptyRootHash, state.NewDatabase(rawdb.NewMemoryDatabase()), nil) + require.NoError(tb, err, "state.New([empty root], [fresh memory db])") + return db +} + +func newTxIncluder(tb testing.TB) (*State, *state.StateDB) { + tb.Helper() + db := newDB(tb) + return NewTxIncluder( + db, params.MergedTestChainConfig, + gastime.New(0, 1e6, 0), + 5, 2, + ), db +} + +func TestNonContextualTransactionRejection(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err, "libevm/crypto.GenerateKey()") + eoa := crypto.PubkeyToAddress(key.PublicKey) + + tests := []struct { + name string + stateSetup func(*state.StateDB) + tx types.TxData + wantErrIs error + }{ + { + name: "nil_err", + stateSetup: func(db *state.StateDB) { + db.SetBalance(eoa, uint256.NewInt(params.TxGas)) + }, + tx: &types.LegacyTx{ + Nonce: 0, + Gas: params.TxGas, + GasPrice: big.NewInt(1), + To: &common.Address{}, + }, + wantErrIs: nil, + }, + { + name: "nonce_too_low", + stateSetup: func(db *state.StateDB) { + db.SetNonce(eoa, 1) + }, + tx: &types.LegacyTx{ + Nonce: 0, + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErrIs: core.ErrNonceTooLow, + }, + { + name: "nonce_too_high", + stateSetup: func(db *state.StateDB) { + db.SetNonce(eoa, 1) + }, + tx: &types.LegacyTx{ + Nonce: 2, + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErrIs: core.ErrNonceTooHigh, + }, + { + name: "exceed_max_init_code_size", + tx: &types.LegacyTx{ + To: nil, // i.e. contract creation + Data: make([]byte, params.MaxInitCodeSize+1), + Gas: 250_000, // cover intrinsic gas + }, + wantErrIs: core.ErrMaxInitCodeSizeExceeded, + }, + { + name: "not_cover_intrinsic_gas", + tx: &types.LegacyTx{ + Gas: params.TxGas - 1, + To: &common.Address{}, + }, + wantErrIs: core.ErrIntrinsicGas, + }, + { + name: "gas_price_too_low", + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: big.NewInt(0), + To: &common.Address{}, + }, + wantErrIs: core.ErrFeeCapTooLow, + }, + { + name: "insufficient_funds_for_gas", + stateSetup: func(db *state.StateDB) { + db.SetBalance(eoa, uint256.NewInt(params.TxGas-1)) + }, + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: big.NewInt(1), + To: &common.Address{}, + }, + wantErrIs: core.ErrInsufficientFunds, + }, + { + name: "insufficient_funds_for_gas_and_value", + stateSetup: func(db *state.StateDB) { + db.SetBalance(eoa, uint256.NewInt(params.TxGas)) + }, + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: big.NewInt(1), + Value: big.NewInt(1), + To: &common.Address{}, + }, + wantErrIs: core.ErrInsufficientFunds, + }, + { + name: "blob_tx_not_supported", + tx: &types.BlobTx{ + Gas: params.TxGas, + }, + wantErrIs: core.ErrTxTypeNotSupported, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inc, db := newTxIncluder(t) + require.NoError(t, inc.StartBlock(&types.Header{ + Number: big.NewInt(0), + }, 1e6), "StartBlock(0, t=0)") + if tt.stateSetup != nil { + tt.stateSetup(db) + } + tx := types.MustSignNewTx(key, types.NewCancunSigner(inc.config.ChainID), tt.tx) + require.ErrorIs(t, inc.ApplyTx(tx), tt.wantErrIs) + }) + } +} + +func TestContextualTransactionRejection(t *testing.T) { + // TODO(arr4n) test rejection of transactions in the context of other + // transactions, e.g. exhausting balance, gas price increasing, etc. +} From 71fa65948f3924c04c849681cb96bc5fc60d922a Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 4 Dec 2025 16:40:52 -0500 Subject: [PATCH 03/35] wip --- hook/hook.go | 4 ++-- hook/hookstest/stub.go | 2 +- worstcase/state.go | 34 ++++++++++++++++++++++++++++++++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index aca21d89..4285824c 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -24,7 +24,7 @@ import ( // Points define user-injected hook points. type Points interface { GasTarget(parent *types.Block) gas.Gas - SubSecondBlockTime(*types.Block) gas.Gas + SubSecondBlockTime(*types.Header) gas.Gas BeforeBlock(params.Rules, *state.StateDB, *types.Block) error AfterBlock(*state.StateDB, *types.Block, types.Receipts) } @@ -34,7 +34,7 @@ type Points interface { 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()), + pts.SubSecondBlockTime(b.Header()), ) target := pts.GasTarget(b.ParentBlock().EthBlock()) if err := clock.SetTarget(target); err != nil { diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 611c70f7..705e6abe 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -26,7 +26,7 @@ func (s *Stub) GasTarget(parent *types.Block) gas.Gas { } // SubSecondBlockTime time ignores its argument and always returns 0. -func (*Stub) SubSecondBlockTime(*types.Block) gas.Gas { +func (*Stub) SubSecondBlockTime(*types.Header) gas.Gas { return 0 } diff --git a/worstcase/state.go b/worstcase/state.go index 66f89ae0..a6a6f6ad 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -21,6 +21,37 @@ import ( "github.com/holiman/uint256" ) +/* +// 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) +} +*/ + // A State assumes that every transaction will consume its stated // gas limit, tracking worst-case gas costs under this assumption. type State struct { @@ -33,8 +64,7 @@ type State struct { clock *gastime.Time - maxQSeconds, maxBlockSeconds uint64 - qLength, maxQLength, blockSize, maxBlockSize gas.Gas + qSize, blockSize, maxBlockSize gas.Gas } // NewState constructs a new includer. From f5637c4ac15c419c7d309f77dcb8ac88f8d21b1a Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 4 Dec 2025 17:51:02 -0500 Subject: [PATCH 04/35] wip --- hook/hook.go | 50 ++++++++++++-- saexec/execution.go | 4 +- worstcase/state.go | 155 +++++++++++++++++++------------------------- 3 files changed, 114 insertions(+), 95 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index 4285824c..c22edfe1 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -29,9 +29,41 @@ type Points interface { 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 { +// BeforeBuildBlock is intended to be called before building a block. +func BeforeBuildBlock( + pts Points, + h *types.Header, + parent *blocks.Block, + clock *gastime.Time, +) error { + clock.FastForwardTo( + h.Time, + pts.SubSecondBlockTime(h), + ) + target := pts.GasTarget(parent.EthBlock()) + if err := clock.SetTarget(target); err != nil { + return fmt.Errorf("%T.SetTarget() before block: %w", clock, err) + } + return nil +} + +// AfterBlock is intended to be called after building a block, with the gas +// sourced from [types.Block.GasUsed] or equivalent. +func AfterBuildBlock( + clock *gastime.Time, + used gas.Gas, +) { + clock.Tick(used) +} + +// BeforeExecuteBlock is intended to be called before executing a block. +func BeforeExecuteBlock( + pts Points, + rules params.Rules, + sdb *state.StateDB, + b *blocks.Block, + clock *gastime.Time, +) error { clock.FastForwardTo( b.BuildTime(), pts.SubSecondBlockTime(b.Header()), @@ -43,9 +75,15 @@ func BeforeBlock(pts Points, rules params.Rules, sdb *state.StateDB, b *blocks.B 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) { +// AfterExecuteBlock is intended to be called after executing a block. +func AfterExecuteBlock( + 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) } diff --git a/saexec/execution.go b/saexec/execution.go index 68df2b7a..8c449333 100644 --- a/saexec/execution.go +++ b/saexec/execution.go @@ -103,7 +103,7 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { 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 { + if err := hook.BeforeExecuteBlock(e.hooks, rules, stateDB, b, gasClock); err != nil { return fmt.Errorf("before-block hook: %v", err) } perTxClock := gasClock.Time.Clone() @@ -160,7 +160,7 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { receipts[ti] = receipt } endTime := time.Now() - hook.AfterBlock(e.hooks, stateDB, b.EthBlock(), gasClock, blockGasConsumed, receipts) + hook.AfterExecuteBlock(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()) } diff --git a/worstcase/state.go b/worstcase/state.go index a6a6f6ad..e2806565 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -15,56 +15,41 @@ import ( "github.com/ava-labs/libevm/core/state" "github.com/ava-labs/libevm/core/txpool" "github.com/ava-labs/libevm/core/types" - "github.com/ava-labs/libevm/params" + libparams "github.com/ava-labs/libevm/params" + "github.com/ava-labs/strevm/blocks" "github.com/ava-labs/strevm/gastime" "github.com/ava-labs/strevm/hook" + "github.com/ava-labs/strevm/params" "github.com/holiman/uint256" ) -/* -// 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) -} -*/ +const ( + maxQSizeMultiplier = 2 + // In order to avoid overflow when calculating the queue size, we cap the + // maximum gas rate to a safe value. + // + // This follows from: + // maxBlockSize = maxRate * Tau * Lambda + // maxQSizeInStart = maxQSizeMultiplier * maxBlockSize + // maxQSizeInFinish = maxQSizeInStart + maxBlockSize + maxRate gas.Gas = math.MaxUint64 / params.Tau / params.Lambda / (maxQSizeMultiplier + 1) +) // A State assumes that every transaction will consume its stated // gas limit, tracking worst-case gas costs under this assumption. type State struct { - db *state.StateDB - - curr *types.Header - config *params.ChainConfig - rules params.Rules - signer types.Signer + pts hook.Points + config *libparams.ChainConfig + db *state.StateDB clock *gastime.Time qSize, blockSize, maxBlockSize gas.Gas + + baseFee *uint256.Int + curr *types.Header + rules libparams.Rules + signer types.Signer } // NewState constructs a new includer. @@ -77,47 +62,57 @@ type State struct { // // [State.StartBlock] MUST be called before the first call to [State.Include]. func NewState( + pts hook.Points, + config *libparams.ChainConfig, db *state.StateDB, - config *params.ChainConfig, fromExecTime *gastime.Time, - maxQueueSeconds, maxBlockSeconds uint64, ) *State { - s := &State{ - db: db, - config: config, - clock: fromExecTime, - maxQSeconds: maxQueueSeconds, - maxBlockSeconds: maxBlockSeconds, + return &State{ + pts: pts, + config: config, + db: db, + clock: fromExecTime, } - s.setMaxSizes() - return s -} - -func (s *State) setMaxSizes() { - s.maxQLength = s.clock.Rate() * gas.Gas(s.maxQSeconds) - s.maxBlockSize = s.clock.Rate() * gas.Gas(s.maxBlockSeconds) } -var errNonConsecutiveBlocks = errors.New("non-consecutive block numbers") +var ( + errNonConsecutiveBlocks = errors.New("non-consecutive block numbers") + ErrQueueFull = errors.New("queue full") +) -// StartBlock calls [State.FinishBlock] and then fast-forwards the -// includer's [gastime.Time] to the new block's timestamp before updating the -// gas target. Only the block number and timestamp are required to be set in the -// header. -func (s *State) StartBlock(hdr *types.Header, target gas.Gas) error { +// StartBlock fast-forwards the [gastime.Time] to the header's timestamp +// before updating the gas target. +// +// If the queue is too full to accept another block, [ErrQueueFull] is returned. +// +// This function populates the header's GasLimit and BaseFee fields. +func (s *State) StartBlock( + hdr *types.Header, + parent *blocks.Block, +) error { if c := s.curr; c != nil { if num, next := c.Number.Uint64(), hdr.Number.Uint64(); next != num+1 { return fmt.Errorf("%w: %d then %d", errNonConsecutiveBlocks, num, next) } } - s.FinishBlock() - if err := hook.BeforeBlock(s.clock, hdr, target); err != nil { + if err := hook.BeforeBuildBlock(s.pts, hdr, parent, s.clock); err != nil { return err } - s.setMaxSizes() - s.curr = types.CopyHeader(hdr) - s.curr.GasLimit = uint64(min(s.maxQLength, s.maxBlockSize)) + + s.blockSize = 0 + + r := min(s.clock.Rate(), maxRate) + s.maxBlockSize = r * params.Tau * params.Lambda + if maxQSize := maxQSizeMultiplier * s.maxBlockSize; s.qSize > maxQSize { + return fmt.Errorf("%w: current size %d exceeds maximum size %d", ErrQueueFull, s.qSize, maxQSize) + } + + s.baseFee = s.clock.BaseFee() + + s.curr = hdr + s.curr.GasLimit = uint64(s.maxBlockSize) + s.curr.BaseFee = s.baseFee.ToBig() // For both rules and signer, we MUST use the block's timestamp, not the // execution clock's, otherwise we might enable an upgrade too early. @@ -127,25 +122,15 @@ func (s *State) StartBlock(hdr *types.Header, target gas.Gas) error { } // FinishBlock advances the includer's [gastime.Time] to account for all -// included transactions since the last call to FinishBlock. In the absence of -// intervening calls to [State.Include], calls to FinishBlock are -// idempotent. -// -// There is no need to call FinishBlock before a call to -// [State.StartBlock]. +// included operations in the current block. func (s *State) FinishBlock() { - hook.AfterBlock(s.clock, s.blockSize) - s.blockSize = 0 + hook.AfterBuildBlock(s.clock, s.blockSize) + s.qSize += s.blockSize } -// ErrQueueTooFull and ErrBlockTooFull are returned by -// [State.Include] if inclusion of the transaction would have -// caused the queue or block, respectively, to exceed their maximum allowed gas -// length. -var ( - ErrQueueTooFull = errors.New("queue too full") - ErrBlockTooFull = errors.New("block too full") -) +// ErrBlockTooFull is returned by [State.ApplyTx] and [State.Apply] if inclusion +// would cause the block to exceed the gas limit. +var ErrBlockTooFull = errors.New("block too full") // ApplyTx validates the transaction both intrinsically and in the context of // worst-case gas assumptions of all previous operations. This provides an upper @@ -194,21 +179,18 @@ func (s *State) ApplyTx(tx *types.Transaction) error { // not modified. // // Operations are invalid if: -// - The operation consumes more gas than currently allowed. +// - The operation consumes more gas than the block has available. // - The operation specifies too low of a gas price. // - The operation specifies a From account with an incorrect or invalid nonce. // - The operation specifies a From account with an insufficient balance. func (s *State) Apply(o hook.Op) error { // ----- Gas ----- - switch { - case o.Gas > s.maxQLength-s.qLength: - return ErrQueueTooFull - case o.Gas > s.maxBlockSize-s.blockSize: + if o.Gas > s.maxBlockSize-s.blockSize { return ErrBlockTooFull } // ----- GasPrice ----- - if min := s.clock.BaseFee(); o.GasPrice.Cmp(min) < 0 { + if o.GasPrice.Cmp(s.baseFee) < 0 { return core.ErrFeeCapTooLow } @@ -223,13 +205,12 @@ func (s *State) Apply(o hook.Op) error { return core.ErrNonceMax } - if bal := s.db.GetBalance(from); bal.Cmp(&ad.Amount) < 0 { + if bal := s.db.GetBalance(from); ad.Amount.Cmp(bal) > 0 { return core.ErrInsufficientFunds } } // ----- Inclusion ----- - s.qLength += o.Gas s.blockSize += o.Gas for from, ad := range o.From { From 8b038bf61442d77d647c558cb050c787093dc83d Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 16:18:28 -0500 Subject: [PATCH 05/35] Move BeforeBlock and AfterBlock into gastime --- gastime/acp176.go | 32 +++++++++++++++ gastime/acp176_test.go | 88 ++++++++++++++++++++++++++++++++++++++++++ hook/hook.go | 39 ++++++------------- hook/hookstest/stub.go | 12 +++--- saexec/execution.go | 16 ++++---- 5 files changed, 147 insertions(+), 40 deletions(-) create mode 100644 gastime/acp176.go create mode 100644 gastime/acp176_test.go diff --git a/gastime/acp176.go b/gastime/acp176.go new file mode 100644 index 00000000..9a851c18 --- /dev/null +++ b/gastime/acp176.go @@ -0,0 +1,32 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +// Package gastime measures time based on the consumption of gas. +package gastime + +import ( + "fmt" + + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/strevm/hook" +) + +// BeforeBlock is intended to be called before processing a block, with the +// timestamp sourced from [hook.Points] and [types.Header]. +func BeforeBlock(clock *Time, pts hook.Points, h *types.Header) { + r := clock.Rate() + toFrac := pts.SubSecondBlockTime(r, h) + clock.FastForwardTo(h.Time, toFrac) +} + +// AfterBlock is intended to be called after processing a block, with the target +// sourced from [hook.Points] and [types.Header]. +func AfterBlock(clock *Time, used gas.Gas, pts hook.Points, h *types.Header) error { + clock.Tick(used) + target := pts.GasTarget(h) + if err := clock.SetTarget(target); err != nil { + return fmt.Errorf("%T.SetTarget() after block: %w", clock, err) + } + return nil +} diff --git a/gastime/acp176_test.go b/gastime/acp176_test.go new file mode 100644 index 00000000..b2f10b1c --- /dev/null +++ b/gastime/acp176_test.go @@ -0,0 +1,88 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package gastime + +import ( + "testing" + + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/strevm/hook/hookstest" + "github.com/stretchr/testify/require" +) + +func FuzzWorstCasePrice(f *testing.F) { + f.Fuzz(func( + t *testing.T, + initTimestamp, initTarget, initExcess, + time0, timeFrac0, used0, limit0, target0, + time1, timeFrac1, used1, limit1, target1, + time2, timeFrac2, used2, limit2, target2, + time3, timeFrac3, used3, limit3, target3 uint64, + ) { + initTarget = max(initTarget, 1) + + worstcase := New(initTimestamp, gas.Gas(initTarget), gas.Gas(initExcess)) + actual := New(initTimestamp, gas.Gas(initTarget), gas.Gas(initExcess)) + require.LessOrEqual(t, actual.Price(), worstcase.Price()) + + blocks := []struct { + time uint64 + timeFrac gas.Gas + used gas.Gas + limit gas.Gas + target gas.Gas + }{ + { + time: time0, + timeFrac: gas.Gas(timeFrac0), + used: gas.Gas(used0), + limit: gas.Gas(limit0), + target: gas.Gas(target0), + }, + { + time: time1, + timeFrac: gas.Gas(timeFrac1), + used: gas.Gas(used1), + limit: gas.Gas(limit1), + target: gas.Gas(target1), + }, + { + time: time2, + timeFrac: gas.Gas(timeFrac2), + used: gas.Gas(used2), + limit: gas.Gas(limit2), + target: gas.Gas(target2), + }, + { + time: time3, + timeFrac: gas.Gas(timeFrac3), + used: gas.Gas(used3), + limit: gas.Gas(limit3), + target: gas.Gas(target3), + }, + } + for _, block := range blocks { + block.limit = max(block.used, block.limit) + block.target = clampTarget(max(block.target, 1)) + block.timeFrac %= rateOf(block.target) + + header := &types.Header{ + Time: block.time, + } + hook := &hookstest.Stub{ + Target: block.target, + SubSecondTime: block.timeFrac, + } + + BeforeBlock(worstcase, hook, header) + BeforeBlock(actual, hook, header) + + require.LessOrEqual(t, actual.Price(), worstcase.Price()) + + AfterBlock(worstcase, block.limit, hook, header) + AfterBlock(actual, block.used, hook, header) + } + }) +} diff --git a/hook/hook.go b/hook/hook.go index aca21d89..a518a1bb 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -8,48 +8,31 @@ 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 + // GasTarget returns the amount of gas per second that the chain should + // target to consume after executing the given block. + GasTarget(*types.Header) gas.Gas + // SubSecondBlockTime returns the sub-second portion of the block time based + // on the provided gas rate. + // + // For example, if the block timestamp is 10.75 seconds and the gas rate is + // 100 gas/second, then this method should return 75 gas. + SubSecondBlockTime(gasRate gas.Gas, h *types.Header) gas.Gas + // BeforeBlock is called immediately prior to executing the block. BeforeBlock(params.Rules, *state.StateDB, *types.Block) error + // AfterBlock is called immediately after executing the block. 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. diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 611c70f7..62f0b8b6 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -15,19 +15,21 @@ import ( // Stub implements [hook.Points]. type Stub struct { - Target gas.Gas + Target gas.Gas + SubSecondTime 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 { +func (s *Stub) GasTarget(*types.Header) gas.Gas { return s.Target } -// SubSecondBlockTime time ignores its argument and always returns 0. -func (*Stub) SubSecondBlockTime(*types.Block) gas.Gas { - return 0 +// SubSecondBlockTime time ignores its arguments and always returns +// [Stub.SubSecondTime]. +func (s *Stub) SubSecondBlockTime(gas.Gas, *types.Header) gas.Gas { + return s.SubSecondTime } // BeforeBlock is a no-op that always returns nil. diff --git a/saexec/execution.go b/saexec/execution.go index 1fba3507..d82055cb 100644 --- a/saexec/execution.go +++ b/saexec/execution.go @@ -19,7 +19,7 @@ import ( "go.uber.org/zap" "github.com/ava-labs/strevm/blocks" - "github.com/ava-labs/strevm/hook" + "github.com/ava-labs/strevm/gastime" ) var errExecutorClosed = errors.New("saexec.Executor closed") @@ -103,12 +103,14 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { 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 { + gastime.BeforeBlock(gasClock, e.hooks, b.Header()) + perTxClock := gasClock.Time.Clone() + + rules := e.chainConfig.Rules(b.Number(), true /*isMerge*/, b.BuildTime()) + if err := e.hooks.BeforeBlock(rules, stateDB, b.EthBlock()); err != nil { return fmt.Errorf("before-block hook: %v", err) } - perTxClock := gasClock.Time.Clone() header := types.CopyHeader(b.Header()) header.BaseFee = gasClock.BaseFee().ToBig() @@ -161,10 +163,10 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { // to access them before the end of the block. receipts[ti] = receipt } + e.hooks.AfterBlock(stateDB, b.EthBlock(), receipts) 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()) + if err := gastime.AfterBlock(gasClock, blockGasConsumed, e.hooks, b.Header()); err != nil { + return fmt.Errorf("after-block gas time update: %w", err) } logger.Debug( From af596f7ac2bb571837a978f60adf68f9d75c3162 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 16:29:15 -0500 Subject: [PATCH 06/35] Replace full blocks with headers in hook.Points --- hook/hook.go | 15 +++++++++++---- hook/hookstest/stub.go | 4 ++-- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index aca21d89..d392bea8 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -23,9 +23,16 @@ import ( // Points define user-injected hook points. type Points interface { - GasTarget(parent *types.Block) gas.Gas - SubSecondBlockTime(*types.Block) gas.Gas + GasTarget(parent *types.Header) gas.Gas + // SubSecondBlockTime returns the sub-second portion of the block time based + // on the provided gas rate. + // + // For example, if the block timestamp is 10.75 seconds and the gas rate is + // 100 gas/second, then this method should return 75 gas. + SubSecondBlockTime(*types.Header) gas.Gas + // BeforeBlock is called immediately prior to executing the block. BeforeBlock(params.Rules, *state.StateDB, *types.Block) error + // AfterBlock is called immediately after executing the block. AfterBlock(*state.StateDB, *types.Block, types.Receipts) } @@ -34,9 +41,9 @@ type Points interface { 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()), + pts.SubSecondBlockTime(b.Header()), ) - target := pts.GasTarget(b.ParentBlock().EthBlock()) + target := pts.GasTarget(b.ParentBlock().Header()) if err := clock.SetTarget(target); err != nil { return fmt.Errorf("%T.SetTarget() before block: %w", clock, err) } diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 611c70f7..668e66ce 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -21,12 +21,12 @@ type Stub struct { var _ hook.Points = (*Stub)(nil) // GasTarget ignores its argument and always returns [Stub.Target]. -func (s *Stub) GasTarget(parent *types.Block) gas.Gas { +func (s *Stub) GasTarget(*types.Header) gas.Gas { return s.Target } // SubSecondBlockTime time ignores its argument and always returns 0. -func (*Stub) SubSecondBlockTime(*types.Block) gas.Gas { +func (*Stub) SubSecondBlockTime(*types.Header) gas.Gas { return 0 } From 178724f21daed7c9922541304d707225aad80e13 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 16:42:39 -0500 Subject: [PATCH 07/35] Update gas target immediately after block execution --- hook/hook.go | 22 ++++++++++++---------- saexec/execution.go | 7 +++---- 2 files changed, 15 insertions(+), 14 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index d392bea8..382c8e03 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -15,7 +15,6 @@ import ( "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" @@ -23,7 +22,9 @@ import ( // Points define user-injected hook points. type Points interface { - GasTarget(parent *types.Header) gas.Gas + // GasTarget returns the amount of gas per second that the chain should + // target to consume after executing the provided block. + GasTarget(*types.Header) gas.Gas // SubSecondBlockTime returns the sub-second portion of the block time based // on the provided gas rate. // @@ -38,23 +39,24 @@ type Points interface { // 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 { +func BeforeBlock(pts Points, rules params.Rules, sdb *state.StateDB, b *types.Block, clock *gastime.Time) error { clock.FastForwardTo( - b.BuildTime(), + b.Time(), pts.SubSecondBlockTime(b.Header()), ) - target := pts.GasTarget(b.ParentBlock().Header()) - if err := clock.SetTarget(target); err != nil { - return fmt.Errorf("%T.SetTarget() before block: %w", clock, err) - } - return pts.BeforeBlock(rules, sdb, b.EthBlock()) + return pts.BeforeBlock(rules, sdb, b) } // 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) { +func AfterBlock(pts Points, sdb *state.StateDB, b *types.Block, clock *gastime.Time, used gas.Gas, rs types.Receipts) error { clock.Tick(used) + target := pts.GasTarget(b.Header()) + if err := clock.SetTarget(target); err != nil { + return fmt.Errorf("%T.SetTarget() after block: %w", clock, err) + } pts.AfterBlock(sdb, b, rs) + return nil } // MinimumGasConsumption MUST be used as the implementation for the respective diff --git a/saexec/execution.go b/saexec/execution.go index 1fba3507..9af858c5 100644 --- a/saexec/execution.go +++ b/saexec/execution.go @@ -105,7 +105,7 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { 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 { + if err := hook.BeforeBlock(e.hooks, rules, stateDB, b.EthBlock(), gasClock); err != nil { return fmt.Errorf("before-block hook: %v", err) } perTxClock := gasClock.Time.Clone() @@ -162,9 +162,8 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { 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()) + if err := hook.AfterBlock(e.hooks, stateDB, b.EthBlock(), gasClock, blockGasConsumed, receipts); err != nil { + return fmt.Errorf("after-block hook: %v", err) } logger.Debug( From 19c3fb99dd518bd09bbbb8bc0f18b59ffce2b1ab Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 17:08:15 -0500 Subject: [PATCH 08/35] Add test --- hook/hook_test.go | 64 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 hook/hook_test.go diff --git a/hook/hook_test.go b/hook/hook_test.go new file mode 100644 index 00000000..ca720120 --- /dev/null +++ b/hook/hook_test.go @@ -0,0 +1,64 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package hook_test + +import ( + "testing" + + "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/strevm/gastime" + "github.com/ava-labs/strevm/hook/hookstest" + "github.com/ava-labs/strevm/saetest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + . "github.com/ava-labs/strevm/hook" +) + +// TestTargetUpdateTiming verifies that the gas target is modified in AfterBlock +// rather than BeforeBlock. +func TestTargetUpdateTiming(t *testing.T) { + const ( + initialTime = 42 + initialTarget gas.Gas = 1_600_000 + initialExcess = 1_234_567_890 + ) + tm := gastime.New(initialTime, initialTarget, initialExcess) + + const ( + newTime uint64 = initialTime + 1 + newTarget = initialTarget + 100_000 + ) + hook := &hookstest.Stub{ + Target: newTarget, + } + header := &types.Header{ + Time: newTime, + } + block := types.NewBlock(header, nil, nil, nil, saetest.TrieHasher()) + + initialPrice := tm.Price() + require.NoError(t, BeforeBlock(hook, params.TestRules, nil, block, tm), "BeforeBlock()") + assert.Equal(t, newTime, tm.Unix(), "Unix time advanced by BeforeBlock()") + assert.Equal(t, initialTarget, tm.Target(), "Target not changed by BeforeBlock()") + + enforcedPrice := tm.Price() + assert.LessOrEqual(t, enforcedPrice, initialPrice, "Price should not increase in BeforeBlock()") + if t.Failed() { + t.FailNow() + } + + const ( + secondsOfGasUsed = 3 + initialRate = initialTarget * gastime.TargetToRate + used gas.Gas = initialRate * secondsOfGasUsed + expectedEndTime = newTime + secondsOfGasUsed + ) + require.NoError(t, AfterBlock(hook, nil, block, tm, used, nil), "AfterBlock()") + assert.Equal(t, expectedEndTime, tm.Unix(), "Unix time advanced by AfterBlock()") + assert.Equal(t, newTarget, tm.Target(), "Target updated by AfterBlock()") + assert.GreaterOrEqual(t, tm.Price(), enforcedPrice, "Price should not decrease in AfterBlock()") +} From 3b7e488f9726ad9bd62cefb98d170fe691edf744 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 17:10:09 -0500 Subject: [PATCH 09/35] reduce comments --- hook/hook.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index 382c8e03..b45a6363 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -37,8 +37,7 @@ type Points interface { AfterBlock(*state.StateDB, *types.Block, types.Receipts) } -// BeforeBlock is intended to be called before processing a block, with the gas -// target sourced from [Points]. +// BeforeBlock is intended to be called before processing a block. func BeforeBlock(pts Points, rules params.Rules, sdb *state.StateDB, b *types.Block, clock *gastime.Time) error { clock.FastForwardTo( b.Time(), @@ -47,8 +46,7 @@ func BeforeBlock(pts Points, rules params.Rules, sdb *state.StateDB, b *types.Bl return pts.BeforeBlock(rules, sdb, b) } -// AfterBlock is intended to be called after processing a block, with the gas -// sourced from [types.Block.GasUsed] or equivalent. +// AfterBlock is intended to be called after processing a block. func AfterBlock(pts Points, sdb *state.StateDB, b *types.Block, clock *gastime.Time, used gas.Gas, rs types.Receipts) error { clock.Tick(used) target := pts.GasTarget(b.Header()) From 6851d5a70f74e8527865405fbf48a4ceeefd2a17 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 17:15:34 -0500 Subject: [PATCH 10/35] Update hook.Points --- hook/hook.go | 5 +++-- hook/hookstest/stub.go | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index b45a6363..949bd22d 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -30,7 +30,7 @@ type Points interface { // // For example, if the block timestamp is 10.75 seconds and the gas rate is // 100 gas/second, then this method should return 75 gas. - SubSecondBlockTime(*types.Header) gas.Gas + SubSecondBlockTime(gasRate gas.Gas, h *types.Header) gas.Gas // BeforeBlock is called immediately prior to executing the block. BeforeBlock(params.Rules, *state.StateDB, *types.Block) error // AfterBlock is called immediately after executing the block. @@ -39,9 +39,10 @@ type Points interface { // BeforeBlock is intended to be called before processing a block. func BeforeBlock(pts Points, rules params.Rules, sdb *state.StateDB, b *types.Block, clock *gastime.Time) error { + r := clock.Rate() clock.FastForwardTo( b.Time(), - pts.SubSecondBlockTime(b.Header()), + pts.SubSecondBlockTime(r, b.Header()), ) return pts.BeforeBlock(rules, sdb, b) } diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 668e66ce..f416611a 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -25,8 +25,8 @@ func (s *Stub) GasTarget(*types.Header) gas.Gas { return s.Target } -// SubSecondBlockTime time ignores its argument and always returns 0. -func (*Stub) SubSecondBlockTime(*types.Header) gas.Gas { +// SubSecondBlockTime time ignores its arguments and always returns 0. +func (*Stub) SubSecondBlockTime(gas.Gas, *types.Header) gas.Gas { return 0 } From 2db758533b688b8362cb42a3bc31a47712f6c505 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 18:34:35 -0500 Subject: [PATCH 11/35] worstcase.State compiling --- builder.go | 293 ----------------------------------------- hook/hook.go | 25 ++++ hook/hookstest/stub.go | 5 + worstcase/state.go | 49 ++++--- 4 files changed, 63 insertions(+), 309 deletions(-) delete mode 100644 builder.go diff --git a/builder.go b/builder.go deleted file mode 100644 index d8d7bf87..00000000 --- a/builder.go +++ /dev/null @@ -1,293 +0,0 @@ -package sae - -import ( - "context" - "errors" - "fmt" - "iter" - "math/big" - "slices" - - "github.com/arr4n/sink" - "github.com/ava-labs/avalanchego/snow/engine/snowman/block" - "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/strevm/blocks" - "github.com/ava-labs/strevm/hook" - "github.com/ava-labs/strevm/intmath" - "github.com/ava-labs/strevm/params" - "github.com/ava-labs/strevm/queue" - "github.com/ava-labs/strevm/worstcase" - "go.uber.org/zap" -) - -func (vm *VM) buildBlock(ctx context.Context, blockContext *block.Context, timestamp uint64, parent *blocks.Block) (*blocks.Block, error) { - block, err := sink.FromPriorityMutex( - ctx, vm.mempool, sink.MaxPriority, - func(_ <-chan sink.Priority, pool *queue.Priority[*pendingTx]) (*blocks.Block, error) { - block, err := vm.buildBlockWithCandidateTxs(timestamp, parent, pool, blockContext, vm.hooks.ConstructBlock) - if pool.Len() == 0 { - vm.mempoolHasTxs.Block() - } - return block, err - }, - ) - if err != nil { - return nil, err - } - vm.logger().Debug( - "Built block", - zap.Uint64("timestamp", timestamp), - zap.Uint64("height", block.Height()), - zap.Stringer("parent", parent.Hash()), - zap.Int("transactions", len(block.Transactions())), - ) - return block, nil -} - -var ( - errWaitingForExecution = errors.New("waiting for execution when building block") - errNoopBlock = errors.New("block does not settle state nor include transactions") -) - -func (vm *VM) buildBlockWithCandidateTxs( - timestamp uint64, - parent *blocks.Block, - candidateTxs queue.Queue[*pendingTx], - blockContext *block.Context, - constructBlock hook.ConstructBlock, -) (*blocks.Block, error) { - if timestamp < parent.Time() { - return nil, fmt.Errorf("block at time %d before parent at %d", timestamp, parent.Time()) - } - - toSettle, ok := vm.lastBlockToSettleAt(timestamp, parent) - if !ok { - vm.logger().Warn( - "Block building waiting for execution", - zap.Uint64("timestamp", timestamp), - zap.Stringer("parent", parent.Hash()), - ) - return nil, fmt.Errorf("%w: parent %#x at time %d", errWaitingForExecution, parent.Hash(), timestamp) - } - vm.logger().Debug( - "Settlement candidate", - zap.Uint64("timestamp", timestamp), - zap.Stringer("parent", parent.Hash()), - zap.Stringer("block_hash", toSettle.Hash()), - zap.Uint64("block_height", toSettle.Height()), - zap.Uint64("block_time", toSettle.Time()), - ) - - ethB, err := vm.buildBlockOnHistory( - toSettle, - parent, - timestamp, - candidateTxs, - blockContext, - constructBlock, - ) - if err != nil { - return nil, err - } - return blocks.New(ethB, parent, toSettle, vm.logger()) -} - -func (vm *VM) buildBlockOnHistory( - lastSettled, - parent *blocks.Block, - timestamp uint64, - candidateTxs queue.Queue[*pendingTx], - blockContext *block.Context, - constructBlock hook.ConstructBlock, -) (_ *types.Block, retErr error) { - var history []*blocks.Block - for b := parent; b.ID() != lastSettled.ID(); b = b.ParentBlock() { - history = append(history, b) - } - slices.Reverse(history) - - sdb, err := state.New(lastSettled.PostExecutionStateRoot(), vm.exec.StateCache(), nil) - if err != nil { - return nil, err - } - - checker := worstcase.NewState( - sdb, - vm.exec.ChainConfig(), - lastSettled.ExecutedByGasTime().Clone(), - 5, 2, // TODO(arr4n) what are the max queue and block seconds? - ) - - for _, b := range history { - checker.StartBlock(b.Header(), vm.hooks.GasTarget(b.ParentBlock().Block)) - for _, tx := range b.Transactions() { - if err := checker.ApplyTx(tx); err != nil { - vm.logger().Error( - "Transaction not included when replaying history", - zap.Stringer("block", b.Hash()), - zap.Stringer("tx", tx.Hash()), - zap.Error(err), - ) - return nil, err - } - } - - extraOps, err := vm.hooks.ExtraBlockOperations(context.TODO(), b.Block) - if err != nil { - vm.logger().Error( - "Unable to extract extra block operations when replaying history", - zap.Stringer("block", b.Hash()), - zap.Error(err), - ) - return nil, err - } - for i, op := range extraOps { - if err := checker.Apply(op); err != nil { - vm.logger().Error( - "Operation not applied when replaying history", - zap.Stringer("block", b.Hash()), - zap.Int("index", i), - zap.Error(err), - ) - return nil, err - } - } - } - - var ( - include []*pendingTx - delayed []*pendingTx - ) - defer func() { - for _, tx := range delayed { - candidateTxs.Push(tx) - } - if retErr != nil { - for _, tx := range include { - candidateTxs.Push(tx) - } - } - }() - - hdr := &types.Header{ - Number: new(big.Int).SetUint64(parent.NumberU64() + 1), - Time: timestamp, - } - checker.StartBlock(hdr, vm.hooks.GasTarget(parent.Block)) - - for full := false; !full && candidateTxs.Len() > 0; { - candidate := candidateTxs.Pop() - tx := candidate.tx - - switch err := checker.ApplyTx(tx); { - case err == nil: - include = append(include, candidate) - - case errIsOneOf(err, worstcase.ErrBlockTooFull, worstcase.ErrQueueTooFull): - delayed = append(delayed, candidate) - full = true - - // TODO(arr4n) handle all other errors. - - default: - vm.logger().Error( - "Unknown error from worst-case transaction checking", - zap.Error(err), - ) - - // TODO: It is not acceptable to return an error here, as all - // transactions that have been removed from the mempool will be - // dropped and never included. - return nil, err - } - } - - var ( - receipts []types.Receipts - gasUsed uint64 - ) - // We can never concurrently build and accept a block on the same parent, - // which guarantees that `parent` won't be settled, so the [Block] invariant - // means that `parent.lastSettled != nil`. - for _, b := range parent.IfChildSettles(lastSettled) { - brs := b.Receipts() - receipts = append(receipts, brs) - for _, r := range brs { - gasUsed += r.GasUsed - } - } - - // TODO(arr4n) setting `gasUsed` (i.e. historical) based on receipts and - // `gasLimit` (future-looking) based on the enqueued transactions follows - // naturally from all of the other changes. However it will be possible to - // have `gasUsed>gasLimit`, which may break some systems. - var gasLimit gas.Gas - txs := make(types.Transactions, len(include)) - for i, tx := range include { - txs[i] = tx.tx - gasLimit += gas.Gas(tx.tx.Gas()) - } - - header := &types.Header{ - ParentHash: parent.Hash(), - Root: lastSettled.PostExecutionStateRoot(), - Number: new(big.Int).Add(parent.Number(), big.NewInt(1)), - GasLimit: uint64(gasLimit), - GasUsed: gasUsed, - Time: timestamp, - BaseFee: nil, // TODO(arr4n) - } - ancestors := iterateUntilSettled(parent) - return constructBlock( - context.TODO(), - blockContext, - header, - parent.Block.Header(), - ancestors, - checker, - txs, - slices.Concat(receipts...), - ) -} - -func errIsOneOf(err error, targets ...error) bool { - for _, t := range targets { - if errors.Is(err, t) { - return true - } - } - return false -} - -func (vm *VM) lastBlockToSettleAt(timestamp uint64, parent *blocks.Block) (*blocks.Block, bool) { - settleAt := intmath.BoundedSubtract(timestamp, params.StateRootDelaySeconds, vm.last.synchronous.time) - return blocks.LastToSettleAt(settleAt, parent) -} - -// iterateUntilSettled returns an iterator which starts at the provided block -// and iterates up to but not including the most recently settled block. -// -// If the provided block is settled, then the returned iterator is empty. -func iterateUntilSettled(from *blocks.Block) iter.Seq[*types.Block] { - return func(yield func(*types.Block) bool) { - // Do not modify the `from` variable to support multiple iterations. - current := from - for { - next := current.ParentBlock() - // If the next block is nil, then the current block is settled. - if next == nil { - return - } - - // If the person iterating over this iterator broke out of the loop, - // we must not call yield again. - if !yield(current.Block) { - return - } - - current = next - } - } -} diff --git a/hook/hook.go b/hook/hook.go index 72d076f5..d8ea94b8 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -9,14 +9,35 @@ package hook import ( "github.com/ava-labs/avalanchego/vms/components/gas" + "github.com/ava-labs/libevm/common" "github.com/ava-labs/libevm/core/state" "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/params" + "github.com/holiman/uint256" "github.com/ava-labs/strevm/intmath" saeparams "github.com/ava-labs/strevm/params" ) +type Account struct { + Nonce uint64 + Amount uint256.Int +} + +type Op struct { + // Gas consumed by this operation + Gas gas.Gas + // GasPrice this operation is willing to spend + GasPrice uint256.Int + // From specifies the set of accounts and the authorization of funds to be + // removed from the accounts. + From map[common.Address]Account + // To specifies the amount to increase account balances by. These funds are + // not necessarily tied to the funds consumed in the From field. The sum of + // the To amounts may even exceed the sum of the From amounts. + To map[common.Address]uint256.Int +} + // Points define user-injected hook points. type Points interface { // GasTarget returns the amount of gas per second that the chain should @@ -30,6 +51,10 @@ type Points interface { SubSecondBlockTime(gasRate gas.Gas, h *types.Header) gas.Gas // BeforeBlock is called immediately prior to executing the block. BeforeBlock(params.Rules, *state.StateDB, *types.Block) error + // ExtraBlockOps returns operations outside of the normal EVM state changes + // to perform while executing the block. These operations should be + // performed after executing the normal ethereum transactions in the block. + ExtraBlockOps(*types.Block) ([]Op, error) // AfterBlock is called immediately after executing the block. AfterBlock(*state.StateDB, *types.Block, types.Receipts) } diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 62f0b8b6..6d590ae7 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -37,5 +37,10 @@ func (*Stub) BeforeBlock(params.Rules, *state.StateDB, *types.Block) error { return nil } +// ExtraBlockOps always returns no operations and nil. +func (*Stub) ExtraBlockOps(*types.Block) ([]hook.Op, error) { + return nil, nil +} + // AfterBlock is a no-op. func (*Stub) AfterBlock(*state.StateDB, *types.Block, types.Receipts) {} diff --git a/worstcase/state.go b/worstcase/state.go index 30e3ed84..a83eabbf 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -15,10 +15,10 @@ import ( "github.com/ava-labs/libevm/core/state" "github.com/ava-labs/libevm/core/txpool" "github.com/ava-labs/libevm/core/types" - libparams "github.com/ava-labs/libevm/params" + "github.com/ava-labs/libevm/params" "github.com/ava-labs/strevm/gastime" "github.com/ava-labs/strevm/hook" - "github.com/ava-labs/strevm/params" + saeparams "github.com/ava-labs/strevm/params" "github.com/holiman/uint256" ) @@ -31,14 +31,16 @@ const ( // maxBlockSize = maxRate * Tau * Lambda // maxQSizeInStart = maxQSizeMultiplier * maxBlockSize // maxQSizeInFinish = maxQSizeInStart + maxBlockSize - maxRate gas.Gas = math.MaxUint64 / params.Tau / params.Lambda / (maxQSizeMultiplier + 1) + maxRate gas.Gas = math.MaxUint64 / saeparams.Tau / saeparams.Lambda / (maxQSizeMultiplier + 1) ) +type Op = hook.Op + // A State assumes that every transaction will consume its stated // gas limit, tracking worst-case gas costs under this assumption. type State struct { pts hook.Points - config *libparams.ChainConfig + config *params.ChainConfig db *state.StateDB clock *gastime.Time @@ -47,7 +49,7 @@ type State struct { baseFee *uint256.Int curr *types.Header - rules libparams.Rules + rules params.Rules signer types.Signer } @@ -62,7 +64,7 @@ type State struct { // [State.StartBlock] MUST be called before the first call to [State.Include]. func NewState( pts hook.Points, - config *libparams.ChainConfig, + config *params.ChainConfig, db *state.StateDB, fromExecTime *gastime.Time, ) *State { @@ -76,7 +78,7 @@ func NewState( var ( errNonConsecutiveBlocks = errors.New("non-consecutive block numbers") - ErrQueueFull = errors.New("queue full") + errQueueFull = errors.New("queue full") ) // StartBlock updates the worst-case state to the beginning of the provided @@ -99,9 +101,9 @@ func (s *State) StartBlock(hdr *types.Header) error { s.blockSize = 0 r := min(s.clock.Rate(), maxRate) - s.maxBlockSize = r * params.Tau * params.Lambda + s.maxBlockSize = r * saeparams.Tau * saeparams.Lambda if maxQSize := maxQSizeMultiplier * s.maxBlockSize; s.qSize > maxQSize { - return fmt.Errorf("%w: current size %d exceeds maximum size %d", ErrQueueFull, s.qSize, maxQSize) + return fmt.Errorf("%w: current size %d exceeds maximum size %d", errQueueFull, s.qSize, maxQSize) } s.baseFee = s.clock.BaseFee() @@ -127,6 +129,11 @@ func (s *State) BaseFee() *uint256.Int { return s.baseFee } +var ( + errGasFeeCapOverflow = errors.New("GasFeeCap() overflows uint256") + errCostOverflow = errors.New("Cost() overflows uint256") +) + // ApplyTx validates the transaction both intrinsically and in the context of // worst-case gas assumptions of all previous operations. This provides an upper // bound on the total cost of the transaction such that a nil error returned by @@ -147,7 +154,7 @@ func (s *State) ApplyTx(tx *types.Transaction) error { MinTip: big.NewInt(0), } if err := txpool.ValidateTransaction(tx, s.curr, s.signer, opts); err != nil { - return err + return fmt.Errorf("validating transaction: %w", err) } from, err := types.Sender(s.signer, tx) @@ -155,13 +162,23 @@ func (s *State) ApplyTx(tx *types.Transaction) error { return fmt.Errorf("determining sender: %w", err) } - return s.Apply(hook.Op{ + gasPrice, overflow := uint256.FromBig(tx.GasFeeCap()) + if overflow { + return errGasFeeCapOverflow + } + + amount, overflow := uint256.FromBig(tx.Cost()) + if overflow { + return errCostOverflow + } + + return s.Apply(Op{ Gas: gas.Gas(tx.Gas()), - GasPrice: *uint256.MustFromBig(tx.GasFeeCap()), + GasPrice: *gasPrice, From: map[common.Address]hook.Account{ from: { Nonce: tx.Nonce(), - Amount: *uint256.MustFromBig(tx.Cost()), + Amount: *amount, }, }, // To is not populated here because this transaction may revert. @@ -180,9 +197,9 @@ var ErrBlockTooFull = errors.New("block too full") // Operations are invalid if: // - The operation consumes more gas than the block has available. // - The operation specifies too low of a gas price. -// - The operation specifies a From account with an incorrect or invalid nonce. -// - The operation specifies a From account with an insufficient balance. -func (s *State) Apply(o hook.Op) error { +// - The operation is from an account with an incorrect or invalid nonce. +// - The operation is from an account with an insufficient balance. +func (s *State) Apply(o Op) error { // ----- Gas ----- if o.Gas > s.maxBlockSize-s.blockSize { return ErrBlockTooFull From d612654b0fdce19ad60eefd9af8342e1f388e995 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Fri, 5 Dec 2025 18:42:50 -0500 Subject: [PATCH 12/35] nits --- worstcase/state.go | 27 +++++++++++++-------------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index a83eabbf..fd9c8f54 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -22,20 +22,6 @@ import ( "github.com/holiman/uint256" ) -const ( - maxQSizeMultiplier = 2 - // In order to avoid overflow when calculating the queue size, we cap the - // maximum gas rate to a safe value. - // - // This follows from: - // maxBlockSize = maxRate * Tau * Lambda - // maxQSizeInStart = maxQSizeMultiplier * maxBlockSize - // maxQSizeInFinish = maxQSizeInStart + maxBlockSize - maxRate gas.Gas = math.MaxUint64 / saeparams.Tau / saeparams.Lambda / (maxQSizeMultiplier + 1) -) - -type Op = hook.Op - // A State assumes that every transaction will consume its stated // gas limit, tracking worst-case gas costs under this assumption. type State struct { @@ -100,6 +86,17 @@ func (s *State) StartBlock(hdr *types.Header) error { gastime.BeforeBlock(s.clock, s.pts, hdr) s.blockSize = 0 + const ( + maxQSizeMultiplier = 2 + // In order to avoid overflow when calculating the queue size, we cap + // the maximum gas rate to a safe value. + // + // This follows from: + // maxBlockSize = maxRate * Tau * Lambda + // maxQSizeInStart = maxQSizeMultiplier * maxBlockSize + // maxQSizeInFinish = maxQSizeInStart + maxBlockSize + maxRate gas.Gas = math.MaxUint64 / saeparams.Tau / saeparams.Lambda / (maxQSizeMultiplier + 1) + ) r := min(s.clock.Rate(), maxRate) s.maxBlockSize = r * saeparams.Tau * saeparams.Lambda if maxQSize := maxQSizeMultiplier * s.maxBlockSize; s.qSize > maxQSize { @@ -129,6 +126,8 @@ func (s *State) BaseFee() *uint256.Int { return s.baseFee } +type Op = hook.Op + var ( errGasFeeCapOverflow = errors.New("GasFeeCap() overflows uint256") errCostOverflow = errors.New("Cost() overflows uint256") From b4450bb6eb188008ae33b50a529ee117d62fe4d6 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Mon, 8 Dec 2025 11:41:29 -0500 Subject: [PATCH 13/35] ok --- hook/hook_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hook/hook_test.go b/hook/hook_test.go index ded00c24..90e5d3a2 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -47,7 +47,7 @@ func TestTargetUpdateTiming(t *testing.T) { assert.Equal(t, initialTarget, tm.Target(), "Target not changed by BeforeBlock()") enforcedPrice := tm.Price() - assert.LessOrEqual(t, enforcedPrice, initialPrice, "Price should not increase in BeforeBlock()") + assert.Less(t, enforcedPrice, initialPrice, "Price should not increase in BeforeBlock()") if t.Failed() { t.FailNow() } @@ -60,5 +60,5 @@ func TestTargetUpdateTiming(t *testing.T) { require.NoError(t, AfterBlock(hook, nil, block, tm, used, nil), "AfterBlock()") assert.Equal(t, expectedEndTime, tm.Unix(), "Unix time advanced by AfterBlock()") assert.Equal(t, newTarget, tm.Target(), "Target updated by AfterBlock()") - assert.GreaterOrEqual(t, tm.Price(), enforcedPrice, "Price should not decrease in AfterBlock()") + assert.Greater(t, tm.Price(), enforcedPrice, "Price should not decrease in AfterBlock()") } From f34665a755a5618985a534379797eaf07ea4c99a Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Mon, 8 Dec 2025 11:42:58 -0500 Subject: [PATCH 14/35] nit --- hook/hook_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hook/hook_test.go b/hook/hook_test.go index 90e5d3a2..5d336856 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -45,7 +45,8 @@ func TestTargetUpdateTiming(t *testing.T) { require.NoError(t, BeforeBlock(hook, params.TestRules, nil, block, tm), "BeforeBlock()") assert.Equal(t, newTime, tm.Unix(), "Unix time advanced by BeforeBlock()") assert.Equal(t, initialTarget, tm.Target(), "Target not changed by BeforeBlock()") - + // While the price technically could remain the same, being more strict + // ensures the test is meaningful. enforcedPrice := tm.Price() assert.Less(t, enforcedPrice, initialPrice, "Price should not increase in BeforeBlock()") if t.Failed() { @@ -60,5 +61,7 @@ func TestTargetUpdateTiming(t *testing.T) { require.NoError(t, AfterBlock(hook, nil, block, tm, used, nil), "AfterBlock()") assert.Equal(t, expectedEndTime, tm.Unix(), "Unix time advanced by AfterBlock()") assert.Equal(t, newTarget, tm.Target(), "Target updated by AfterBlock()") + // While the price technically could remain the same, being more strict + // ensures the test is meaningful. assert.Greater(t, tm.Price(), enforcedPrice, "Price should not decrease in AfterBlock()") } From 0373d0a965ca70b38d810ee28d4f08cd3046525c Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Mon, 8 Dec 2025 13:30:31 -0500 Subject: [PATCH 15/35] lint? --- hook/hook_test.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hook/hook_test.go b/hook/hook_test.go index 5d336856..0d647007 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -10,12 +10,11 @@ import ( "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/params" "github.com/ava-labs/strevm/gastime" + . "github.com/ava-labs/strevm/hook" "github.com/ava-labs/strevm/hook/hookstest" "github.com/ava-labs/strevm/saetest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - . "github.com/ava-labs/strevm/hook" ) // TestTargetUpdateTiming verifies that the gas target is modified in AfterBlock From 94e1fb93a558ec3a353a50ad95ead5b1619ebfab Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Mon, 8 Dec 2025 13:34:51 -0500 Subject: [PATCH 16/35] lint --- hook/hook_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/hook/hook_test.go b/hook/hook_test.go index 0d647007..d4e06184 100644 --- a/hook/hook_test.go +++ b/hook/hook_test.go @@ -9,12 +9,13 @@ import ( "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/params" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/ava-labs/strevm/gastime" . "github.com/ava-labs/strevm/hook" "github.com/ava-labs/strevm/hook/hookstest" "github.com/ava-labs/strevm/saetest" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // TestTargetUpdateTiming verifies that the gas target is modified in AfterBlock From 5c4a6e1f290a083a274a532db6dc175469cc960c Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 9 Dec 2025 10:46:59 -0500 Subject: [PATCH 17/35] Update test --- saexec/saexec_test.go | 41 +++++++++++++++++++++++++---------------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/saexec/saexec_test.go b/saexec/saexec_test.go index fce127a9..1c43a483 100644 --- a/saexec/saexec_test.go +++ b/saexec/saexec_test.go @@ -363,9 +363,9 @@ func TestGasAccounting(t *testing.T) { // 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 + targetAfter gas.Gas 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 @@ -374,74 +374,83 @@ func TestGasAccounting(t *testing.T) { wantPriceAfter gas.Price }{ { - target: 5 * gasPerTx, + // Initially set the gasTarget for the next block. + blockTime: 0, + numTxs: 0, + targetAfter: 5 * gasPerTx, + wantExecutedBy: at(0, 0, 10*gasPerTx), + wantExcessAfter: 0, + wantPriceAfter: 1, + }, + { blockTime: 2, numTxs: 3, + targetAfter: 5 * gasPerTx, 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, + targetAfter: 5 * gasPerTx, 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, + targetAfter: 5 * gasPerTx, 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, + targetAfter: 10 * gasPerTx, // double gas/block --> halve ticking rate + wantExecutedBy: at(8, 6*2, 20*gasPerTx), // ending point scales + wantExcessAfter: 2 * (12 + 20 - 8 + 16) * gasPerTx / 2, wantPriceAfter: 1, }, { - target: 10 * gasPerTx, // double gas/block --> halve ticking rate - blockTime: 8, // no fast-forward + 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, + targetAfter: 5 * gasPerTx, // back to original + wantExecutedBy: at(8, 6+(4/2), 10*gasPerTx), // ending point scales + wantExcessAfter: ((12 + 20 - 8 + 16) + 4/2) * gasPerTx / 2, wantPriceAfter: 1, }, { - target: 5 * gasPerTx, // back to original blockTime: 8, numTxs: 5, + targetAfter: 5 * gasPerTx, 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, + targetAfter: 5 * gasPerTx, 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 + targetAfter: 5 * gasPerTx, 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, + targetAfter: 5 * gasPerTx, 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 */))), @@ -451,7 +460,7 @@ func TestGasAccounting(t *testing.T) { e, chain, wallet := sut.Executor, sut.chain, sut.wallet for i, step := range steps { - hooks.Target = step.target + hooks.Target = step.targetAfter txs := make(types.Transactions, step.numTxs) for i := range txs { From 16badd0da6be31452b0253ed40c30839b32a3fe4 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 9 Dec 2025 10:56:56 -0500 Subject: [PATCH 18/35] Add genesis block target override --- blocks/blockstest/blocks.go | 18 +++++++++++++++++- saexec/saexec_test.go | 2 +- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go index fa952bd7..55f0744f 100644 --- a/blocks/blockstest/blocks.go +++ b/blocks/blockstest/blocks.go @@ -14,6 +14,7 @@ import ( "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" @@ -114,13 +115,21 @@ 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, 1, 0), time.Time{}, new(big.Int), nil, b.SettledStateRoot()), "%T.MarkExecuted()", b) + require.NoErrorf(tb, b.MarkExecuted(db, gastime.New(gen.Timestamp, conf.gasTarget(), 0), time.Time{}, new(big.Int), nil, b.SettledStateRoot()), "%T.MarkExecuted()", b) require.NoErrorf(tb, b.MarkSynchronous(), "%T.MarkSynchronous()", b) return b } type genesisConfig struct { tdbConfig *triedb.Config + target gas.Gas +} + +func (gc *genesisConfig) gasTarget() gas.Gas { + if gc.target == 0 { + return 1 + } + return gc.target } // A GenesisOption configures [NewGenesis]. @@ -132,3 +141,10 @@ func WithTrieDBConfig(tc *triedb.Config) GenesisOption { gc.tdbConfig = tc }) } + +// WithGasTarget overrides the gas target used by [NewGenesis]. +func WithGasTarget(target gas.Gas) GenesisOption { + return options.Func[genesisConfig](func(gc *genesisConfig) { + gc.target = target + }) +} diff --git a/saexec/saexec_test.go b/saexec/saexec_test.go index 1c43a483..228480d2 100644 --- a/saexec/saexec_test.go +++ b/saexec/saexec_test.go @@ -80,7 +80,7 @@ func newSUT(tb testing.TB, hooks hook.Points) (context.Context, SUT) { wallet := saetest.NewUNSAFEWallet(tb, 1, types.LatestSigner(config)) alloc := saetest.MaxAllocFor(wallet.Addresses()...) - genesis := blockstest.NewGenesis(tb, db, config, alloc, blockstest.WithTrieDBConfig(tdbConfig)) + genesis := blockstest.NewGenesis(tb, db, config, alloc, blockstest.WithTrieDBConfig(tdbConfig), blockstest.WithGasTarget(1e6)) opts := blockstest.WithBlockOptions( blockstest.WithLogger(logger), From 8a8b9e2fb04c24eba70f9a970fd41371f4353f4e Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Tue, 9 Dec 2025 13:41:05 -0500 Subject: [PATCH 19/35] address comments --- gastime/acp176.go | 17 ++++++++--------- gastime/acp176_test.go | 17 +++++++++-------- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/gastime/acp176.go b/gastime/acp176.go index 5320ae40..1387efd2 100644 --- a/gastime/acp176.go +++ b/gastime/acp176.go @@ -1,7 +1,6 @@ // Copyright (C) 2025, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. -// Package gastime measures time based on the consumption of gas. package gastime import ( @@ -14,20 +13,20 @@ import ( // BeforeBlock is intended to be called before processing a block, with the // timestamp sourced from [hook.Points] and [types.Header]. -func BeforeBlock(clock *Time, pts hook.Points, h *types.Header) { - clock.FastForwardTo( +func (tm *Time) BeforeBlock(hooks hook.Points, h *types.Header) { + tm.FastForwardTo( h.Time, - pts.SubSecondBlockTime(clock.Rate(), h), + hooks.SubSecondBlockTime(tm.Rate(), h), ) } // AfterBlock is intended to be called after processing a block, with the target // sourced from [hook.Points] and [types.Header]. -func AfterBlock(clock *Time, used gas.Gas, pts hook.Points, h *types.Header) error { - clock.Tick(used) - target := pts.GasTargetAfter(h) - if err := clock.SetTarget(target); err != nil { - return fmt.Errorf("%T.SetTarget() after block: %w", clock, err) +func (tm *Time) AfterBlock(used gas.Gas, hooks hook.Points, h *types.Header) error { + tm.Tick(used) + target := hooks.GasTargetAfter(h) + if err := tm.SetTarget(target); err != nil { + return fmt.Errorf("%T.SetTarget() after block: %w", tm, err) } return nil } diff --git a/gastime/acp176_test.go b/gastime/acp176_test.go index 8bb2fe98..4bbea16b 100644 --- a/gastime/acp176_test.go +++ b/gastime/acp176_test.go @@ -36,7 +36,7 @@ func TestTargetUpdateTiming(t *testing.T) { } initialPrice := tm.Price() - BeforeBlock(tm, hook, header) + tm.BeforeBlock(hook, header) assert.Equal(t, newTime, tm.Unix(), "Unix time advanced by BeforeBlock()") assert.Equal(t, initialTarget, tm.Target(), "Target not changed by BeforeBlock()") // While the price technically could remain the same, being more strict @@ -52,7 +52,7 @@ func TestTargetUpdateTiming(t *testing.T) { expectedEndTime = newTime + secondsOfGasUsed ) used := initialRate * secondsOfGasUsed - require.NoError(t, AfterBlock(tm, used, hook, header), "AfterBlock()") + require.NoError(t, tm.AfterBlock(used, hook, header), "AfterBlock()") assert.Equal(t, expectedEndTime, tm.Unix(), "Unix time advanced by AfterBlock()") assert.Equal(t, newTarget, tm.Target(), "Target updated by AfterBlock()") // While the price technically could remain the same, being more strict @@ -73,7 +73,6 @@ func FuzzWorstCasePrice(f *testing.F) { worstcase := New(initTimestamp, gas.Gas(initTarget), gas.Gas(initExcess)) actual := New(initTimestamp, gas.Gas(initTarget), gas.Gas(initExcess)) - require.LessOrEqual(t, actual.Price(), worstcase.Price()) blocks := []struct { time uint64 @@ -124,13 +123,15 @@ func FuzzWorstCasePrice(f *testing.F) { SubSecondTime: block.timeFrac, } - BeforeBlock(worstcase, hook, header) - BeforeBlock(actual, hook, header) + worstcase.BeforeBlock(hook, header) + actual.BeforeBlock(hook, header) - require.LessOrEqual(t, actual.Price(), worstcase.Price()) + require.LessOrEqualf(t, actual.Price(), worstcase.Price(), "actual <= worst-case %T.Price()", actual) - AfterBlock(worstcase, block.limit, hook, header) - AfterBlock(actual, block.used, hook, header) + // The crux of this test lies in the maintaining of this inequality + // through the use of `limit` instead of `used` + worstcase.AfterBlock(block.limit, hook, header) + actual.AfterBlock(block.used, hook, header) } }) } From 9a0d4b091772209c6487691b47a00b47101e3b6c Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 11:38:12 -0500 Subject: [PATCH 20/35] wip --- saexec/execution.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/saexec/execution.go b/saexec/execution.go index d82055cb..fb413fca 100644 --- a/saexec/execution.go +++ b/saexec/execution.go @@ -104,7 +104,7 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { } gasClock := parent.ExecutedByGasTime().Clone() - gastime.BeforeBlock(gasClock, e.hooks, b.Header()) + gasClock.BeforeBlock(gasClock, e.hooks, b.Header()) perTxClock := gasClock.Time.Clone() rules := e.chainConfig.Rules(b.Number(), true /*isMerge*/, b.BuildTime()) From 537bc52bfe1bdf1186a40ff00be85f78e206273f Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 11:40:04 -0500 Subject: [PATCH 21/35] re-push execution file --- saexec/execution.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/saexec/execution.go b/saexec/execution.go index d82055cb..e25ad79d 100644 --- a/saexec/execution.go +++ b/saexec/execution.go @@ -19,7 +19,6 @@ import ( "go.uber.org/zap" "github.com/ava-labs/strevm/blocks" - "github.com/ava-labs/strevm/gastime" ) var errExecutorClosed = errors.New("saexec.Executor closed") @@ -104,11 +103,11 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { } gasClock := parent.ExecutedByGasTime().Clone() - gastime.BeforeBlock(gasClock, e.hooks, b.Header()) + gasClock.BeforeBlock(e.hooks, b.Header()) perTxClock := gasClock.Time.Clone() rules := e.chainConfig.Rules(b.Number(), true /*isMerge*/, b.BuildTime()) - if err := e.hooks.BeforeBlock(rules, stateDB, b.EthBlock()); err != nil { + if err := e.hooks.BeforeExecutingBlock(rules, stateDB, b.EthBlock()); err != nil { return fmt.Errorf("before-block hook: %v", err) } @@ -163,9 +162,9 @@ func (e *Executor) execute(b *blocks.Block, logger logging.Logger) error { // to access them before the end of the block. receipts[ti] = receipt } - e.hooks.AfterBlock(stateDB, b.EthBlock(), receipts) + e.hooks.AfterExecutingBlock(stateDB, b.EthBlock(), receipts) endTime := time.Now() - if err := gastime.AfterBlock(gasClock, blockGasConsumed, e.hooks, b.Header()); err != nil { + if err := gasClock.AfterBlock(blockGasConsumed, e.hooks, b.Header()); err != nil { return fmt.Errorf("after-block gas time update: %w", err) } From 02cbc858a2e2e1a740a9c75f6f91c9adad80954f Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 12:31:22 -0500 Subject: [PATCH 22/35] nits --- worstcase/state.go | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index 3e6c9750..9cb7db59 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -25,7 +25,7 @@ import ( // A State assumes that every transaction will consume its stated // gas limit, tracking worst-case gas costs under this assumption. type State struct { - pts hook.Points + hooks hook.Points config *params.ChainConfig db *state.StateDB @@ -49,13 +49,13 @@ type State struct { // // [State.StartBlock] MUST be called before the first call to [State.Include]. func NewState( - pts hook.Points, + hooks hook.Points, config *params.ChainConfig, db *state.StateDB, fromExecTime *gastime.Time, ) *State { return &State{ - pts: pts, + hooks: hooks, config: config, db: db, clock: fromExecTime, @@ -83,7 +83,7 @@ func (s *State) StartBlock(hdr *types.Header) error { } } - s.clock.BeforeBlock(s.pts, hdr) + s.clock.BeforeBlock(s.hooks, hdr) s.blockSize = 0 const ( @@ -126,7 +126,10 @@ func (s *State) BaseFee() *uint256.Int { return s.baseFee } -type Op = hook.Op +type ( + Account = hook.Account + Op = hook.Op +) var ( errGasFeeCapOverflow = errors.New("GasFeeCap() overflows uint256") @@ -161,23 +164,21 @@ func (s *State) ApplyTx(tx *types.Transaction) error { return fmt.Errorf("determining sender: %w", err) } - gasPrice, overflow := uint256.FromBig(tx.GasFeeCap()) - if overflow { + var gasPrice uint256.Int + if overflow := gasPrice.SetFromBig(tx.GasFeeCap()); overflow { return errGasFeeCapOverflow } - - amount, overflow := uint256.FromBig(tx.Cost()) - if overflow { + var amount uint256.Int + if overflow := amount.SetFromBig(tx.Cost()); overflow { return errCostOverflow } - return s.Apply(Op{ Gas: gas.Gas(tx.Gas()), - GasPrice: *gasPrice, + GasPrice: gasPrice, From: map[common.Address]hook.Account{ from: { Nonce: tx.Nonce(), - Amount: *amount, + Amount: amount, }, }, // To is not populated here because this transaction may revert. @@ -242,7 +243,7 @@ func (s *State) Apply(o Op) error { // FinishBlock advances the includer's [gastime.Time] to account for all // included operations in the current block. func (s *State) FinishBlock() error { - if err := s.clock.AfterBlock(s.blockSize, s.pts, s.curr); err != nil { + if err := s.clock.AfterBlock(s.blockSize, s.hooks, s.curr); err != nil { return fmt.Errorf("finishing block gas time update: %w", err) } s.qSize += s.blockSize From ad66e078c53a86a6b8fd020b48b0ab66c25aa35c Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 12:33:20 -0500 Subject: [PATCH 23/35] Default to max gas target --- blocks/blockstest/blocks.go | 11 ++++++----- saexec/saexec_test.go | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/blocks/blockstest/blocks.go b/blocks/blockstest/blocks.go index 55f0744f..abcad879 100644 --- a/blocks/blockstest/blocks.go +++ b/blocks/blockstest/blocks.go @@ -8,6 +8,7 @@ package blockstest import ( + "math" "math/big" "slices" "testing" @@ -122,14 +123,14 @@ func NewGenesis(tb testing.TB, db ethdb.Database, config *params.ChainConfig, al type genesisConfig struct { tdbConfig *triedb.Config - target gas.Gas + target *gas.Gas } func (gc *genesisConfig) gasTarget() gas.Gas { - if gc.target == 0 { - return 1 + if gc.target == nil { + return math.MaxUint64 } - return gc.target + return *gc.target } // A GenesisOption configures [NewGenesis]. @@ -145,6 +146,6 @@ func WithTrieDBConfig(tc *triedb.Config) GenesisOption { // WithGasTarget overrides the gas target used by [NewGenesis]. func WithGasTarget(target gas.Gas) GenesisOption { return options.Func[genesisConfig](func(gc *genesisConfig) { - gc.target = target + gc.target = &target }) } diff --git a/saexec/saexec_test.go b/saexec/saexec_test.go index 228480d2..1c43a483 100644 --- a/saexec/saexec_test.go +++ b/saexec/saexec_test.go @@ -80,7 +80,7 @@ func newSUT(tb testing.TB, hooks hook.Points) (context.Context, SUT) { wallet := saetest.NewUNSAFEWallet(tb, 1, types.LatestSigner(config)) alloc := saetest.MaxAllocFor(wallet.Addresses()...) - genesis := blockstest.NewGenesis(tb, db, config, alloc, blockstest.WithTrieDBConfig(tdbConfig), blockstest.WithGasTarget(1e6)) + genesis := blockstest.NewGenesis(tb, db, config, alloc, blockstest.WithTrieDBConfig(tdbConfig)) opts := blockstest.WithBlockOptions( blockstest.WithLogger(logger), From 19569753290b4c37c711d38d6b37b6fe55dcda62 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 13:52:56 -0500 Subject: [PATCH 24/35] wip --- worstcase/state.go | 6 +- worstcase/state_test.go | 341 ++++++++++++++++++++++++++-------------- 2 files changed, 228 insertions(+), 119 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index 9cb7db59..10be3735 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -62,6 +62,8 @@ func NewState( } } +const rateToMaxBlockSize = saeparams.Tau * saeparams.Lambda + var ( errNonConsecutiveBlocks = errors.New("non-consecutive block numbers") errQueueFull = errors.New("queue full") @@ -95,10 +97,10 @@ func (s *State) StartBlock(hdr *types.Header) error { // maxBlockSize = maxRate * Tau * Lambda // maxQSizeInStart = maxQSizeMultiplier * maxBlockSize // maxQSizeInFinish = maxQSizeInStart + maxBlockSize - maxRate gas.Gas = math.MaxUint64 / saeparams.Tau / saeparams.Lambda / (maxQSizeMultiplier + 1) + maxRate gas.Gas = math.MaxUint64 / rateToMaxBlockSize / (maxQSizeMultiplier + 1) ) r := min(s.clock.Rate(), maxRate) - s.maxBlockSize = r * saeparams.Tau * saeparams.Lambda + s.maxBlockSize = r * rateToMaxBlockSize if maxQSize := maxQSizeMultiplier * s.maxBlockSize; s.qSize > maxQSize { return fmt.Errorf("%w: current size %d exceeds maximum size %d", errQueueFull, s.qSize, maxQSize) } diff --git a/worstcase/state_test.go b/worstcase/state_test.go index f3edee2d..c849cc45 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -1,6 +1,25 @@ package worstcase -/* +import ( + "math" + "math/big" + "testing" + + "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/crypto" + "github.com/ava-labs/libevm/params" + "github.com/ava-labs/strevm/gastime" + "github.com/ava-labs/strevm/hook/hookstest" + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" +) + +const initialGasTarget = 1_000_000 + func newDB(tb testing.TB) *state.StateDB { tb.Helper() db, err := state.New(types.EmptyRootHash, state.NewDatabase(rawdb.NewMemoryDatabase()), nil) @@ -8,141 +27,229 @@ func newDB(tb testing.TB) *state.StateDB { return db } -func newTxIncluder(tb testing.TB) (*State, *state.StateDB) { +type SUT struct { + *State + DB *state.StateDB + Hooks *hookstest.Stub +} + +func newSUT(tb testing.TB) SUT { tb.Helper() db := newDB(tb) - return NewTxIncluder( - db, params.MergedTestChainConfig, - gastime.New(0, 1e6, 0), - 5, 2, - ), db + hooks := &hookstest.Stub{} + return SUT{ + State: NewState( + hooks, + params.MergedTestChainConfig, + db, + gastime.New(0, initialGasTarget, 0), + ), + DB: db, + Hooks: hooks, + } } -func TestNonContextualTransactionRejection(t *testing.T) { +const targetToMaxBlockSize = gastime.TargetToRate * rateToMaxBlockSize + +func TestState(t *testing.T) { key, err := crypto.GenerateKey() require.NoError(t, err, "libevm/crypto.GenerateKey()") eoa := crypto.PubkeyToAddress(key.PublicKey) - tests := []struct { - name string - stateSetup func(*state.StateDB) - tx types.TxData - wantErrIs error + state := newSUT(t) + state.DB.SetBalance(eoa, uint256.NewInt(math.MaxUint64)) + + type op struct { + name string + tx types.TxData + op *Op + wantErr error + } + blocks := []struct { + hooks *hookstest.Stub + wantGasLimit uint64 + wantBaseFee *uint256.Int + ops []op }{ { - name: "nil_err", - stateSetup: func(db *state.StateDB) { - db.SetBalance(eoa, uint256.NewInt(params.TxGas)) - }, - tx: &types.LegacyTx{ - Nonce: 0, - Gas: params.TxGas, - GasPrice: big.NewInt(1), - To: &common.Address{}, - }, - wantErrIs: nil, - }, - { - name: "nonce_too_low", - stateSetup: func(db *state.StateDB) { - db.SetNonce(eoa, 1) - }, - tx: &types.LegacyTx{ - Nonce: 0, - Gas: params.TxGas, - To: &common.Address{}, - }, - wantErrIs: core.ErrNonceTooLow, - }, - { - name: "nonce_too_high", - stateSetup: func(db *state.StateDB) { - db.SetNonce(eoa, 1) - }, - tx: &types.LegacyTx{ - Nonce: 2, - Gas: params.TxGas, - To: &common.Address{}, - }, - wantErrIs: core.ErrNonceTooHigh, - }, - { - name: "exceed_max_init_code_size", - tx: &types.LegacyTx{ - To: nil, // i.e. contract creation - Data: make([]byte, params.MaxInitCodeSize+1), - Gas: 250_000, // cover intrinsic gas - }, - wantErrIs: core.ErrMaxInitCodeSizeExceeded, - }, - { - name: "not_cover_intrinsic_gas", - tx: &types.LegacyTx{ - Gas: params.TxGas - 1, - To: &common.Address{}, - }, - wantErrIs: core.ErrIntrinsicGas, - }, - { - name: "gas_price_too_low", - tx: &types.LegacyTx{ - Gas: params.TxGas, - GasPrice: big.NewInt(0), - To: &common.Address{}, - }, - wantErrIs: core.ErrFeeCapTooLow, - }, - { - name: "insufficient_funds_for_gas", - stateSetup: func(db *state.StateDB) { - db.SetBalance(eoa, uint256.NewInt(params.TxGas-1)) - }, - tx: &types.LegacyTx{ - Gas: params.TxGas, - GasPrice: big.NewInt(1), - To: &common.Address{}, - }, - wantErrIs: core.ErrInsufficientFunds, - }, - { - name: "insufficient_funds_for_gas_and_value", - stateSetup: func(db *state.StateDB) { - db.SetBalance(eoa, uint256.NewInt(params.TxGas)) - }, - tx: &types.LegacyTx{ - Gas: params.TxGas, - GasPrice: big.NewInt(1), - Value: big.NewInt(1), - To: &common.Address{}, - }, - wantErrIs: core.ErrInsufficientFunds, - }, - { - name: "blob_tx_not_supported", - tx: &types.BlobTx{ - Gas: params.TxGas, + wantGasLimit: initialGasTarget * targetToMaxBlockSize, + wantBaseFee: uint256.NewInt(1), + ops: []op{ + { + name: "not_cover_intrinsic_gas", + tx: &types.LegacyTx{ + To: &common.Address{}, + Gas: params.TxGas - 1, + }, + wantErr: core.ErrIntrinsicGas, + }, + { + name: "exceed_max_init_code_size", + tx: &types.LegacyTx{ + Gas: 250_000, // cover intrinsic gas + To: nil, // contract creation + Data: make([]byte, params.MaxInitCodeSize+1), + }, + wantErr: core.ErrMaxInitCodeSizeExceeded, + }, + { + name: "blob_tx_not_supported", + tx: &types.BlobTx{ + Gas: params.TxGas, + }, + wantErr: core.ErrTxTypeNotSupported, + }, + { + name: "cost_overflow", + tx: &types.LegacyTx{ + Nonce: 0, + GasPrice: new(big.Int).Lsh(big.NewInt(1), 256-1), + Gas: params.TxGas, + To: &eoa, + Value: big.NewInt(10), + }, + wantErr: errCostOverflow, + }, + { + name: "gas_price_too_low", + tx: &types.LegacyTx{ + GasPrice: big.NewInt(0), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrFeeCapTooLow, + }, + { + name: "nonce_too_high", + tx: &types.LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrNonceTooHigh, + }, + { + name: "include_transfer", + tx: &types.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(1), + Gas: params.TxGas, + To: &eoa, + Value: big.NewInt(10), + }, + wantErr: nil, + }, }, - wantErrIs: core.ErrTxTypeNotSupported, }, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - inc, db := newTxIncluder(t) - require.NoError(t, inc.StartBlock(&types.Header{ - Number: big.NewInt(0), - }, 1e6), "StartBlock(0, t=0)") - if tt.stateSetup != nil { - tt.stateSetup(db) + for i, block := range blocks { + if block.hooks != nil { + *state.Hooks = *block.hooks + } + header := &types.Header{ + Number: big.NewInt(int64(i)), + } + require.NoErrorf(t, state.StartBlock(header), "StartBlock(%d)", i) + require.Equalf(t, block.wantBaseFee, state.BaseFee(), "base fee after StartBlock(%d)", i) + require.Equalf(t, block.wantGasLimit, state.GasLimit(), "gas limit after StartBlock(%d)", i) + + for _, op := range block.ops { + if op.tx != nil { + tx := types.MustSignNewTx(key, types.NewCancunSigner(state.config.ChainID), op.tx) + gotErr := state.ApplyTx(tx) + require.ErrorIsf(t, gotErr, op.wantErr, "ApplyTx(%s) error", op.name) } - tx := types.MustSignNewTx(key, types.NewCancunSigner(inc.config.ChainID), tt.tx) - require.ErrorIs(t, inc.ApplyTx(tx), tt.wantErrIs) - }) + if op.op != nil { + gotErr := state.Apply(*op.op) + require.ErrorIsf(t, gotErr, op.wantErr, "Apply(%s) error", op.name) + } + } + + require.NoError(t, state.FinishBlock(), "FinishBlock()") } } +// func TestNonContextualTransactionRejection(t *testing.T) { +// key, err := crypto.GenerateKey() +// require.NoError(t, err, "libevm/crypto.GenerateKey()") +// eoa := crypto.PubkeyToAddress(key.PublicKey) + +// tests := []struct { +// name string +// stateSetup func(*state.StateDB) +// tx types.TxData +// wantErrIs error +// }{ +// { +// name: "nil_err", +// stateSetup: func(db *state.StateDB) { +// db.SetBalance(eoa, uint256.NewInt(params.TxGas)) +// }, +// tx: &types.LegacyTx{ +// Nonce: 0, +// Gas: params.TxGas, +// GasPrice: big.NewInt(1), +// To: &common.Address{}, +// }, +// wantErrIs: nil, +// }, +// { +// name: "nonce_too_low", +// stateSetup: func(db *state.StateDB) { +// db.SetNonce(eoa, 1) +// }, +// tx: &types.LegacyTx{ +// Nonce: 0, +// Gas: params.TxGas, +// To: &common.Address{}, +// }, +// wantErrIs: core.ErrNonceTooLow, +// }, +// { +// name: "insufficient_funds_for_gas", +// stateSetup: func(db *state.StateDB) { +// db.SetBalance(eoa, uint256.NewInt(params.TxGas-1)) +// }, +// tx: &types.LegacyTx{ +// Gas: params.TxGas, +// GasPrice: big.NewInt(1), +// To: &common.Address{}, +// }, +// wantErrIs: core.ErrInsufficientFunds, +// }, +// { +// name: "insufficient_funds_for_gas_and_value", +// stateSetup: func(db *state.StateDB) { +// db.SetBalance(eoa, uint256.NewInt(params.TxGas)) +// }, +// tx: &types.LegacyTx{ +// Gas: params.TxGas, +// GasPrice: big.NewInt(1), +// Value: big.NewInt(1), +// To: &common.Address{}, +// }, +// wantErrIs: core.ErrInsufficientFunds, +// }, +// } + +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// inc, db := newTxIncluder(t) +// require.NoError(t, inc.StartBlock(&types.Header{ +// Number: big.NewInt(0), +// }, 1e6), "StartBlock(0, t=0)") +// if tt.stateSetup != nil { +// tt.stateSetup(db) +// } +// tx := types.MustSignNewTx(key, types.NewCancunSigner(inc.config.ChainID), tt.tx) +// require.ErrorIs(t, inc.ApplyTx(tx), tt.wantErrIs) +// }) +// } +// } + func TestContextualTransactionRejection(t *testing.T) { // TODO(arr4n) test rejection of transactions in the context of other // transactions, e.g. exhausting balance, gas price increasing, etc. } -*/ From 67171b5c431644cdced4ef55894d81ec37cb53d3 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 13:54:45 -0500 Subject: [PATCH 25/35] lint --- gastime/acp176.go | 1 + gastime/acp176_test.go | 7 ++++--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/gastime/acp176.go b/gastime/acp176.go index 1387efd2..7c807915 100644 --- a/gastime/acp176.go +++ b/gastime/acp176.go @@ -8,6 +8,7 @@ import ( "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/ava-labs/libevm/core/types" + "github.com/ava-labs/strevm/hook" ) diff --git a/gastime/acp176_test.go b/gastime/acp176_test.go index 4bbea16b..b975ae66 100644 --- a/gastime/acp176_test.go +++ b/gastime/acp176_test.go @@ -8,9 +8,10 @@ import ( "github.com/ava-labs/avalanchego/vms/components/gas" "github.com/ava-labs/libevm/core/types" - "github.com/ava-labs/strevm/hook/hookstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/ava-labs/strevm/hook/hookstest" ) // TestTargetUpdateTiming verifies that the gas target is modified in AfterBlock @@ -130,8 +131,8 @@ func FuzzWorstCasePrice(f *testing.F) { // The crux of this test lies in the maintaining of this inequality // through the use of `limit` instead of `used` - worstcase.AfterBlock(block.limit, hook, header) - actual.AfterBlock(block.used, hook, header) + require.NoError(t, worstcase.AfterBlock(block.limit, hook, header), "worstcase.AfterBlock()") + require.NoError(t, actual.AfterBlock(block.used, hook, header), "actual.AfterBlock()") } }) } From f0df77c0ea7f3146933b730bbdb5361ba39cafbe Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 16:09:11 -0500 Subject: [PATCH 26/35] wip --- worstcase/state.go | 13 ++- worstcase/state_test.go | 238 ++++++++++++++++++++++++++-------------- 2 files changed, 164 insertions(+), 87 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index 10be3735..b992d140 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -1,6 +1,6 @@ -// Package worstcase is a pessimist, always seeing the glass as half empty. But -// where others see full glasses and opportunities, package worstcase sees DoS -// vulnerabilities. +// Copyright (C) ((20\d\d\-2025)|(2025)), Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + package worstcase import ( @@ -16,10 +16,11 @@ import ( "github.com/ava-labs/libevm/core/txpool" "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/params" + "github.com/holiman/uint256" + "github.com/ava-labs/strevm/gastime" "github.com/ava-labs/strevm/hook" saeparams "github.com/ava-labs/strevm/params" - "github.com/holiman/uint256" ) // A State assumes that every transaction will consume its stated @@ -147,6 +148,9 @@ var ( // // If the transaction can not be applied, an error is returned and the state is // not modified. +// +// TODO: Consider refactoring into a standalone function to convert transactions +// into Ops, rather than Applying internally. func (s *State) ApplyTx(tx *types.Transaction) error { opts := &txpool.ValidationOptions{ Config: s.config, @@ -168,6 +172,7 @@ func (s *State) ApplyTx(tx *types.Transaction) error { var gasPrice uint256.Int if overflow := gasPrice.SetFromBig(tx.GasFeeCap()); overflow { + // This should be unreachable due to the txpool validation. return errGasFeeCapOverflow } var amount uint256.Int diff --git a/worstcase/state_test.go b/worstcase/state_test.go index c849cc45..759bcf96 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -36,7 +36,9 @@ type SUT struct { func newSUT(tb testing.TB) SUT { tb.Helper() db := newDB(tb) - hooks := &hookstest.Stub{} + hooks := &hookstest.Stub{ + Target: initialGasTarget, + } return SUT{ State: NewState( hooks, @@ -59,6 +61,11 @@ func TestState(t *testing.T) { state := newSUT(t) state.DB.SetBalance(eoa, uint256.NewInt(math.MaxUint64)) + eoaMaxNonce := common.Address{0x00} + state.DB.SetNonce(eoaMaxNonce, math.MaxUint64) + state.DB.SetBalance(eoaMaxNonce, uint256.NewInt(math.MaxUint64)) + + eoaNoBalance := common.Address{0x01} type op struct { name string tx types.TxData @@ -129,6 +136,26 @@ func TestState(t *testing.T) { }, wantErr: core.ErrNonceTooHigh, }, + { + name: "insufficient_funds_for_gas", + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: new(big.Int).SetUint64(math.MaxUint64), + To: &common.Address{}, + Value: big.NewInt(0), + }, + wantErr: core.ErrInsufficientFunds, + }, + { + name: "insufficient_funds_for_value", + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: big.NewInt(1), + To: &common.Address{}, + Value: new(big.Int).SetUint64(math.MaxUint64), + }, + wantErr: core.ErrInsufficientFunds, + }, { name: "include_transfer", tx: &types.LegacyTx{ @@ -140,10 +167,120 @@ func TestState(t *testing.T) { }, wantErr: nil, }, + { + name: "nonce_too_low", + tx: &types.LegacyTx{ + Nonce: 0, + GasPrice: big.NewInt(1), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrNonceTooLow, + }, + { + name: "block_too_full", + tx: &types.LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: initialGasTarget*targetToMaxBlockSize - params.TxGas + 1, + To: &common.Address{}, + }, + wantErr: ErrBlockTooFull, + }, + { + name: "full_block", + tx: &types.LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: initialGasTarget*targetToMaxBlockSize - params.TxGas, + To: &common.Address{}, + }, + wantErr: nil, + }, + }, + }, + { + wantGasLimit: initialGasTarget * targetToMaxBlockSize, + wantBaseFee: uint256.NewInt(1), + ops: []op{ + { + name: "max_nonce", + op: &Op{ + Gas: 1, + GasPrice: *uint256.NewInt(1), + From: map[common.Address]Account{ + eoaMaxNonce: { + Nonce: math.MaxUint64, + Amount: *uint256.NewInt(1), + }, + }, + }, + wantErr: core.ErrNonceMax, + }, + { + name: "import", + op: &Op{ + Gas: 1, + GasPrice: *uint256.NewInt(1), + From: map[common.Address]Account{ + eoa: { + Nonce: 2, + Amount: *uint256.NewInt(1), + }, + }, + To: map[common.Address]uint256.Int{ + eoaNoBalance: *uint256.NewInt(10), + }, + }, + wantErr: nil, + }, + { + name: "imported_insufficient_funds", + op: &Op{ + Gas: initialGasTarget*targetToMaxBlockSize - 1, + GasPrice: *uint256.NewInt(1), + From: map[common.Address]Account{ + eoaNoBalance: { + Nonce: 0, + Amount: *uint256.NewInt(11), + }, + }, + }, + wantErr: core.ErrInsufficientFunds, + }, + { + name: "spend_imported_funds", + op: &Op{ + Gas: initialGasTarget*targetToMaxBlockSize - 1, + GasPrice: *uint256.NewInt(1), + From: map[common.Address]Account{ + eoaNoBalance: { + Nonce: 0, + Amount: *uint256.NewInt(10), + }, + }, + }, + wantErr: nil, + }, + }, + }, + { + wantGasLimit: initialGasTarget * targetToMaxBlockSize, + wantBaseFee: uint256.NewInt(1), + ops: []op{ + { + name: "full_block", + tx: &types.LegacyTx{ + Nonce: 3, + GasPrice: big.NewInt(1), + Gas: initialGasTarget * targetToMaxBlockSize, + To: &common.Address{}, + }, + wantErr: nil, + }, }, }, } - for i, block := range blocks { if block.hooks != nil { *state.Hooks = *block.hooks @@ -169,87 +306,22 @@ func TestState(t *testing.T) { require.NoError(t, state.FinishBlock(), "FinishBlock()") } -} - -// func TestNonContextualTransactionRejection(t *testing.T) { -// key, err := crypto.GenerateKey() -// require.NoError(t, err, "libevm/crypto.GenerateKey()") -// eoa := crypto.PubkeyToAddress(key.PublicKey) -// tests := []struct { -// name string -// stateSetup func(*state.StateDB) -// tx types.TxData -// wantErrIs error -// }{ -// { -// name: "nil_err", -// stateSetup: func(db *state.StateDB) { -// db.SetBalance(eoa, uint256.NewInt(params.TxGas)) -// }, -// tx: &types.LegacyTx{ -// Nonce: 0, -// Gas: params.TxGas, -// GasPrice: big.NewInt(1), -// To: &common.Address{}, -// }, -// wantErrIs: nil, -// }, -// { -// name: "nonce_too_low", -// stateSetup: func(db *state.StateDB) { -// db.SetNonce(eoa, 1) -// }, -// tx: &types.LegacyTx{ -// Nonce: 0, -// Gas: params.TxGas, -// To: &common.Address{}, -// }, -// wantErrIs: core.ErrNonceTooLow, -// }, -// { -// name: "insufficient_funds_for_gas", -// stateSetup: func(db *state.StateDB) { -// db.SetBalance(eoa, uint256.NewInt(params.TxGas-1)) -// }, -// tx: &types.LegacyTx{ -// Gas: params.TxGas, -// GasPrice: big.NewInt(1), -// To: &common.Address{}, -// }, -// wantErrIs: core.ErrInsufficientFunds, -// }, -// { -// name: "insufficient_funds_for_gas_and_value", -// stateSetup: func(db *state.StateDB) { -// db.SetBalance(eoa, uint256.NewInt(params.TxGas)) -// }, -// tx: &types.LegacyTx{ -// Gas: params.TxGas, -// GasPrice: big.NewInt(1), -// Value: big.NewInt(1), -// To: &common.Address{}, -// }, -// wantErrIs: core.ErrInsufficientFunds, -// }, -// } - -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// inc, db := newTxIncluder(t) -// require.NoError(t, inc.StartBlock(&types.Header{ -// Number: big.NewInt(0), -// }, 1e6), "StartBlock(0, t=0)") -// if tt.stateSetup != nil { -// tt.stateSetup(db) -// } -// tx := types.MustSignNewTx(key, types.NewCancunSigner(inc.config.ChainID), tt.tx) -// require.ErrorIs(t, inc.ApplyTx(tx), tt.wantErrIs) -// }) -// } -// } + // Test that nonconsecutive blocks are disallowed. + { + header := &types.Header{ + Number: big.NewInt(int64(len(blocks) + 1)), + } + err := state.StartBlock(header) + require.ErrorIs(t, err, errNonConsecutiveBlocks, "nonconsecutive StartBlock()") + } -func TestContextualTransactionRejection(t *testing.T) { - // TODO(arr4n) test rejection of transactions in the context of other - // transactions, e.g. exhausting balance, gas price increasing, etc. + // Test that starting a new block fails if the queue is full. + { + header := &types.Header{ + Number: big.NewInt(int64(len(blocks))), + } + err := state.StartBlock(header) + require.ErrorIsf(t, err, errQueueFull, "StartBlock(%d)", len(blocks)) + } } From 2e71b8e656ec7c7184cce31f89b4e3cd81ab3c80 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 16:45:22 -0500 Subject: [PATCH 27/35] wip --- worstcase/state.go | 27 ++++++++++++++++----------- worstcase/state_test.go | 3 +++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index b992d140..f370e31e 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -1,6 +1,9 @@ -// Copyright (C) ((20\d\d\-2025)|(2025)), Ava Labs, Inc. All rights reserved. +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. // See the file LICENSE for licensing terms. +// Package worstcase provides the worst-case balance and nonce tracking needed +// to safely include transactions that are guaranteed to be valid. + package worstcase import ( @@ -23,8 +26,15 @@ import ( saeparams "github.com/ava-labs/strevm/params" ) -// A State assumes that every transaction will consume its stated -// gas limit, tracking worst-case gas costs under this assumption. +// State tracks the worst-case gas price and account state as operations are +// executed. +// +// Usage of the [State] should follow the pattern: +// 1. [State.StartBlock] for each block to be included. +// 2. [State.GasLimit] and [State.BaseFee] to query the block's parameters. +// 3. [State.ApplyTx] or [State.Apply] for each transaction or operation to +// include in the block. +// 4. [State.FinishBlock] to finalize the block's gas time. type State struct { hooks hook.Points config *params.ChainConfig @@ -40,15 +50,13 @@ type State struct { signer types.Signer } -// NewState constructs a new includer. +// NewState constructs a new worst-case state. // // The [state.StateDB] MUST be opened at the state immediately following the -// last-executed block upon which the includer is building. Similarly, the +// last-executed block upon which the worst-case state is built. Similarly, the // [gastime.Time] MUST be a clone of the gas clock at the same point. The // StateDB will only be used as a scratchpad for tracking accounts, and will NOT // be committed. -// -// [State.StartBlock] MUST be called before the first call to [State.Include]. func NewState( hooks hook.Points, config *params.ChainConfig, @@ -77,8 +85,6 @@ var ( // be set. // // If the queue is too full to accept another block, [ErrQueueFull] is returned. -// -// This function populates the header's GasLimit and BaseFee fields. func (s *State) StartBlock(hdr *types.Header) error { if c := s.curr; c != nil { if num, next := c.Number.Uint64(), hdr.Number.Uint64(); next != num+1 { @@ -247,8 +253,7 @@ func (s *State) Apply(o Op) error { return nil } -// FinishBlock advances the includer's [gastime.Time] to account for all -// included operations in the current block. +// FinishBlock advances the [gastime.Time] in preparation for the next block. func (s *State) FinishBlock() error { if err := s.clock.AfterBlock(s.blockSize, s.hooks, s.curr); err != nil { return fmt.Errorf("finishing block gas time update: %w", err) diff --git a/worstcase/state_test.go b/worstcase/state_test.go index 759bcf96..1580f747 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -1,3 +1,6 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + package worstcase import ( From 1c7b5b1e4eb835d96da042c5ff33f474f9e64f5b Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 16:47:14 -0500 Subject: [PATCH 28/35] wip --- worstcase/state.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index f370e31e..3ddcb7a1 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -2,8 +2,8 @@ // See the file LICENSE for licensing terms. // Package worstcase provides the worst-case balance and nonce tracking needed -// to safely include transactions that are guaranteed to be valid. - +// to safely include transactions that are guaranteed to be valid during +// execution. package worstcase import ( From cac97ef2b4923d28de35ca0689823764674e7373 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 16:52:59 -0500 Subject: [PATCH 29/35] nits --- hook/hook.go | 4 ++-- worstcase/state.go | 21 +++++++++++++-------- worstcase/state_test.go | 8 ++++---- 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index c616b846..f214c853 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -19,7 +19,7 @@ import ( saeparams "github.com/ava-labs/strevm/params" ) -type Account struct { +type AccountDebit struct { Nonce uint64 Amount uint256.Int } @@ -31,7 +31,7 @@ type Op struct { GasPrice uint256.Int // From specifies the set of accounts and the authorization of funds to be // removed from the accounts. - From map[common.Address]Account + From map[common.Address]AccountDebit // To specifies the amount to increase account balances by. These funds are // not necessarily tied to the funds consumed in the From field. The sum of // the To amounts may even exceed the sum of the From amounts. diff --git a/worstcase/state.go b/worstcase/state.go index 3ddcb7a1..e12d4a0e 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -35,6 +35,7 @@ import ( // 3. [State.ApplyTx] or [State.Apply] for each transaction or operation to // include in the block. // 4. [State.FinishBlock] to finalize the block's gas time. +// 5. Repeat from step 1 for the next block. type State struct { hooks hook.Points config *params.ChainConfig @@ -84,7 +85,7 @@ var ( // It is not necessary for [types.Header.GasLimit] or [types.Header.BaseFee] to // be set. // -// If the queue is too full to accept another block, [ErrQueueFull] is returned. +// If the queue is too full to accept another block, an error is returned. func (s *State) StartBlock(hdr *types.Header) error { if c := s.curr; c != nil { if num, next := c.Number.Uint64(), hdr.Number.Uint64(); next != num+1 { @@ -136,8 +137,11 @@ func (s *State) BaseFee() *uint256.Int { } type ( - Account = hook.Account - Op = hook.Op + // AccountDebit includes an amount that an account should have debited, + // along with the nonce used to debit the account. + AccountDebit = hook.AccountDebit + // Op is an operation that can be applied to a [State]. + Op = hook.Op ) var ( @@ -188,7 +192,7 @@ func (s *State) ApplyTx(tx *types.Transaction) error { return s.Apply(Op{ Gas: gas.Gas(tx.Gas()), GasPrice: gasPrice, - From: map[common.Address]hook.Account{ + From: map[common.Address]hook.AccountDebit{ from: { Nonce: tx.Nonce(), Amount: amount, @@ -208,10 +212,11 @@ var ErrBlockTooFull = errors.New("block too full") // not modified. // // Operations are invalid if: -// - The operation consumes more gas than the block has available. -// - The operation specifies too low of a gas price. -// - The operation is from an account with an incorrect or invalid nonce. -// - The operation is from an account with an insufficient balance. +// +// - The operation consumes more gas than the block has available. +// - The operation specifies too low of a gas price. +// - The operation is from an account with an incorrect or invalid nonce. +// - The operation is from an account with an insufficient balance. func (s *State) Apply(o Op) error { // ----- Gas ----- if o.Gas > s.maxBlockSize-s.blockSize { diff --git a/worstcase/state_test.go b/worstcase/state_test.go index 1580f747..b28f23e0 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -211,7 +211,7 @@ func TestState(t *testing.T) { op: &Op{ Gas: 1, GasPrice: *uint256.NewInt(1), - From: map[common.Address]Account{ + From: map[common.Address]AccountDebit{ eoaMaxNonce: { Nonce: math.MaxUint64, Amount: *uint256.NewInt(1), @@ -225,7 +225,7 @@ func TestState(t *testing.T) { op: &Op{ Gas: 1, GasPrice: *uint256.NewInt(1), - From: map[common.Address]Account{ + From: map[common.Address]AccountDebit{ eoa: { Nonce: 2, Amount: *uint256.NewInt(1), @@ -242,7 +242,7 @@ func TestState(t *testing.T) { op: &Op{ Gas: initialGasTarget*targetToMaxBlockSize - 1, GasPrice: *uint256.NewInt(1), - From: map[common.Address]Account{ + From: map[common.Address]AccountDebit{ eoaNoBalance: { Nonce: 0, Amount: *uint256.NewInt(11), @@ -256,7 +256,7 @@ func TestState(t *testing.T) { op: &Op{ Gas: initialGasTarget*targetToMaxBlockSize - 1, GasPrice: *uint256.NewInt(1), - From: map[common.Address]Account{ + From: map[common.Address]AccountDebit{ eoaNoBalance: { Nonce: 0, Amount: *uint256.NewInt(10), From 3ec85316f64932908aae2ca819fca89764b99b51 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Wed, 10 Dec 2025 16:54:41 -0500 Subject: [PATCH 30/35] lint --- hook/hook.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/hook/hook.go b/hook/hook.go index f214c853..a29915bf 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -19,11 +19,15 @@ import ( saeparams "github.com/ava-labs/strevm/params" ) +// AccountDebit includes an amount that an account should have debited, +// along with the nonce used to debit the account. type AccountDebit struct { Nonce uint64 Amount uint256.Int } +// Op is an operation that can be applied to state during the execution of a +// block. type Op struct { // Gas consumed by this operation Gas gas.Gas From fa76f25ad7f17f7c9a2dd40ba73527995afae8bc Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 11 Dec 2025 12:39:57 -0500 Subject: [PATCH 31/35] lint --- worstcase/state_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/worstcase/state_test.go b/worstcase/state_test.go index b28f23e0..b23686b6 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -15,10 +15,11 @@ import ( "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/crypto" "github.com/ava-labs/libevm/params" - "github.com/ava-labs/strevm/gastime" - "github.com/ava-labs/strevm/hook/hookstest" "github.com/holiman/uint256" "github.com/stretchr/testify/require" + + "github.com/ava-labs/strevm/gastime" + "github.com/ava-labs/strevm/hook/hookstest" ) const initialGasTarget = 1_000_000 From b6ef7f84e9e5c6cf70ab53339d29632f59c0bd0f Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 11 Dec 2025 12:51:13 -0500 Subject: [PATCH 32/35] minor refactor --- worstcase/state.go | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index e12d4a0e..d3b10cbc 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -159,8 +159,7 @@ var ( // If the transaction can not be applied, an error is returned and the state is // not modified. // -// TODO: Consider refactoring into a standalone function to convert transactions -// into Ops, rather than Applying internally. +// TODO: Consider exporting txToOp and expecting users to call Apply directly. func (s *State) ApplyTx(tx *types.Transaction) error { opts := &txpool.ValidationOptions{ Config: s.config, @@ -175,21 +174,28 @@ func (s *State) ApplyTx(tx *types.Transaction) error { return fmt.Errorf("validating transaction: %w", err) } - from, err := types.Sender(s.signer, tx) + op, err := txToOp(s.signer, tx) if err != nil { - return fmt.Errorf("determining sender: %w", err) + return fmt.Errorf("converting transaction to operation: %w", err) + } + return s.Apply(op) +} + +func txToOp(signer types.Signer, tx *types.Transaction) (Op, error) { + from, err := types.Sender(signer, tx) + if err != nil { + return Op{}, fmt.Errorf("determining sender: %w", err) } var gasPrice uint256.Int if overflow := gasPrice.SetFromBig(tx.GasFeeCap()); overflow { - // This should be unreachable due to the txpool validation. - return errGasFeeCapOverflow + return Op{}, errGasFeeCapOverflow } var amount uint256.Int if overflow := amount.SetFromBig(tx.Cost()); overflow { - return errCostOverflow + return Op{}, errCostOverflow } - return s.Apply(Op{ + return Op{ Gas: gas.Gas(tx.Gas()), GasPrice: gasPrice, From: map[common.Address]hook.AccountDebit{ @@ -199,7 +205,7 @@ func (s *State) ApplyTx(tx *types.Transaction) error { }, }, // To is not populated here because this transaction may revert. - }) + }, nil } // ErrBlockTooFull is returned by [State.ApplyTx] and [State.Apply] if inclusion From 21d3e540345e36b726567f416144623ff1642f63 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 11 Dec 2025 15:14:15 -0500 Subject: [PATCH 33/35] update comments + errors --- hook/hook.go | 4 ++-- worstcase/state.go | 6 +----- worstcase/state_test.go | 2 +- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/hook/hook.go b/hook/hook.go index a29915bf..d8e51e0f 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -54,8 +54,8 @@ type Points interface { // 100 gas/second, then this method should return 75 gas. SubSecondBlockTime(gasRate gas.Gas, h *types.Header) gas.Gas // ExtraBlockOps returns operations outside of the normal EVM state changes - // to perform while executing the block. These operations should be - // performed after executing the normal ethereum transactions in the block. + // to perform while executing the block. These operations will be performed + // during both worst-case and actual execution. ExtraBlockOps(*types.Block) ([]Op, error) // BeforeExecutingBlock is called immediately prior to executing the block. BeforeExecutingBlock(params.Rules, *state.StateDB, *types.Block) error diff --git a/worstcase/state.go b/worstcase/state.go index d3b10cbc..90cd226c 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -208,10 +208,6 @@ func txToOp(signer types.Signer, tx *types.Transaction) (Op, error) { }, nil } -// ErrBlockTooFull is returned by [State.ApplyTx] and [State.Apply] if inclusion -// would cause the block to exceed the gas limit. -var ErrBlockTooFull = errors.New("block too full") - // Apply attempts to apply the operation to this state. // // If the operation can not be applied, an error is returned and the state is @@ -226,7 +222,7 @@ var ErrBlockTooFull = errors.New("block too full") func (s *State) Apply(o Op) error { // ----- Gas ----- if o.Gas > s.maxBlockSize-s.blockSize { - return ErrBlockTooFull + return core.ErrGasLimitReached } // ----- GasPrice ----- diff --git a/worstcase/state_test.go b/worstcase/state_test.go index b23686b6..d2aef54d 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -189,7 +189,7 @@ func TestState(t *testing.T) { Gas: initialGasTarget*targetToMaxBlockSize - params.TxGas + 1, To: &common.Address{}, }, - wantErr: ErrBlockTooFull, + wantErr: core.ErrGasLimitReached, }, { name: "full_block", From 14c26f62b5e7f9202a257c6728a8e6d7ac4e1d85 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 11 Dec 2025 17:05:29 -0500 Subject: [PATCH 34/35] Cleanup tests --- worstcase/state.go | 18 +- worstcase/state_test.go | 466 +++++++++++++++++++++++----------------- 2 files changed, 279 insertions(+), 205 deletions(-) diff --git a/worstcase/state.go b/worstcase/state.go index 90cd226c..025d9c91 100644 --- a/worstcase/state.go +++ b/worstcase/state.go @@ -144,10 +144,7 @@ type ( Op = hook.Op ) -var ( - errGasFeeCapOverflow = errors.New("GasFeeCap() overflows uint256") - errCostOverflow = errors.New("Cost() overflows uint256") -) +var errCostOverflow = errors.New("Cost() overflows uint256") // ApplyTx validates the transaction both intrinsically and in the context of // worst-case gas assumptions of all previous operations. This provides an upper @@ -189,7 +186,7 @@ func txToOp(signer types.Signer, tx *types.Transaction) (Op, error) { var gasPrice uint256.Int if overflow := gasPrice.SetFromBig(tx.GasFeeCap()); overflow { - return Op{}, errGasFeeCapOverflow + return Op{}, core.ErrFeeCapVeryHigh } var amount uint256.Int if overflow := amount.SetFromBig(tx.Cost()); overflow { @@ -248,16 +245,19 @@ func (s *State) Apply(o Op) error { // ----- Inclusion ----- s.blockSize += o.Gas + executeOp(s.db, o) + return nil +} +func executeOp(db *state.StateDB, o Op) { for from, ad := range o.From { - s.db.SetNonce(from, ad.Nonce+1) - s.db.SubBalance(from, &ad.Amount) + db.SetNonce(from, ad.Nonce+1) + db.SubBalance(from, &ad.Amount) } for to, amount := range o.To { - s.db.AddBalance(to, &amount) + db.AddBalance(to, &amount) } - return nil } // FinishBlock advances the [gastime.Time] in preparation for the next block. diff --git a/worstcase/state_test.go b/worstcase/state_test.go index d2aef54d..138ab952 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -8,10 +8,12 @@ import ( "math/big" "testing" + "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/txpool" "github.com/ava-labs/libevm/core/types" "github.com/ava-labs/libevm/crypto" "github.com/ava-labs/libevm/params" @@ -22,14 +24,10 @@ import ( "github.com/ava-labs/strevm/hook/hookstest" ) -const initialGasTarget = 1_000_000 - -func newDB(tb testing.TB) *state.StateDB { - tb.Helper() - db, err := state.New(types.EmptyRootHash, state.NewDatabase(rawdb.NewMemoryDatabase()), nil) - require.NoError(tb, err, "state.New([empty root], [fresh memory db])") - return db -} +const ( + initialGasTarget = 1_000_000 + initialExcess = 60_303_807 // Maximum excess that results in gas price of 1 +) type SUT struct { *State @@ -39,199 +37,92 @@ type SUT struct { func newSUT(tb testing.TB) SUT { tb.Helper() - db := newDB(tb) hooks := &hookstest.Stub{ Target: initialGasTarget, } + db, err := state.New(types.EmptyRootHash, state.NewDatabase(rawdb.NewMemoryDatabase()), nil) + require.NoError(tb, err, "state.New([empty root], [fresh memory db])") return SUT{ State: NewState( hooks, params.MergedTestChainConfig, db, - gastime.New(0, initialGasTarget, 0), + gastime.New(0, initialGasTarget, initialExcess), ), DB: db, Hooks: hooks, } } -const targetToMaxBlockSize = gastime.TargetToRate * rateToMaxBlockSize - -func TestState(t *testing.T) { - key, err := crypto.GenerateKey() - require.NoError(t, err, "libevm/crypto.GenerateKey()") - eoa := crypto.PubkeyToAddress(key.PublicKey) +const ( + targetToMaxBlockSize = gastime.TargetToRate * rateToMaxBlockSize + initialMaxBlockSize = initialGasTarget * targetToMaxBlockSize +) +func TestMultipleBlocks(t *testing.T) { + var ( + eoa = common.Address{0x01} + eoaNoBalance = common.Address{0x02} + ) state := newSUT(t) state.DB.SetBalance(eoa, uint256.NewInt(math.MaxUint64)) - - eoaMaxNonce := common.Address{0x00} - state.DB.SetNonce(eoaMaxNonce, math.MaxUint64) - state.DB.SetBalance(eoaMaxNonce, uint256.NewInt(math.MaxUint64)) - - eoaNoBalance := common.Address{0x01} type op struct { name string - tx types.TxData - op *Op + op Op wantErr error } blocks := []struct { hooks *hookstest.Stub + time uint64 wantGasLimit uint64 wantBaseFee *uint256.Int ops []op }{ { - wantGasLimit: initialGasTarget * targetToMaxBlockSize, + hooks: &hookstest.Stub{ + Target: 2 * initialGasTarget, // Will double the target _after_ this block. + }, + wantGasLimit: initialMaxBlockSize, wantBaseFee: uint256.NewInt(1), ops: []op{ { - name: "not_cover_intrinsic_gas", - tx: &types.LegacyTx{ - To: &common.Address{}, - Gas: params.TxGas - 1, - }, - wantErr: core.ErrIntrinsicGas, - }, - { - name: "exceed_max_init_code_size", - tx: &types.LegacyTx{ - Gas: 250_000, // cover intrinsic gas - To: nil, // contract creation - Data: make([]byte, params.MaxInitCodeSize+1), - }, - wantErr: core.ErrMaxInitCodeSizeExceeded, - }, - { - name: "blob_tx_not_supported", - tx: &types.BlobTx{ - Gas: params.TxGas, - }, - wantErr: core.ErrTxTypeNotSupported, - }, - { - name: "cost_overflow", - tx: &types.LegacyTx{ - Nonce: 0, - GasPrice: new(big.Int).Lsh(big.NewInt(1), 256-1), - Gas: params.TxGas, - To: &eoa, - Value: big.NewInt(10), - }, - wantErr: errCostOverflow, - }, - { - name: "gas_price_too_low", - tx: &types.LegacyTx{ - GasPrice: big.NewInt(0), - Gas: params.TxGas, - To: &common.Address{}, - }, - wantErr: core.ErrFeeCapTooLow, - }, - { - name: "nonce_too_high", - tx: &types.LegacyTx{ - Nonce: 1, - GasPrice: big.NewInt(1), - Gas: params.TxGas, - To: &common.Address{}, - }, - wantErr: core.ErrNonceTooHigh, - }, - { - name: "insufficient_funds_for_gas", - tx: &types.LegacyTx{ - Gas: params.TxGas, - GasPrice: new(big.Int).SetUint64(math.MaxUint64), - To: &common.Address{}, - Value: big.NewInt(0), - }, - wantErr: core.ErrInsufficientFunds, - }, - { - name: "insufficient_funds_for_value", - tx: &types.LegacyTx{ - Gas: params.TxGas, - GasPrice: big.NewInt(1), - To: &common.Address{}, - Value: new(big.Int).SetUint64(math.MaxUint64), - }, - wantErr: core.ErrInsufficientFunds, - }, - { - name: "include_transfer", - tx: &types.LegacyTx{ - Nonce: 0, - GasPrice: big.NewInt(1), - Gas: params.TxGas, - To: &eoa, - Value: big.NewInt(10), + name: "include_small operation", + op: Op{ + Gas: gas.Gas(params.TxGas), + GasPrice: *uint256.NewInt(1), }, wantErr: nil, }, - { - name: "nonce_too_low", - tx: &types.LegacyTx{ - Nonce: 0, - GasPrice: big.NewInt(1), - Gas: params.TxGas, - To: &common.Address{}, - }, - wantErr: core.ErrNonceTooLow, - }, { name: "block_too_full", - tx: &types.LegacyTx{ - Nonce: 1, - GasPrice: big.NewInt(1), - Gas: initialGasTarget*targetToMaxBlockSize - params.TxGas + 1, - To: &common.Address{}, + op: Op{ + Gas: gas.Gas(initialMaxBlockSize - params.TxGas + 1), + GasPrice: *uint256.NewInt(1), }, wantErr: core.ErrGasLimitReached, }, { - name: "full_block", - tx: &types.LegacyTx{ - Nonce: 1, - GasPrice: big.NewInt(1), - Gas: initialGasTarget*targetToMaxBlockSize - params.TxGas, - To: &common.Address{}, + name: "block_full", + op: Op{ + Gas: gas.Gas(initialMaxBlockSize - params.TxGas), + GasPrice: *uint256.NewInt(1), }, wantErr: nil, }, }, }, { - wantGasLimit: initialGasTarget * targetToMaxBlockSize, - wantBaseFee: uint256.NewInt(1), + hooks: &hookstest.Stub{ + Target: initialGasTarget, // Restore the target _after_ this block. + }, + wantGasLimit: 2 * initialMaxBlockSize, + wantBaseFee: uint256.NewInt(2), ops: []op{ - { - name: "max_nonce", - op: &Op{ - Gas: 1, - GasPrice: *uint256.NewInt(1), - From: map[common.Address]AccountDebit{ - eoaMaxNonce: { - Nonce: math.MaxUint64, - Amount: *uint256.NewInt(1), - }, - }, - }, - wantErr: core.ErrNonceMax, - }, { name: "import", - op: &Op{ + op: Op{ Gas: 1, - GasPrice: *uint256.NewInt(1), - From: map[common.Address]AccountDebit{ - eoa: { - Nonce: 2, - Amount: *uint256.NewInt(1), - }, - }, + GasPrice: *uint256.NewInt(2), To: map[common.Address]uint256.Int{ eoaNoBalance: *uint256.NewInt(10), }, @@ -239,13 +130,12 @@ func TestState(t *testing.T) { wantErr: nil, }, { - name: "imported_insufficient_funds", - op: &Op{ - Gas: initialGasTarget*targetToMaxBlockSize - 1, - GasPrice: *uint256.NewInt(1), + name: "imported_funds_insufficient", + op: Op{ + Gas: 1, + GasPrice: *uint256.NewInt(2), From: map[common.Address]AccountDebit{ eoaNoBalance: { - Nonce: 0, Amount: *uint256.NewInt(11), }, }, @@ -254,12 +144,11 @@ func TestState(t *testing.T) { }, { name: "spend_imported_funds", - op: &Op{ - Gas: initialGasTarget*targetToMaxBlockSize - 1, - GasPrice: *uint256.NewInt(1), + op: Op{ + Gas: 1, + GasPrice: *uint256.NewInt(2), From: map[common.Address]AccountDebit{ eoaNoBalance: { - Nonce: 0, Amount: *uint256.NewInt(10), }, }, @@ -269,20 +158,15 @@ func TestState(t *testing.T) { }, }, { - wantGasLimit: initialGasTarget * targetToMaxBlockSize, - wantBaseFee: uint256.NewInt(1), - ops: []op{ - { - name: "full_block", - tx: &types.LegacyTx{ - Nonce: 3, - GasPrice: big.NewInt(1), - Gas: initialGasTarget * targetToMaxBlockSize, - To: &common.Address{}, - }, - wantErr: nil, - }, + hooks: &hookstest.Stub{ + Target: initialGasTarget, // Restore the target _after_ this block. }, + // We have currently included slightly over 10s worth of gas. We + // should increase the time by that same amount to restore the base + // fee. + time: 21, + wantGasLimit: initialMaxBlockSize, + wantBaseFee: uint256.NewInt(1), }, } for i, block := range blocks { @@ -291,41 +175,231 @@ func TestState(t *testing.T) { } header := &types.Header{ Number: big.NewInt(int64(i)), + Time: block.time, } require.NoErrorf(t, state.StartBlock(header), "StartBlock(%d)", i) require.Equalf(t, block.wantBaseFee, state.BaseFee(), "base fee after StartBlock(%d)", i) require.Equalf(t, block.wantGasLimit, state.GasLimit(), "gas limit after StartBlock(%d)", i) for _, op := range block.ops { - if op.tx != nil { - tx := types.MustSignNewTx(key, types.NewCancunSigner(state.config.ChainID), op.tx) - gotErr := state.ApplyTx(tx) - require.ErrorIsf(t, gotErr, op.wantErr, "ApplyTx(%s) error", op.name) - } - if op.op != nil { - gotErr := state.Apply(*op.op) - require.ErrorIsf(t, gotErr, op.wantErr, "Apply(%s) error", op.name) - } + gotErr := state.Apply(op.op) + require.ErrorIsf(t, gotErr, op.wantErr, "Apply(%s) error", op.name) } require.NoError(t, state.FinishBlock(), "FinishBlock()") } +} - // Test that nonconsecutive blocks are disallowed. - { - header := &types.Header{ - Number: big.NewInt(int64(len(blocks) + 1)), - } - err := state.StartBlock(header) - require.ErrorIs(t, err, errNonConsecutiveBlocks, "nonconsecutive StartBlock()") +func TestTransactionValidation(t *testing.T) { + key, err := crypto.GenerateKey() + require.NoError(t, err, "libevm/crypto.GenerateKey()") + eoa := crypto.PubkeyToAddress(key.PublicKey) + + tests := []struct { + name string + nonce uint64 + balance uint64 + tx types.TxData + wantErr error + }{ + { + name: "blob_tx_not_supported", + tx: &types.BlobTx{ + Gas: params.TxGas, + }, + wantErr: core.ErrTxTypeNotSupported, + }, + { + name: "not_cover_intrinsic_gas", + tx: &types.LegacyTx{ + To: &common.Address{}, + Gas: params.TxGas - 1, + }, + wantErr: core.ErrIntrinsicGas, + }, + { + name: "exceed_max_init_code_size", + tx: &types.LegacyTx{ + Gas: 250_000, // cover intrinsic gas + To: nil, // contract creation + Data: make([]byte, params.MaxInitCodeSize+1), + }, + wantErr: core.ErrMaxInitCodeSizeExceeded, + }, + { + name: "gas_price_overflow", + tx: &types.LegacyTx{ + GasPrice: new(big.Int).Lsh(big.NewInt(1), 256), + Gas: params.TxGas, + To: &common.Address{}, + Value: big.NewInt(10), + }, + wantErr: core.ErrFeeCapVeryHigh, + }, + { + name: "cost_overflow", + tx: &types.LegacyTx{ + GasPrice: new(big.Int).Lsh(big.NewInt(1), 256-1), + Gas: params.TxGas, + To: &common.Address{}, + Value: big.NewInt(10), + }, + wantErr: errCostOverflow, + }, + { + name: "gas_price_too_low", + tx: &types.LegacyTx{ + GasPrice: big.NewInt(0), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrFeeCapTooLow, + }, + { + name: "nonce_too_low", + nonce: 1, + tx: &types.LegacyTx{ + GasPrice: big.NewInt(1), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrNonceTooLow, + }, + { + name: "nonce_too_high", + tx: &types.LegacyTx{ + Nonce: 1, + GasPrice: big.NewInt(1), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrNonceTooHigh, + }, + { + name: "max_nonce", + nonce: math.MaxUint64, + tx: &types.LegacyTx{ + Nonce: math.MaxUint64, + GasPrice: big.NewInt(1), + Gas: params.TxGas, + To: &common.Address{}, + }, + wantErr: core.ErrNonceMax, + }, + { + name: "insufficient_funds_for_gas", + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: big.NewInt(1), + To: &common.Address{}, + Value: big.NewInt(0), + }, + wantErr: core.ErrInsufficientFunds, + }, + { + name: "insufficient_funds_for_value", + balance: params.TxGas, + tx: &types.LegacyTx{ + Gas: params.TxGas, + GasPrice: big.NewInt(1), + To: &common.Address{}, + Value: big.NewInt(1), + }, + wantErr: core.ErrInsufficientFunds, + }, + { + name: "gas_limit_exceeded", + balance: initialMaxBlockSize, + tx: &types.LegacyTx{ + GasPrice: big.NewInt(1), + Gas: initialMaxBlockSize + 1, + To: &common.Address{}, + }, + wantErr: txpool.ErrGasLimit, + }, } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + state := newSUT(t) + state.DB.SetNonce(eoa, tt.nonce) + state.DB.SetBalance(eoa, uint256.NewInt(tt.balance)) - // Test that starting a new block fails if the queue is full. - { - header := &types.Header{ - Number: big.NewInt(int64(len(blocks))), - } - err := state.StartBlock(header) - require.ErrorIsf(t, err, errQueueFull, "StartBlock(%d)", len(blocks)) + header := &types.Header{ + Number: big.NewInt(0), + } + require.NoErrorf(t, state.StartBlock(header), "StartBlock()") + + tx := types.MustSignNewTx(key, types.NewCancunSigner(state.config.ChainID), tt.tx) + gotErr := state.ApplyTx(tx) + require.ErrorIsf(t, gotErr, tt.wantErr, "ApplyTx() error") + }) + } +} + +// Test that non-consecutive blocks are sanity checked. +func TestStartBlockNonConsecutiveBlocks(t *testing.T) { + state := newSUT(t) + + err := state.StartBlock(&types.Header{ + Number: big.NewInt(0), + }) + require.NoError(t, err, "StartBlock()") + + err = state.StartBlock(&types.Header{ + Number: big.NewInt(2), // Should be 1 to be consecutive + }) + require.ErrorIs(t, err, errNonConsecutiveBlocks, "nonconsecutive StartBlock()") +} + +// Test that filling the queue eventually prevents new blocks from being added. +func TestStartBlockQueueFull(t *testing.T) { + state := newSUT(t) + + // Fill the queue with the minimum amount of gas to prevent additional + // blocks. + for number, gas := range []gas.Gas{initialMaxBlockSize, initialMaxBlockSize, 1} { + err := state.StartBlock(&types.Header{ + Number: big.NewInt(int64(number)), + }) + require.NoError(t, err, "StartBlock()") + + err = state.Apply(Op{ + Gas: gas, + GasPrice: *uint256.NewInt(2), + }) + require.NoError(t, err, "Apply()") + + err = state.FinishBlock() + require.NoError(t, err, "FinishBlock()") } + + err := state.StartBlock(&types.Header{ + Number: big.NewInt(3), + }) + require.ErrorIs(t, err, errQueueFull, "StartBlock() with full queue") +} + +// Test that changing the target can cause the queue to be treated as full. +func TestStartBlockQueueFullDueToTargetChanges(t *testing.T) { + state := newSUT(t) + + state.Hooks.Target = 1 + err := state.StartBlock(&types.Header{ + Number: big.NewInt(0), + }) + require.NoError(t, err, "StartBlock()") + + err = state.Apply(Op{ + Gas: initialMaxBlockSize, + GasPrice: *uint256.NewInt(1), + }) + require.NoError(t, err, "Apply()") + + err = state.FinishBlock() + require.NoError(t, err, "FinishBlock()") + + err = state.StartBlock(&types.Header{ + Number: big.NewInt(1), + }) + require.ErrorIs(t, err, errQueueFull, "StartBlock() with full queue") } From 3db949d60af608e50d104d5b72802bf1cbf6d2c5 Mon Sep 17 00:00:00 2001 From: Stephen Buttolph Date: Thu, 11 Dec 2025 17:10:10 -0500 Subject: [PATCH 35/35] nit --- worstcase/state_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/worstcase/state_test.go b/worstcase/state_test.go index 138ab952..cb8c3964 100644 --- a/worstcase/state_test.go +++ b/worstcase/state_test.go @@ -24,17 +24,17 @@ import ( "github.com/ava-labs/strevm/hook/hookstest" ) -const ( - initialGasTarget = 1_000_000 - initialExcess = 60_303_807 // Maximum excess that results in gas price of 1 -) - type SUT struct { *State DB *state.StateDB Hooks *hookstest.Stub } +const ( + initialGasTarget = 1_000_000 + initialExcess = 60_303_807 // Maximum excess that results in gas price of 1 +) + func newSUT(tb testing.TB) SUT { tb.Helper() hooks := &hookstest.Stub{