feat: temporary extras (#234)

## Why this should be merged

Registration of extras requires runtime enforcement of payload types. In
production, only a single set of types can be registered and any
attempts at re-registration will panic (by design). Although this makes
production usage safe, it doesn't allow downstream consumers (e.g. data
indexers) to use extras from different chains (e.g. `coreth` _and_
`subnet-evm`) at the same time.

## How this works

1. The `libevm/register.AtMostOnce` type can now be overridden
temporarily.
2. `params`, `core/types`, `core/vm`, and `state` packages introduce
`WithTempRegisteredExtras()` functions.
3. `libevm/temporary.WithRegisteredExtras()` provides "atomic" override
of all extras.

In all cases, the scope of the override is limited to the life of a
single function call.

## How this was tested

Relative to numbered list above:

1. Unit test of new and existing functionality.
2. Integration tests of both packages, demonstrating both payload and
behavioural override.
This commit is contained in:
Arran Schlosberg 2025-10-02 17:42:26 +01:00 committed by GitHub
parent 414b1f5dff
commit 35926db4d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 481 additions and 34 deletions

View file

@ -82,6 +82,19 @@ func RegisterExtras(s StateDBHooks) {
registeredExtras.MustRegister(s)
}
// WithTempRegisteredExtras temporarily registers `s` as if calling
// [RegisterExtras] the same type parameter. After `fn` returns, the
// registration is returned to its former state, be that none or the types
// originally passed to [RegisterExtras].
//
// This MUST NOT be used on a live chain. It is solely intended for off-chain
// consumers that require access to extras. Said consumers SHOULD NOT, however
// call this function directly. Use the libevm/temporary.WithRegisteredExtras()
// function instead as it atomically overrides all possible packages.
func WithTempRegisteredExtras(s StateDBHooks, fn func()) {
registeredExtras.TempOverride(s, fn)
}
// TestOnlyClearRegisteredExtras clears the arguments previously passed to
// [RegisterExtras]. It panics if called from a non-testing call stack.
//

View file

@ -150,6 +150,12 @@ func (highByteFlipper) TransformStateKey(_ common.Address, key common.Hash) comm
return flipHighByte(key)
}
type noopHooks struct{}
func (noopHooks) TransformStateKey(_ common.Address, key common.Hash) common.Hash {
return key
}
func TestTransformStateKey(t *testing.T) {
rawdb := rawdb.NewMemoryDatabase()
trie := triedb.NewDatabase(rawdb, nil)
@ -209,6 +215,16 @@ func TestTransformStateKey(t *testing.T) {
assertCommittedEq(t, flippedKey, regularVal)
assertCommittedEq(t, flippedKey, flippedVal, noTransform)
t.Run("WithTempRegisteredExtras", func(t *testing.T) {
WithTempRegisteredExtras(noopHooks{}, func() {
// No-op hooks are equivalent to using the `noTransform` option.
// NOTE this is NOT the intended usage of [WithTempRegisteredExtras]
// and is simply an easy way to test the temporary registration.
assertEq(t, regularKey, regularVal)
assertEq(t, flippedKey, flippedVal)
})
})
updatedVal := common.Hash{'u', 'p', 'd', 'a', 't', 'e', 'd'}
sdb.SetState(addr, regularKey, updatedVal)
assertEq(t, regularKey, updatedVal)

View file

@ -130,7 +130,7 @@ type BlockBodyHooks interface {
// to no type having been registered.
type NOOPBlockBodyHooks struct{}
var _ BlockBodyPayload[*NOOPBlockBodyHooks] = NOOPBlockBodyHooks{}
var _ BlockBodyPayload[*NOOPBlockBodyHooks] = (*NOOPBlockBodyHooks)(nil)
func (NOOPBlockBodyHooks) Copy() *NOOPBlockBodyHooks { return &NOOPBlockBodyHooks{} }

View file

@ -44,17 +44,27 @@ import (
// [Header] or [Block] / [Body] is a non-nil `HPtr` or `BPtr` respectively. The
// latter guarantee ensures that hooks won't be called on nil-pointer receivers.
func RegisterExtras[
H any, HPtr interface {
HeaderHooks
*H
},
B any, BPtr interface {
BlockBodyPayload[BPtr]
*B
},
H any, HPtr HeaderHooksPointer[H],
B any, BPtr BlockBodyHooksPointer[B, BPtr],
SA any,
]() ExtraPayloads[HPtr, BPtr, SA] {
extra := ExtraPayloads[HPtr, BPtr, SA]{
payloads, ctors := payloadsAndConstructors[H, HPtr, B, BPtr, SA]()
registeredExtras.MustRegister(ctors)
log.Info(
"Registered core/types extras",
"Header", log.TypeOf(pseudo.Zero[HPtr]().Value.Get()),
"Block/Body", log.TypeOf(pseudo.Zero[BPtr]().Value.Get()),
"StateAccount", log.TypeOf(pseudo.Zero[SA]().Value.Get()),
)
return payloads
}
func payloadsAndConstructors[
H any, HPtr HeaderHooksPointer[H],
B any, BPtr BlockBodyHooksPointer[B, BPtr],
SA any,
]() (ExtraPayloads[HPtr, BPtr, SA], *extraConstructors) {
payloads := ExtraPayloads[HPtr, BPtr, SA]{
Header: pseudo.NewAccessor[*Header, HPtr](
(*Header).extraPayload,
func(h *Header, t *pseudo.Type) { h.extra = t },
@ -72,7 +82,7 @@ func RegisterExtras[
func(a StateOrSlimAccount, t *pseudo.Type) { a.extra().t = t },
),
}
registeredExtras.MustRegister(&extraConstructors{
ctors := &extraConstructors{
stateAccountType: func() string {
var x SA
return fmt.Sprintf("%T", x)
@ -84,23 +94,51 @@ func RegisterExtras[
newHeader: pseudo.NewConstructor[H]().NewPointer, // i.e. non-nil HPtr
newBlockOrBody: pseudo.NewConstructor[B]().NewPointer, // i.e. non-nil BPtr
newStateAccount: pseudo.NewConstructor[SA]().Zero,
hooks: extra,
})
log.Info(
"Registered core/types extras",
"Header", log.TypeOf(pseudo.Zero[HPtr]().Value.Get()),
"Block/Body", log.TypeOf(pseudo.Zero[BPtr]().Value.Get()),
"StateAccount", log.TypeOf(pseudo.Zero[SA]().Value.Get()),
)
return extra
hooks: payloads,
}
return payloads, ctors
}
// WithTempRegisteredExtras temporarily registers `HPtr`, `BPtr`, and `SA` as if
// calling [RegisterExtras] the same type parameters. The [ExtraPayloads] are
// passed to `fn` instead of being returned; the argument MUST NOT be persisted
// beyond the life of `fn`. After `fn` returns, the registration is returned to
// its former state, be that none or the types originally passed to
// [RegisterExtras].
//
// This MUST NOT be used on a live chain. It is solely intended for off-chain
// consumers that require access to extras. Said consumers SHOULD NOT, however
// call this function directly. Use the libevm/temporary.WithRegisteredExtras()
// function instead as it atomically overrides all possible packages.
func WithTempRegisteredExtras[
H, B, SA any,
HPtr HeaderHooksPointer[H],
BPtr BlockBodyHooksPointer[B, BPtr],
](fn func(ExtraPayloads[HPtr, BPtr, SA])) {
payloads, ctors := payloadsAndConstructors[H, HPtr, B, BPtr, SA]()
registeredExtras.TempOverride(ctors, func() { fn(payloads) })
}
// A HeaderHooksPointer is a type constraint for an implementation of
// [HeaderHooks] with a pointer receiver.
type HeaderHooksPointer[H any] interface {
HeaderHooks
*H
}
// A BlockBodyHooksPointer is a type constraint for an implementation of
// [BlockBodyPayload] with a pointer receiver.
type BlockBodyHooksPointer[B any, Self any] interface {
BlockBodyPayload[Self]
*B
}
// A BlockBodyPayload is an implementation of [BlockBodyHooks] that is also able
// to clone itself. Both [Block.Body] and [Block.WithBody] require this
// functionality to copy the payload between the types.
type BlockBodyPayload[BPtr any] interface {
type BlockBodyPayload[Self any] interface {
BlockBodyHooks
Copy() BPtr
Copy() Self
}
// TestOnlyClearRegisteredExtras clears the [Extras] previously passed to

View file

@ -0,0 +1,76 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.
package types
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ava-labs/libevm/rlp"
)
type tempBlockBodyHooks struct {
X string
NOOPBlockBodyHooks
}
func (b *tempBlockBodyHooks) Copy() *tempBlockBodyHooks {
return &tempBlockBodyHooks{X: b.X}
}
func (b *tempBlockBodyHooks) BlockRLPFieldsForEncoding(*BlockRLPProxy) *rlp.Fields {
return &rlp.Fields{
Required: []any{b.X},
}
}
func TestTempRegisteredExtras(t *testing.T) {
TestOnlyClearRegisteredExtras()
t.Cleanup(TestOnlyClearRegisteredExtras)
rlpWithoutHooks, err := rlp.EncodeToBytes(&Block{})
require.NoErrorf(t, err, "rlp.EncodeToBytes(%T) without hooks", &Block{})
extras := RegisterExtras[NOOPHeaderHooks, *NOOPHeaderHooks, NOOPBlockBodyHooks, *NOOPBlockBodyHooks, bool]()
testPrimaryExtras := func(t *testing.T) {
t.Helper()
b := new(Block)
got, err := rlp.EncodeToBytes(b)
require.NoErrorf(t, err, "rlp.EncodeToBytes(%T) with %T hooks", b, extras.Block.Get(b))
assert.Equalf(t, rlpWithoutHooks, got, "rlp.EncodeToBytes(%T) with noop hooks; expect same as without hooks", b)
}
t.Run("before_temp", testPrimaryExtras)
t.Run("WithTempRegisteredExtras", func(t *testing.T) {
WithTempRegisteredExtras(func(extras ExtraPayloads[*NOOPHeaderHooks, *tempBlockBodyHooks, bool]) {
const val = "Hello, world"
b := new(Block)
payload := &tempBlockBodyHooks{X: val}
extras.Block.Set(b, payload)
got, err := rlp.EncodeToBytes(b)
require.NoErrorf(t, err, "rlp.EncodeToBytes(%T) with %T hooks", b, extras.Block.Get(b))
want, err := rlp.EncodeToBytes([]string{val})
require.NoErrorf(t, err, "rlp.EncodeToBytes(%T{%[1]v})", []string{val})
assert.Equalf(t, want, got, "rlp.EncodeToBytes(%T) with %T hooks", b, payload)
})
})
t.Run("after_temp", testPrimaryExtras)
}

View file

@ -63,9 +63,23 @@ func TestOverrideNewEVMArgs(t *testing.T) {
hooks := evmArgOverrider{newEVMchainID: chainID}
hooks.register(t)
evm := NewEVM(BlockContext{}, TxContext{}, nil, nil, Config{})
got := evm.ChainConfig().ChainID
require.Equalf(t, big.NewInt(chainID), got, "%T.ChainConfig().ChainID set by NewEVM() hook", evm)
assertChainID := func(t *testing.T, want int64) {
t.Helper()
evm := NewEVM(BlockContext{}, TxContext{}, nil, nil, Config{})
got := evm.ChainConfig().ChainID
require.Equalf(t, big.NewInt(want), got, "%T.ChainConfig().ChainID set by NewEVM() hook", evm)
}
assertChainID(t, chainID)
t.Run("WithTempRegisteredHooks", func(t *testing.T) {
override := evmArgOverrider{newEVMchainID: 24680}
WithTempRegisteredHooks(&override, func() {
assertChainID(t, override.newEVMchainID)
})
t.Run("after", func(t *testing.T) {
assertChainID(t, chainID)
})
})
}
func TestOverrideEVMResetArgs(t *testing.T) {

View file

@ -27,6 +27,19 @@ func RegisterHooks(h Hooks) {
libevmHooks.MustRegister(h)
}
// WithTempRegisteredHooks temporarily registers `h` as if calling
// [RegisterHooks] the same type parameter. After `fn` returns, the registration
// is returned to its former state, be that none or the types originally passed
// to [RegisterHooks].
//
// This MUST NOT be used on a live chain. It is solely intended for off-chain
// consumers that require access to extras. Said consumers SHOULD NOT, however
// call this function directly. Use the libevm/temporary.WithRegisteredExtras()
// function instead as it atomically overrides all possible packages.
func WithTempRegisteredHooks(h Hooks, fn func()) {
libevmHooks.TempOverride(h, fn)
}
// TestOnlyClearRegisteredHooks clears the [Hooks] previously passed to
// [RegisterHooks]. It panics if called from a non-testing call stack.
func TestOnlyClearRegisteredHooks() {

View file

@ -1,4 +1,4 @@
// Copyright 2024 the libevm authors.
// Copyright 2024-2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
@ -66,3 +66,28 @@ func (o *AtMostOnce[T]) TestOnlyClear() {
o.v = nil
})
}
// TempOverride calls `fn`, overriding any registered `T`, but only for the life
// of the call. It is not threadsafe.
//
// It is valid to call this method with or without a prior call to
// [AtMostOnce.Register].
func (o *AtMostOnce[T]) TempOverride(with T, fn func()) {
o.temp(&with, fn)
}
// TempClear calls `fn`, clearing any registered `T`, but only for the life of
// the call. It is not threadsafe.
//
// It is valid to call this method with or without a prior call to
// [AtMostOnce.Register].
func (o *AtMostOnce[T]) TempClear(fn func()) {
o.temp(nil, fn)
}
func (o *AtMostOnce[T]) temp(with *T, fn func()) {
old := o.v
o.v = with
fn()
o.v = old
}

View file

@ -0,0 +1,78 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.
package register
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAtMostOnce(t *testing.T) {
var sut AtMostOnce[int]
assertRegistered := func(t *testing.T, want int) {
t.Helper()
require.True(t, sut.Registered(), "Registered()")
assert.Equal(t, want, sut.Get(), "Get()")
}
const val int = 42
require.NoError(t, sut.Register(val), "Register()")
assertRegistered(t, val)
assert.PanicsWithValue(
t, ErrReRegistration,
func() { sut.MustRegister(0) },
"MustRegister() after Register()",
)
t.Run("TestOnlyClear", func(t *testing.T) {
sut.TestOnlyClear()
require.False(t, sut.Registered(), "Registered()")
t.Run("re-registration", func(t *testing.T) {
sut.MustRegister(val)
assertRegistered(t, val)
})
})
if t.Failed() {
return
}
t.Run("TempOverride", func(t *testing.T) {
t.Run("during", func(t *testing.T) {
sut.TempOverride(val+1, func() {
assertRegistered(t, val+1)
})
})
t.Run("after", func(t *testing.T) {
assertRegistered(t, val)
})
})
t.Run("TempClear", func(t *testing.T) {
t.Run("during", func(t *testing.T) {
sut.TempClear(func() {
assert.False(t, sut.Registered(), "Registered()")
})
})
t.Run("after", func(t *testing.T) {
assertRegistered(t, val)
})
})
}

View file

@ -0,0 +1,65 @@
// Copyright 2025 the libevm authors.
//
// The libevm additions to go-ethereum are free software: you can redistribute
// them and/or modify them under the terms of the GNU Lesser General Public License
// as published by the Free Software Foundation, either version 3 of the License,
// or (at your option) any later version.
//
// The libevm additions are distributed in the hope that they will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser
// General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public License
// along with the go-ethereum library. If not, see
// <http://www.gnu.org/licenses/>.
// Package temporary provides thread-safe, temporary registration of all libevm
// hooks and payloads.
package temporary
import (
"sync"
"github.com/ava-labs/libevm/core/state"
"github.com/ava-labs/libevm/core/types"
"github.com/ava-labs/libevm/core/vm"
"github.com/ava-labs/libevm/params"
)
var mu sync.Mutex
// WithRegisteredExtras takes a global lock and temporarily registers [params],
// [state], [types], and [vm] extras before calling the provided function. It
// can be thought of as an atomic call to all functions equivalent to
// [params.WithTempRegisteredExtras].
//
// This is the *only* safe way to override libevm functionality. Direct calls to
// the package-specific temporary registration functions are not advised.
//
// WithRegisteredExtras MUST NOT be used on a live chain. It is solely intended
// for off-chain consumers that require access to extras.
func WithRegisteredExtras[
C params.ChainConfigHooks, R params.RulesHooks,
H, B, SA any,
HPtr types.HeaderHooksPointer[H],
BPtr types.BlockBodyHooksPointer[B, BPtr],
](
paramsExtras params.Extras[C, R],
sdbHooks state.StateDBHooks,
vmHooks vm.Hooks,
fn func(params.ExtraPayloads[C, R], types.ExtraPayloads[HPtr, BPtr, SA]),
) {
mu.Lock()
defer mu.Unlock()
params.WithTempRegisteredExtras(paramsExtras, func(paramsPayloads params.ExtraPayloads[C, R]) {
types.WithTempRegisteredExtras(func(typesPayloads types.ExtraPayloads[HPtr, BPtr, SA]) {
state.WithTempRegisteredExtras(sdbHooks, func() {
vm.WithTempRegisteredHooks(vmHooks, func() {
fn(paramsPayloads, typesPayloads)
})
})
})
})
}

View file

@ -72,14 +72,8 @@ func RegisterExtras[C ChainConfigHooks, R RulesHooks](e Extras[C, R]) ExtraPaylo
mustBeStructOrPointerToOne[C]()
mustBeStructOrPointerToOne[R]()
payloads := e.payloads()
registeredExtras.MustRegister(&extraConstructors{
newChainConfig: pseudo.NewConstructor[C]().Zero,
newRules: pseudo.NewConstructor[R]().Zero,
reuseJSONRoot: e.ReuseJSONRoot,
newForRules: e.newForRules,
payloads: payloads,
})
payloads, ctors := payloadsAndConstructors(e)
registeredExtras.MustRegister(ctors)
log.Info(
"Registered params extras",
"ChainConfig", log.TypeOf(pseudo.Zero[C]().Value.Get()),
@ -89,6 +83,36 @@ func RegisterExtras[C ChainConfigHooks, R RulesHooks](e Extras[C, R]) ExtraPaylo
return payloads
}
func payloadsAndConstructors[C ChainConfigHooks, R RulesHooks](e Extras[C, R]) (ExtraPayloads[C, R], *extraConstructors) {
payloads := e.payloads()
return payloads, &extraConstructors{
newChainConfig: pseudo.NewConstructor[C]().Zero,
newRules: pseudo.NewConstructor[R]().Zero,
reuseJSONRoot: e.ReuseJSONRoot,
newForRules: e.newForRules,
payloads: payloads,
}
}
// WithTempRegisteredExtras temporarily registers `HPtr`, `BPtr`, and `SA` as if
// calling [RegisterExtras] the same type parameters. The [ExtraPayloads] are
// passed to `fn` instead of being returned; the argument MUST NOT be persisted
// beyond the life of `fn`. After `fn` returns, the registration is returned to
// its former state, be that none or the types originally passed to
// [RegisterExtras].
//
// This MUST NOT be used on a live chain. It is solely intended for off-chain
// consumers that require access to extras. Said consumers SHOULD NOT, however
// call this function directly. Use the libevm/temporary.WithRegisteredExtras()
// function instead as it atomically overrides all possible packages.
func WithTempRegisteredExtras[C ChainConfigHooks, R RulesHooks](
e Extras[C, R],
fn func(ExtraPayloads[C, R]),
) {
payloads, ctors := payloadsAndConstructors(e)
registeredExtras.TempOverride(ctors, func() { fn(payloads) })
}
// TestOnlyClearRegisteredExtras clears the [Extras] previously passed to
// [RegisterExtras]. It panics if called from a non-testing call stack.
//

View file

@ -277,3 +277,88 @@ func assertPanics(t *testing.T, fn func(), wantContains string) {
}()
fn()
}
func TestTempRegisteredExtras(t *testing.T) {
TestOnlyClearRegisteredExtras()
t.Cleanup(TestOnlyClearRegisteredExtras)
type (
primaryCC struct {
X int
NOOPHooks
}
primaryRules struct {
X int
NOOPHooks
}
overrideCC struct {
X string
NOOPHooks
}
overrideRules struct {
X string
NOOPHooks
}
)
primary := Extras[primaryCC, primaryRules]{
NewRules: func(_ *ChainConfig, _ *Rules, cc primaryCC, _ *big.Int, _ bool, _ uint64) primaryRules {
return primaryRules{
X: cc.X,
}
},
}
override := Extras[overrideCC, overrideRules]{
NewRules: func(_ *ChainConfig, _ *Rules, cc overrideCC, _ *big.Int, _ bool, _ uint64) overrideRules {
return overrideRules{
X: cc.X,
}
},
}
extras := RegisterExtras(primary)
testPrimaryExtras := func(t *testing.T) {
t.Helper()
assertRulesCopiedFromChainConfig(
t, extras, 42,
func(cc *primaryCC, x int) { cc.X = x },
func(r *primaryRules) int { return r.X },
)
}
t.Run("before_temp", testPrimaryExtras)
t.Run("WithTempRegisteredExtras", func(t *testing.T) {
WithTempRegisteredExtras(
override,
func(extras ExtraPayloads[overrideCC, overrideRules]) { // deliberately shadow `extras`
assertRulesCopiedFromChainConfig(
t, extras, "hello, world",
func(cc *overrideCC, x string) { cc.X = x },
func(r *overrideRules) string { return r.X },
)
},
)
})
t.Run("after_temp", testPrimaryExtras)
}
func assertRulesCopiedFromChainConfig[C ChainConfigHooks, R RulesHooks, Payload any](
t *testing.T,
extras ExtraPayloads[C, R],
val Payload,
setX func(*C, Payload),
getX func(*R) Payload,
) {
t.Helper()
cc := new(ChainConfig)
var ccExtra C
setX(&ccExtra, val)
extras.ChainConfig.Set(cc, ccExtra)
rules := cc.Rules(nil, false, 0)
rulesExtra := extras.Rules.Get(&rules)
assert.Equalf(t, val, getX(&rulesExtra), "%T.X copied from %T.X", rulesExtra, ccExtra)
}