diff --git a/common/pointer.libevm.go b/common/pointer.libevm.go
new file mode 100644
index 000000000000..c51b930ee31c
--- /dev/null
+++ b/common/pointer.libevm.go
@@ -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
+// .
+
+package common
+
+// PointerTo is a convenience wrapper for creating a pointer to a value.
+func PointerTo[T any](x T) *T { return &x }
diff --git a/core/types/block.libevm.go b/core/types/block.libevm.go
index 9258eefac799..094613ee1e33 100644
--- a/core/types/block.libevm.go
+++ b/core/types/block.libevm.go
@@ -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) {}
+
+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 (n *NOOPHeaderHooks) PostCopy(dst *Header) {}
+func (NOOPBodyHooks) AppendRLPFields(rlp.EncoderBuffer, bool) error { return nil }
+func (NOOPBodyHooks) DecodeExtraRLPFields(*rlp.Stream) error { return nil }
diff --git a/core/types/rlp_backwards_compat.libevm_test.go b/core/types/rlp_backwards_compat.libevm_test.go
index 8b22eac29752..09cff5bd1e14 100644
--- a/core/types/rlp_backwards_compat.libevm_test.go
+++ b/core/types/rlp_backwards_compat.libevm_test.go
@@ -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)
+ }
+ })
+ })
+ }
+}
diff --git a/libevm/cmpeth/cmpeth.go b/libevm/cmpeth/cmpeth.go
new file mode 100644
index 000000000000..8d17e3e51921
--- /dev/null
+++ b/libevm/cmpeth/cmpeth.go
@@ -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
+// .
+
+// 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
+}
diff --git a/rlp/list.libevm.go b/rlp/list.libevm.go
new file mode 100644
index 000000000000..2fd74ce645d6
--- /dev/null
+++ b/rlp/list.libevm.go
@@ -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
+// .
+
+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
+}
diff --git a/rlp/list.libevm_test.go b/rlp/list.libevm_test.go
new file mode 100644
index 000000000000..8e2c76711c95
--- /dev/null
+++ b/rlp/list.libevm_test.go
@@ -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
+// .
+
+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)
+ }
+}