diff --git a/gastime/acp176.go b/gastime/acp176.go new file mode 100644 index 00000000..7c807915 --- /dev/null +++ b/gastime/acp176.go @@ -0,0 +1,33 @@ +// Copyright (C) 2025, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +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 (tm *Time) BeforeBlock(hooks hook.Points, h *types.Header) { + tm.FastForwardTo( + h.Time, + 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 (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 new file mode 100644 index 00000000..e03ff58a --- /dev/null +++ b/gastime/acp176_test.go @@ -0,0 +1,137 @@ +// 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/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 +// rather than BeforeBlock. +func TestTargetUpdateTiming(t *testing.T) { + const ( + initialTime = 42 + initialTarget gas.Gas = 1_600_000 + initialExcess = 1_234_567_890 + ) + tm := New(initialTime, initialTarget, initialExcess) + initialRate := tm.Rate() + + const ( + newTime uint64 = initialTime + 1 + newTarget = initialTarget + 100_000 + ) + hook := &hookstest.Stub{ + Target: newTarget, + } + header := &types.Header{ + Time: newTime, + } + + initialPrice := tm.Price() + 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 + // ensures the test is meaningful. + enforcedPrice := tm.Price() + assert.Less(t, enforcedPrice, initialPrice, "Price should not increase in BeforeBlock()") + if t.Failed() { + t.FailNow() + } + + const ( + secondsOfGasUsed = 3 + expectedEndTime = newTime + secondsOfGasUsed + ) + used := initialRate * secondsOfGasUsed + require.NoError(t, tm.AfterBlock(used, hook, header), "AfterBlock()") + assert.Equal(t, expectedEndTime, tm.Unix(), "Unix time advanced by AfterBlock() due to gas consumption") + 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()") +} + +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)) + + 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, + } + + worstcase.BeforeBlock(hook, header) + actual.BeforeBlock(hook, header) + + // The crux of this test lies in the maintaining of this inequality + // through the use of `limit` instead of `used` in `AfterBlock()` + require.LessOrEqualf(t, actual.Price(), worstcase.Price(), "actual <= worst-case %T.Price()", actual) + require.NoError(t, worstcase.AfterBlock(block.limit, hook, header), "worstcase.AfterBlock()") + require.NoError(t, actual.AfterBlock(block.used, hook, header), "actual.AfterBlock()") + } + }) +} diff --git a/hook/hook.go b/hook/hook.go index fb913f17..360e9ef3 100644 --- a/hook/hook.go +++ b/hook/hook.go @@ -8,14 +8,11 @@ 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/gastime" "github.com/ava-labs/strevm/intmath" saeparams "github.com/ava-labs/strevm/params" ) @@ -37,26 +34,6 @@ type Points interface { AfterExecutingBlock(*state.StateDB, *types.Block, types.Receipts) } -// 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(), - pts.SubSecondBlockTime(clock.Rate(), b.Header()), - ) - return pts.BeforeExecutingBlock(rules, sdb, b) -} - -// 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.GasTargetAfter(b.Header()) - if err := clock.SetTarget(target); err != nil { - return fmt.Errorf("%T.SetTarget() after block: %w", clock, err) - } - pts.AfterExecutingBlock(sdb, b, rs) - return nil -} - // 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/hook_test.go b/hook/hook_test.go deleted file mode 100644 index 406ce806..00000000 --- a/hook/hook_test.go +++ /dev/null @@ -1,68 +0,0 @@ -// 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/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/ava-labs/strevm/gastime" - "github.com/ava-labs/strevm/hook/hookstest" - "github.com/ava-labs/strevm/saetest" - - . "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) - initialRate := tm.Rate() - - 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()") - // 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() { - t.FailNow() - } - - const ( - secondsOfGasUsed = 3 - expectedEndTime = newTime + secondsOfGasUsed - ) - used := initialRate * secondsOfGasUsed - require.NoError(t, AfterBlock(hook, nil, block, tm, used, nil), "AfterBlock()") - assert.Equal(t, expectedEndTime, tm.Unix(), "Unix time advanced by AfterBlock() due to gas consumption") - 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()") -} diff --git a/hook/hookstest/stub.go b/hook/hookstest/stub.go index 5ca4b68c..bdcc44ce 100644 --- a/hook/hookstest/stub.go +++ b/hook/hookstest/stub.go @@ -15,7 +15,8 @@ import ( // Stub implements [hook.Points]. type Stub struct { - Target gas.Gas + Target gas.Gas + SubSecondTime gas.Gas } var _ hook.Points = (*Stub)(nil) @@ -25,9 +26,10 @@ func (s *Stub) GasTargetAfter(*types.Header) gas.Gas { return s.Target } -// SubSecondBlockTime time ignores its arguments and always returns 0. -func (*Stub) SubSecondBlockTime(gas.Gas, *types.Header) 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 } // BeforeExecutingBlock is a no-op that always returns nil. diff --git a/saexec/execution.go b/saexec/execution.go index 9af858c5..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/hook" ) var errExecutorClosed = errors.New("saexec.Executor closed") @@ -103,12 +102,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.EthBlock(), gasClock); err != nil { + gasClock.BeforeBlock(e.hooks, b.Header()) + perTxClock := gasClock.Time.Clone() + + rules := e.chainConfig.Rules(b.Number(), true /*isMerge*/, b.BuildTime()) + if err := e.hooks.BeforeExecutingBlock(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,9 +162,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.AfterExecutingBlock(stateDB, b.EthBlock(), receipts) endTime := time.Now() - if err := hook.AfterBlock(e.hooks, stateDB, b.EthBlock(), gasClock, blockGasConsumed, receipts); err != nil { - return fmt.Errorf("after-block hook: %v", err) + if err := gasClock.AfterBlock(blockGasConsumed, e.hooks, b.Header()); err != nil { + return fmt.Errorf("after-block gas time update: %w", err) } logger.Debug(