diff --git a/core/types/block.libevm.go b/core/types/block.libevm.go
index 094613ee1e3..832fe421f0a 100644
--- a/core/types/block.libevm.go
+++ b/core/types/block.libevm.go
@@ -112,79 +112,26 @@ func (*NOOPHeaderHooks) DecodeRLP(h *Header, s *rlp.Stream) error {
}
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{}}
-)
+var _ interface {
+ rlp.Encoder
+ rlp.Decoder
+} = (*Body)(nil)
// 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)
- })
+func (b *Body) EncodeRLP(w io.Writer) error {
+ return b.hooks().RLPFieldsForEncoding(b).EncodeRLP(w)
}
// 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
- })
+ return b.hooks().RLPFieldPointersForDecoding(b).DecodeRLP(s)
}
// 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
+ RLPFieldsForEncoding(*Body) *rlp.Fields
+ RLPFieldPointersForDecoding(*Body) *rlp.Fields
}
// TestOnlyRegisterBodyHooks is a temporary means of "registering" BodyHooks for
@@ -209,5 +156,22 @@ func (b *Body) hooks() BodyHooks {
// having been registered.
type NOOPBodyHooks struct{}
-func (NOOPBodyHooks) AppendRLPFields(rlp.EncoderBuffer, bool) error { return nil }
-func (NOOPBodyHooks) DecodeExtraRLPFields(*rlp.Stream) error { return nil }
+// The RLP-related methods of [NOOPBodyHooks] 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.
+var _ = &Body{[]*Transaction{}, []*Header{}, []*Withdrawal{}}
+
+func (NOOPBodyHooks) RLPFieldsForEncoding(b *Body) *rlp.Fields {
+ return &rlp.Fields{
+ Required: []any{b.Transactions, b.Uncles},
+ Optional: []any{b.Withdrawals},
+ }
+}
+
+func (NOOPBodyHooks) RLPFieldPointersForDecoding(b *Body) *rlp.Fields {
+ return &rlp.Fields{
+ Required: []any{&b.Transactions, &b.Uncles},
+ Optional: []any{&b.Withdrawals},
+ }
+}
diff --git a/core/types/rlp_backwards_compat.libevm_test.go b/core/types/rlp_backwards_compat.libevm_test.go
index 09cff5bd1e1..673bf548c39 100644
--- a/core/types/rlp_backwards_compat.libevm_test.go
+++ b/core/types/rlp_backwards_compat.libevm_test.go
@@ -116,8 +116,8 @@ func TestBodyRLPBackwardsCompatibility(t *testing.T) {
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.
+ // We build up test-case [Body] instances from the Cartesian product of each
+ // of these components.
txMatrix := [][]*Transaction{
nil, {}, // Must be equivalent for non-optional field
{newTx(1)},
@@ -197,35 +197,33 @@ type cChainBodyExtras struct {
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
+func (e *cChainBodyExtras) RLPFieldsForEncoding(b *Body) *rlp.Fields {
+ // The Avalanche C-Chain uses all of the geth required fields (but none of
+ // the optional ones) so there's no need to explicitly list them. This
+ // pattern might not be ideal for readability but is used here for
+ // demonstrative purposes.
+ //
+ // All new fields will always be tagged as optional for backwards
+ // compatibility so this is safe to do, but only for the required fields.
+ return &rlp.Fields{
+ Required: append(
+ NOOPBodyHooks{}.RLPFieldsForEncoding(b).Required,
+ e.Version, 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
+func (e *cChainBodyExtras) RLPFieldPointersForDecoding(b *Body) *rlp.Fields {
+ // An alternative to the pattern used above is to explicitly list all
+ // fields for better introspection.
+ return &rlp.Fields{
+ Required: []any{
+ &b.Transactions,
+ &b.Uncles,
+ &e.Version,
+ rlp.Nillable(&e.ExtData), // equivalent to `rlp:"nil"`
+ },
}
-
- return nil
}
func TestBodyRLPCChainCompat(t *testing.T) {
@@ -256,12 +254,14 @@ func TestBodyRLPCChainCompat(t *testing.T) {
wantRLPHex string
}{
{
+ name: "nil_ExtData",
extra: &cChainBodyExtras{
Version: version,
},
wantRLPHex: `e5dedd2a80809400000000000000000000000000decafc0ffeebad8080808080c08304cb2f80`,
},
{
+ name: "non-nil_ExtData",
extra: &cChainBodyExtras{
Version: version,
ExtData: &[]byte{1, 4, 2, 8, 5, 7},
diff --git a/rlp/fields.libevm.go b/rlp/fields.libevm.go
new file mode 100644
index 00000000000..9595e2e32e3
--- /dev/null
+++ b/rlp/fields.libevm.go
@@ -0,0 +1,139 @@
+// 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 (
+ "errors"
+ "fmt"
+ "io"
+ "reflect"
+)
+
+// Fields mirror the RLP encoding of struct fields.
+type Fields struct {
+ Required []any
+ Optional []any // equivalent to those tagged with `rlp:"optional"`
+}
+
+var _ interface {
+ Encoder
+ Decoder
+} = (*Fields)(nil)
+
+// EncodeRLP encodes the `f.Required` and `f.Optional` slices to `w`,
+// concatenated as a single list, as if they were fields in a struct. The
+// optional values are treated identically to those tagged with
+// `rlp:"optional"`.
+func (f *Fields) EncodeRLP(w io.Writer) error {
+ includeOptional, err := f.optionalInclusionFlags()
+ if err != nil {
+ return err
+ }
+
+ b := NewEncoderBuffer(w)
+ err = b.InList(func() error {
+ for _, v := range f.Required {
+ if err := Encode(b, v); err != nil {
+ return err
+ }
+ }
+
+ for i, v := range f.Optional {
+ if !includeOptional[i] {
+ return nil
+ }
+ if err := Encode(b, v); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+ if err != nil {
+ return err
+ }
+ return b.Flush()
+}
+
+var errUnsupportedOptionalFieldType = errors.New("unsupported optional field type")
+
+// optionalInclusionFlags returns a slice of booleans, the same length as
+// `f.Optional`, indicating whether or not the respective field MUST be written
+// to a list. A field must be written if it or any later field value is non-nil;
+// the returned slice is therefore monotonic non-increasing from true to false.
+func (f *Fields) optionalInclusionFlags() ([]bool, error) {
+ flags := make([]bool, len(f.Optional))
+ var include bool
+ for i := len(f.Optional) - 1; i >= 0; i-- {
+ switch v := reflect.ValueOf(f.Optional[i]); v.Kind() {
+ case reflect.Slice, reflect.Pointer:
+ include = include || !v.IsNil()
+ default:
+ return nil, fmt.Errorf("%w: %T", errUnsupportedOptionalFieldType, f.Optional[i])
+ }
+ flags[i] = include
+ }
+ return flags, nil
+}
+
+// DecodeRLP implements the [Decoder] interface. All destination fields, be they
+// required or optional, MUST be pointers and all optional fields MUST be
+// provided in case they are present in the RLP being decoded.
+//
+// Typically, the arguments to this method mirror those passed to
+// [Fields.EncodeRLP] except for being pointers. See the example.
+func (f *Fields) DecodeRLP(s *Stream) error {
+ return s.FromList(func() error {
+ for _, v := range f.Required {
+ if err := s.Decode(v); err != nil {
+ return err
+ }
+ }
+
+ for _, v := range f.Optional {
+ if !s.MoreDataInList() {
+ return nil
+ }
+ if err := s.Decode(v); err != nil {
+ return err
+ }
+ }
+ return nil
+ })
+}
+
+// Nillable wraps `field` to mirror the behaviour of an `rlp:"nil"` tag; i.e. if
+// a zero-sized RLP item is decoded into the returned Decoder then it is dropped
+// and `*field` is set to nil, otherwise the RLP item is decoded directly into
+// `field`. The return argument is intended for use with [Fields].
+func Nillable[T any](field **T) Decoder {
+ return &nillable[T]{field}
+}
+
+type nillable[T any] struct{ v **T }
+
+func (n *nillable[T]) DecodeRLP(s *Stream) error {
+ _, size, err := s.Kind()
+ if err != nil {
+ return err
+ }
+ if size > 0 {
+ return s.Decode(n.v)
+ }
+ *n.v = nil
+ _, err = s.Raw() // consume the item
+ return err
+}
diff --git a/rlp/fields.libevm_test.go b/rlp/fields.libevm_test.go
new file mode 100644
index 00000000000..0eead78489b
--- /dev/null
+++ b/rlp/fields.libevm_test.go
@@ -0,0 +1,218 @@
+// 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"
+ "io"
+ "testing"
+
+ "github.com/kr/pretty"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ava-labs/libevm/common"
+)
+
+func TestFields(t *testing.T) {
+ type foo struct {
+ A uint64
+ B uint64
+ C *uint64
+ D *uint64 `rlp:"optional"`
+ E []uint64 `rlp:"optional"`
+ F *[]uint64 `rlp:"optional"`
+ }
+
+ const (
+ a uint64 = iota
+ b
+ cVal
+ dVal
+ )
+ c := common.PointerTo(cVal)
+ d := common.PointerTo(dVal)
+ e := []uint64{40, 41}
+ f := &[]uint64{50, 51}
+
+ tests := []foo{
+ {a, b, c, d, e, f}, // 000 (which of d/e/f are nil)
+ {a, b, c, d, e, nil}, // 001
+ {a, b, c, d, nil, f}, // 010
+ {a, b, c, d, nil, nil}, // 011
+ {a, b, c, nil, e, f}, // 100
+ {a, b, c, nil, e, nil}, // 101
+ {a, b, c, nil, nil, f}, // 110
+ {a, b, c, nil, nil, nil}, // 111
+ // Empty and nil slices are treated differently when optional
+ {a, b, c, nil, []uint64{}, nil},
+ {a, b, c, nil, nil, &[]uint64{}},
+ }
+
+ for _, obj := range tests {
+ t.Run("", func(t *testing.T) {
+ t.Logf("\n%s", pretty.Sprint(obj))
+
+ wantRLP, err := EncodeToBytes(obj)
+ require.NoErrorf(t, err, "EncodeToBytes([actual struct])")
+
+ t.Run("Fields.EncodeRLP", func(t *testing.T) {
+ got, err := EncodeToBytes(&Fields{
+ Required: []any{obj.A, obj.B, obj.C},
+ Optional: []any{obj.D, obj.E, obj.F},
+ })
+ require.NoError(t, err)
+ assert.Equal(t, wantRLP, got, "vs EncodeToBytes([actual struct])")
+ })
+
+ t.Run("Fields.DecodeRLP", func(t *testing.T) {
+ var got foo
+ err := DecodeBytes(wantRLP, &Fields{
+ Required: []any{&got.A, &got.B, &got.C},
+ Optional: []any{&got.D, &got.E, &got.F},
+ })
+ require.NoError(t, err, "DecodeBytes(..., %T)", &Fields{})
+
+ var want foo
+ err = DecodeBytes(wantRLP, &want)
+ require.NoError(t, err, "DecodeBytes(..., [actual struct])")
+
+ assert.Equal(t, want, got, "vs DecodeBytes(..., [original struct])")
+ })
+ })
+ }
+}
+
+//nolint:testableexamples // Demonstrating code equivalence, not outputs.
+func ExampleFields() {
+ type inner struct {
+ X uint64
+ }
+
+ type outer struct {
+ A uint64
+ B *inner `rlp:"nil"`
+ C *inner `rlp:"optional"`
+ }
+
+ val := outer{
+ A: 42,
+ B: &inner{X: 42},
+ C: &inner{X: 99},
+ }
+
+ // Errors are dropped for brevity for the sake of the example only.
+
+ _ = Encode(io.Discard, val)
+ // is equivalent to
+ _ = Encode(
+ io.Discard,
+ &Fields{
+ Required: []any{val.A, val.B},
+ Optional: []any{val.C},
+ },
+ )
+
+ var (
+ r *bytes.Reader // arbitrary RLP buffer
+ decoded outer
+ )
+
+ _ = Decode(r, &decoded)
+ // is equivalent to
+ _ = Decode(r, &Fields{
+ Required: []any{
+ &decoded.A,
+ Nillable(&decoded.B),
+ },
+ Optional: []any{&decoded.C},
+ })
+
+ // Note the parallels between the arguments passed to
+ // Fields.{En,De}codeRLP() and that, when decoding an optional or
+ // `rlp:"nil`-tagged field, a pointer to the _field_ is required even though
+ // in this example it will be a `**inner`.
+}
+
+func TestNillable(t *testing.T) {
+ type inner struct {
+ X uint64
+ }
+
+ type outer struct {
+ A *uint64 `rlp:"nil"`
+ B *inner `rlp:"nil"`
+ C *[]uint64 `rlp:"nil"`
+ }
+
+ // Unlike the `rlp:"optional"` tag, there is no interplay between nil-tagged
+ // fields so we don't need the Cartesian product of all possible
+ // combinations.
+ var tests []outer
+ for _, a := range []*uint64{
+ nil,
+ common.PointerTo[uint64](0),
+ } {
+ tests = append(tests, outer{a, nil, nil})
+ }
+ for _, b := range []*inner{
+ nil,
+ {0},
+ } {
+ tests = append(tests, outer{nil, b, nil})
+ }
+ for _, c := range []*[]uint64{
+ nil,
+ {},
+ {0},
+ } {
+ tests = append(tests, outer{nil, nil, c})
+ }
+
+ // When a Nillable encounters an empty list it MUST set the field to nil,
+ // not just ignore it.
+ corruptInitialValue := func() outer {
+ return outer{common.PointerTo[uint64](42), &inner{42}, &[]uint64{42}}
+ }
+
+ for _, obj := range tests {
+ t.Run("", func(t *testing.T) {
+ rlp, err := EncodeToBytes(obj)
+ require.NoErrorf(t, err, "EncodeToBytes(%+v)", obj)
+ t.Logf("%s => %#x", pretty.Sprint(obj), rlp)
+
+ // Although this is an immediate inversion of the line above, it
+ // provides us with the canonical RLP decoding, which our input
+ // struct may not honour.
+ want := corruptInitialValue()
+ err = DecodeBytes(rlp, &want)
+ require.NoErrorf(t, err, "DecodeBytes(%#x, %T)", rlp, &want)
+
+ got := corruptInitialValue()
+ err = DecodeBytes(rlp, &Fields{
+ Required: []any{
+ Nillable(&got.A),
+ Nillable(&got.B),
+ Nillable(&got.C),
+ },
+ })
+ require.NoErrorf(t, err, "DecodeBytes(..., %T)", &Fields{})
+
+ assert.Equal(t, want, got, "DecodeBytes(..., [actual struct]) vs DecodeBytes(..., [fields wrapped in Nillable()])")
+ })
+ }
+}