feat(core/types): fine-grained Body RLP override (#109)

## Why this should be merged

Allows for modification of `types.Body` payload data + RLP encoding
without placing the entire RLP burden on the `libevm` user as we did
with `types.HeaderHooks`.

## How this works

RLP encoding of a struct is simply a concatenation of RLP encodings of
fields, encompassed by an RLP "list". The
`AppendRLPFields(rlp.EncoderBuffer, ...)` hook exploits this and plugs
in before all `rlp:"optional"`-tagged fields to allow for inclusion of
any new fields. The `EncoderBuffer` SHOULD be used as the `io.Writer`
passed when encoding each field: `rlp.Encode(buffer, fieldValue)`.

`Body` doesn't have `{En,De}codeRLP` methods so they are implemented to
identically replicate original behaviour when a no-op hook is present.

This pattern is sufficient for the `ava-labs/coreth` modifications of
`Body` but can be modified / extended for more complex scenarios, like
`Header`.

> [!NOTE]
> This PR does not include registration of the hooks as that was not the
initial goal and adding them would create too much PR bloat. There is a
placeholder `var todoRegisteredBodyHooks` global variable that can only
be set in tests.

## How this was tested

- Backwards compatibility: the new methods are fuzzed against a `type
withoutMethods Body` passed directly to `rlp.{En,De}code()`
- `coreth` compatibility: unit test of a local implementation of
`BodyHooks` demonstrating reproducibility of RLP encoding.

---------

Signed-off-by: Arran Schlosberg <519948+ARR4N@users.noreply.github.com>
Co-authored-by: Quentin McGaw <quentin.mcgaw@avalabs.org>
This commit is contained in:
Arran Schlosberg 2025-02-05 10:52:28 +00:00 committed by GitHub
parent be6e93eedb
commit eda3b59f67
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 518 additions and 3 deletions

20
common/pointer.libevm.go Normal file
View file

@ -0,0 +1,20 @@
// 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 common
// PointerTo is a convenience wrapper for creating a pointer to a value.
func PointerTo[T any](x T) *T { return &x }

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
@ -22,6 +22,7 @@ import (
"io"
"github.com/ava-labs/libevm/libevm/pseudo"
"github.com/ava-labs/libevm/libevm/testonly"
"github.com/ava-labs/libevm/rlp"
)
@ -109,5 +110,104 @@ func (*NOOPHeaderHooks) DecodeRLP(h *Header, s *rlp.Stream) error {
type withoutMethods Header
return s.Decode((*withoutMethods)(h))
}
func (*NOOPHeaderHooks) PostCopy(dst *Header) {}
func (n *NOOPHeaderHooks) PostCopy(dst *Header) {}
var (
_ interface {
rlp.Encoder
rlp.Decoder
} = (*Body)(nil)
// The implementations of [Body.EncodeRLP] and [Body.DecodeRLP] make
// assumptions about the struct fields and their order, which we lock in here as a change
// detector. If this breaks then it MUST be updated and the RLP methods
// reviewed + new backwards-compatibility tests added.
_ = &Body{[]*Transaction{}, []*Header{}, []*Withdrawal{}}
)
// EncodeRLP implements the [rlp.Encoder] interface.
func (b *Body) EncodeRLP(dst io.Writer) error {
w := rlp.NewEncoderBuffer(dst)
return w.InList(func() error {
if err := rlp.EncodeListToBuffer(w, b.Transactions); err != nil {
return err
}
if err := rlp.EncodeListToBuffer(w, b.Uncles); err != nil {
return err
}
hasLaterOptionalField := b.Withdrawals != nil
if err := b.hooks().AppendRLPFields(w, hasLaterOptionalField); err != nil {
return err
}
if !hasLaterOptionalField {
return nil
}
return rlp.EncodeListToBuffer(w, b.Withdrawals)
})
}
// DecodeRLP implements the [rlp.Decoder] interface.
func (b *Body) DecodeRLP(s *rlp.Stream) error {
return s.FromList(func() error {
txs, err := rlp.DecodeList[Transaction](s)
if err != nil {
return err
}
uncles, err := rlp.DecodeList[Header](s)
if err != nil {
return err
}
*b = Body{
Transactions: txs,
Uncles: uncles,
}
if err := b.hooks().DecodeExtraRLPFields(s); err != nil {
return err
}
if !s.MoreDataInList() {
return nil
}
ws, err := rlp.DecodeList[Withdrawal](s)
if err != nil {
return err
}
b.Withdrawals = ws
return nil
})
}
// BodyHooks are required for all types registered with [RegisterExtras] for
// [Body] payloads.
type BodyHooks interface {
AppendRLPFields(_ rlp.EncoderBuffer, mustWriteEmptyOptional bool) error
DecodeExtraRLPFields(*rlp.Stream) error
}
// TestOnlyRegisterBodyHooks is a temporary means of "registering" BodyHooks for
// the purpose of testing. It will panic if called outside of a test.
func TestOnlyRegisterBodyHooks(h BodyHooks) {
testonly.OrPanic(func() {
todoRegisteredBodyHooks = h
})
}
// todoRegisteredBodyHooks is a temporary placeholder for "registering"
// BodyHooks, before they are included in [RegisterExtras].
var todoRegisteredBodyHooks BodyHooks = NOOPBodyHooks{}
func (b *Body) hooks() BodyHooks {
// TODO(arr4n): when incorporating BodyHooks into [RegisterExtras], the
// [todoRegisteredBodyHooks] variable MUST be removed.
return todoRegisteredBodyHooks
}
// NOOPBodyHooks implements [BodyHooks] such that they are equivalent to no type
// having been registered.
type NOOPBodyHooks struct{}
func (NOOPBodyHooks) AppendRLPFields(rlp.EncoderBuffer, bool) error { return nil }
func (NOOPBodyHooks) DecodeExtraRLPFields(*rlp.Stream) error { return nil }

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
@ -20,10 +20,14 @@ import (
"encoding/hex"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/kr/pretty"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ava-labs/libevm/common"
. "github.com/ava-labs/libevm/core/types"
"github.com/ava-labs/libevm/libevm/cmpeth"
"github.com/ava-labs/libevm/libevm/ethtest"
"github.com/ava-labs/libevm/rlp"
)
@ -106,3 +110,195 @@ func testHeaderRLPBackwardsCompatibility(t *testing.T) {
assert.Equal(t, hdr, got)
})
}
func TestBodyRLPBackwardsCompatibility(t *testing.T) {
newTx := func(nonce uint64) *Transaction { return NewTx(&LegacyTx{Nonce: nonce}) }
newHdr := func(hashLow byte) *Header { return &Header{ParentHash: common.Hash{hashLow}} }
newWithdraw := func(idx uint64) *Withdrawal { return &Withdrawal{Index: idx} }
// We build up test-case [Body] instances from the power set of each of
// these components.
txMatrix := [][]*Transaction{
nil, {}, // Must be equivalent for non-optional field
{newTx(1)},
{newTx(2), newTx(3)}, // Demonstrates nested lists
}
uncleMatrix := [][]*Header{
nil, {},
{newHdr(1)},
{newHdr(2), newHdr(3)},
}
withdrawMatrix := [][]*Withdrawal{
nil, {}, // Must be different for optional field
{newWithdraw(1)},
{newWithdraw(2), newWithdraw(3)},
}
var bodies []*Body
for _, tx := range txMatrix {
for _, u := range uncleMatrix {
for _, w := range withdrawMatrix {
bodies = append(bodies, &Body{tx, u, w})
}
}
}
for _, body := range bodies {
t.Run("", func(t *testing.T) {
t.Logf("\n%s", pretty.Sprint(body))
// The original [Body] doesn't implement [rlp.Encoder] nor
// [rlp.Decoder] so we can use a methodless equivalent as the gold
// standard.
type withoutMethods Body
wantRLP, err := rlp.EncodeToBytes((*withoutMethods)(body))
require.NoErrorf(t, err, "rlp.EncodeToBytes([%T with methods stripped])", body)
t.Run("Encode", func(t *testing.T) {
got, err := rlp.EncodeToBytes(body)
require.NoErrorf(t, err, "rlp.EncodeToBytes(%#v)", body)
assert.Equalf(t, wantRLP, got, "rlp.EncodeToBytes(%#v)", body)
})
t.Run("Decode", func(t *testing.T) {
got := new(Body)
err := rlp.DecodeBytes(wantRLP, got)
require.NoErrorf(t, err, "rlp.DecodeBytes(%v, %T)", wantRLP, got)
want := body
// Regular RLP decoding will never leave these non-optional
// fields nil.
if want.Transactions == nil {
want.Transactions = []*Transaction{}
}
if want.Uncles == nil {
want.Uncles = []*Header{}
}
opts := cmp.Options{
cmpeth.CompareHeadersByHash(),
cmpeth.CompareTransactionsByBinary(t),
}
if diff := cmp.Diff(body, got, opts); diff != "" {
t.Errorf("rlp.DecodeBytes(rlp.EncodeToBytes(%#v)) diff (-want +got):\n%s", body, diff)
}
})
})
}
}
// cChainBodyExtras carries the same additional fields as the Avalanche C-Chain
// (ava-labs/coreth) [Body] and implements [BodyHooks] to achieve equivalent RLP
// {en,de}coding.
type cChainBodyExtras struct {
Version uint32
ExtData *[]byte
}
var _ BodyHooks = (*cChainBodyExtras)(nil)
func (e *cChainBodyExtras) AppendRLPFields(b rlp.EncoderBuffer, _ bool) error {
b.WriteUint64(uint64(e.Version))
var data []byte
if e.ExtData != nil {
data = *e.ExtData
}
b.WriteBytes(data)
return nil
}
func (e *cChainBodyExtras) DecodeExtraRLPFields(s *rlp.Stream) error {
if err := s.Decode(&e.Version); err != nil {
return err
}
buf, err := s.Bytes()
if err != nil {
return err
}
if len(buf) > 0 {
e.ExtData = &buf
} else {
// Respect the `rlp:"nil"` field tag.
e.ExtData = nil
}
return nil
}
func TestBodyRLPCChainCompat(t *testing.T) {
// The inputs to this test were used to generate the expected RLP with
// ava-labs/coreth. This serves as both an example of how to use [BodyHooks]
// and a test of compatibility.
t.Cleanup(func() {
TestOnlyRegisterBodyHooks(NOOPBodyHooks{})
})
body := &Body{
Transactions: []*Transaction{
NewTx(&LegacyTx{
Nonce: 42,
To: common.PointerTo(common.HexToAddress(`decafc0ffeebad`)),
}),
},
Uncles: []*Header{ /* RLP encoding differs in ava-labs/coreth */ },
}
const version = 314159
tests := []struct {
name string
extra *cChainBodyExtras
// WARNING: changing these values might break backwards compatibility of
// RLP encoding!
wantRLPHex string
}{
{
extra: &cChainBodyExtras{
Version: version,
},
wantRLPHex: `e5dedd2a80809400000000000000000000000000decafc0ffeebad8080808080c08304cb2f80`,
},
{
extra: &cChainBodyExtras{
Version: version,
ExtData: &[]byte{1, 4, 2, 8, 5, 7},
},
wantRLPHex: `ebdedd2a80809400000000000000000000000000decafc0ffeebad8080808080c08304cb2f86010402080507`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
wantRLP, err := hex.DecodeString(tt.wantRLPHex)
require.NoErrorf(t, err, "hex.DecodeString(%q)", tt.wantRLPHex)
t.Run("Encode", func(t *testing.T) {
TestOnlyRegisterBodyHooks(tt.extra)
got, err := rlp.EncodeToBytes(body)
require.NoErrorf(t, err, "rlp.EncodeToBytes(%+v)", body)
assert.Equalf(t, wantRLP, got, "rlp.EncodeToBytes(%+v)", body)
})
t.Run("Decode", func(t *testing.T) {
var extra cChainBodyExtras
TestOnlyRegisterBodyHooks(&extra)
got := new(Body)
err := rlp.DecodeBytes(wantRLP, got)
require.NoErrorf(t, err, "rlp.DecodeBytes(%#x, %T)", wantRLP, got)
assert.Equal(t, tt.extra, &extra, "rlp.DecodeBytes(%#x, [%T as registered extra in %T carrier])", wantRLP, &extra, got)
opts := cmp.Options{
cmpeth.CompareHeadersByHash(),
cmpeth.CompareTransactionsByBinary(t),
}
if diff := cmp.Diff(body, got, opts); diff != "" {
t.Errorf("rlp.DecodeBytes(%#x, [%T while carrying registered %T extra payload]) diff (-want +got):\n%s", wantRLP, got, &extra, diff)
}
})
})
}
}

66
libevm/cmpeth/cmpeth.go Normal file
View file

@ -0,0 +1,66 @@
// 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 cmpeth provides ETH-specific options for the cmp package.
package cmpeth
import (
"bytes"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/ava-labs/libevm/core/types"
)
// CompareHeadersByHash returns an option to compare Headers based on
// [types.Header.Hash] equality.
func CompareHeadersByHash() cmp.Option {
return cmp.Comparer(func(a, b *types.Header) bool {
return a.Hash() == b.Hash()
})
}
// CompareTransactionsByBinary returns an option to compare Transactions based
// on [types.Transaction.MarshalBinary] equality. Two nil pointers are
// considered equal.
//
// If MarshalBinary() returns an error, it will be reported with
// [testing.TB.Fatal].
func CompareTransactionsByBinary(tb testing.TB) cmp.Option {
tb.Helper()
return cmp.Comparer(func(a, b *types.Transaction) bool {
tb.Helper()
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return bytes.Equal(marshalTxBinary(tb, a), marshalTxBinary(tb, b))
})
}
func marshalTxBinary(tb testing.TB, tx *types.Transaction) []byte {
tb.Helper()
buf, err := tx.MarshalBinary()
if err != nil {
tb.Fatalf("%T.MarshalBinary() error %v", tx, err)
}
return buf
}

77
rlp/list.libevm.go Normal file
View file

@ -0,0 +1,77 @@
// 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 rlp
// InList is a convenience wrapper, calling `fn` between calls to
// [EncoderBuffer.List] and [EncoderBuffer.ListEnd]. If `fn` returns an error,
// it is propagated directly.
func (b EncoderBuffer) InList(fn func() error) error {
l := b.List()
if err := fn(); err != nil {
return err
}
b.ListEnd(l)
return nil
}
// EncodeListToBuffer is equivalent to [Encode], writing the RLP encoding of
// each element to `b`, except that it wraps the writes inside a call to
// [EncoderBuffer.InList].
func EncodeListToBuffer[T any](b EncoderBuffer, vals []T) error {
return b.InList(func() error {
for _, v := range vals {
if err := Encode(b, v); err != nil {
return err
}
}
return nil
})
}
// FromList is a convenience wrapper, calling `fn` between calls to
// [Stream.List] and [Stream.ListEnd]. If `fn` returns an error, it is
// propagated directly.
func (s *Stream) FromList(fn func() error) error {
if _, err := s.List(); err != nil {
return err
}
if err := fn(); err != nil {
return err
}
return s.ListEnd()
}
// DecodeList assumes that the next item in `s` is a list and decodes every item
// in said list to a `*T`.
//
// The returned slice is guaranteed to be non-nil, even if the list is empty.
// This is in keeping with other behaviour in this package and it is therefore
// the responsibility of callers to respect `rlp:"nil"` struct tags.
func DecodeList[T any](s *Stream) ([]*T, error) {
vals := []*T{}
err := s.FromList(func() error {
for s.MoreDataInList() {
var v T
if err := s.Decode(&v); err != nil {
return err
}
vals = append(vals, &v)
}
return nil
})
return vals, err
}

56
rlp/list.libevm_test.go Normal file
View file

@ -0,0 +1,56 @@
// 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 rlp
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestEncodeListToBuffer(t *testing.T) {
vals := []uint{1, 2, 3, 4, 5}
want, err := EncodeToBytes(vals)
require.NoErrorf(t, err, "EncodeToBytes(%T{%[1]v})", vals)
var got bytes.Buffer
buf := NewEncoderBuffer(&got)
err = EncodeListToBuffer(buf, vals)
require.NoErrorf(t, err, "EncodeListToBuffer(..., %T{%[1]v})", vals)
require.NoErrorf(t, buf.Flush(), "%T.Flush()", buf)
assert.Equal(t, want, got.Bytes(), "EncodeListToBuffer(..., %T{%[1]v})", vals)
}
func TestDecodeList(t *testing.T) {
vals := []uint{0, 1, 42, 314159}
rlp, err := EncodeToBytes(vals)
require.NoErrorf(t, err, "EncodeToBytes(%T{%[1]v})", vals)
s := NewStream(bytes.NewReader(rlp), 0)
got, err := DecodeList[uint](s)
require.NoErrorf(t, err, "DecodeList[%T]()", vals[0])
require.Equal(t, len(vals), len(got), "number of values returned by DecodeList()")
for i, gotPtr := range got {
assert.Equalf(t, vals[i], *gotPtr, "DecodeList()[%d]", i)
}
}