Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions core/types/backwards_compat.libevm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,6 @@ func (e *cChainBodyExtras) Copy() *cChainBodyExtras {
panic("unimplemented")
}

func (e *cChainBodyExtras) BlockRLPFieldsForEncoding(b *BlockRLPProxy) *rlp.Fields {
panic("unimplemented")
}

func (e *cChainBodyExtras) BlockRLPFieldPointersForDecoding(b *BlockRLPProxy) *rlp.Fields {
panic("unimplemented")
}

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]
Expand Down
6 changes: 3 additions & 3 deletions core/types/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ type extblock struct {
Uncles []*Header
Withdrawals []*Withdrawal `rlp:"optional"`

hooks BlockBodyHooks // libevm: MUST be unexported + populated from [Block.hooks]
extra *pseudo.Type // libevm: MUST be unexported + populated from [Block.extraOrNil]
}

// NewBlock creates a new block. The input data is copied, changes to header and to the
Expand Down Expand Up @@ -322,7 +322,7 @@ func CopyHeader(h *Header) *Header {
// DecodeRLP decodes a block from RLP.
func (b *Block) DecodeRLP(s *rlp.Stream) error {
var eb extblock
eb.hooks = b.hooks()
eb.extra = b.extraOrNil()
_, size, _ := s.Kind()
if err := s.Decode(&eb); err != nil {
return err
Expand All @@ -339,7 +339,7 @@ func (b *Block) EncodeRLP(w io.Writer) error {
Txs: b.transactions,
Uncles: b.uncles,
Withdrawals: b.withdrawals,
hooks: b.hooks(),
extra: b.extraOrNil(),
})
}

Expand Down
97 changes: 68 additions & 29 deletions core/types/block.libevm.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package types

import (
"encoding/json"
"fmt"
"io"

"github.com/ava-labs/libevm/internal/libevm/pseudo"
Expand Down Expand Up @@ -106,25 +107,80 @@ func (b *Body) DecodeRLP(s *rlp.Stream) error {
return b.hooks().BodyRLPFieldPointersForDecoding(b).DecodeRLP(s)
}

// BlockRLPProxy exports the geth-internal type used for RLP {en,de}coding of a
// [Block].
type BlockRLPProxy extblock

func (b *extblock) EncodeRLP(w io.Writer) error {
bb := (*BlockRLPProxy)(b)
return b.hooks.BlockRLPFieldsForEncoding(bb).EncodeRLP(w)
body := Body{
Transactions: b.Txs,
Uncles: b.Uncles,
Withdrawals: b.Withdrawals,
extra: b.extra,
}
bodyFields := body.hooks().BodyRLPFieldsForEncoding(&body)
blockFields := rlp.Fields{
Required: append(
[]any{b.Header},
bodyFields.Required...,
),
Optional: bodyFields.Optional,
}
return blockFields.EncodeRLP(w)
}

func (b *extblock) DecodeRLP(s *rlp.Stream) error {
bb := (*BlockRLPProxy)(b)
return b.hooks.BlockRLPFieldPointersForDecoding(bb).DecodeRLP(s)
body := Body{
// The body provided to the hooks is expected to contain the same extra
// as the method receiver.
extra: b.extra,
Comment on lines +130 to +132

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was really the annoying part. It's kind of weird, since I don't think an implementation should actually use this... But I think we should guarantee that it is provided correctly. Thoughts on simplifying this @ARR4N?

}
bodyFields := body.hooks().BodyRLPFieldPointersForDecoding(&body)
blockFields := rlp.Fields{
Required: append(
[]any{&b.Header},
bodyFields.Required...,
),
Optional: bodyFields.Optional,
}
if err := blockFields.DecodeRLP(s); err != nil {
return err
}
b.Txs = body.Transactions
b.Uncles = body.Uncles
b.Withdrawals = body.Withdrawals
return nil
}

// BlockBytes combines an RLP encoded [Header] and [Body] into an RLP encoded
// [Block].
//
// For correctly formatted inputs it is a faster equivalent of:
// - Decoding into a [Header] and [Body]
// - Combining them into a Block
// - Encoding the Block
//
// This function does NOT validate the header or body.
func BlockBytes(headerBytes, bodyBytes []byte) ([]byte, error) {
bodyFields, _, err := rlp.SplitList(bodyBytes)
if err != nil {
return nil, fmt.Errorf("splitting body: %w", err)
}

Comment thread
StephenButtolph marked this conversation as resolved.
Outdated
w := rlp.NewEncoderBuffer(nil)
l := w.List()
if _, err := w.Write(headerBytes); err != nil {
return nil, fmt.Errorf("writing header: %w", err)
}
if _, err := w.Write(bodyFields); err != nil {
return nil, fmt.Errorf("writing body: %w", err)
}
w.ListEnd(l)
blockBytes := w.ToBytes()
return blockBytes, w.Flush() // Flush returns the internal buffer to the pool.
Comment thread
StephenButtolph marked this conversation as resolved.
Outdated
}

// BlockBodyHooks are required for all types registered with [RegisterExtras]
// for [Block] and [Body] payloads.
// for [Block] and [Body] payloads. The same methods are used for both [Block]
// and [Body] {en,de}coding as a Block is encoded as its [Header] followed by
// the fields of its [Body].
type BlockBodyHooks interface {
BlockRLPFieldsForEncoding(*BlockRLPProxy) *rlp.Fields
BlockRLPFieldPointersForDecoding(*BlockRLPProxy) *rlp.Fields
BodyRLPFieldsForEncoding(*Body) *rlp.Fields
BodyRLPFieldPointersForDecoding(*Body) *rlp.Fields
Comment thread
StephenButtolph marked this conversation as resolved.
PostRPCMarshal(b *Block, marshalled map[string]any)
Expand All @@ -149,27 +205,10 @@ var (
}
_ = extblock{
&Header{}, []*Transaction{}, []*Header{}, []*Withdrawal{}, // geth
BlockBodyHooks(nil), // libevm
&pseudo.Type{}, // libevm
}
// Demonstrate identity of these two types, by definition but useful for
// inspection here.
_ = extblock(BlockRLPProxy{})
)

func (NOOPBlockBodyHooks) BlockRLPFieldsForEncoding(b *BlockRLPProxy) *rlp.Fields {
return &rlp.Fields{
Required: []any{b.Header, b.Txs, b.Uncles},
Optional: []any{b.Withdrawals},
}
}

func (NOOPBlockBodyHooks) BlockRLPFieldPointersForDecoding(b *BlockRLPProxy) *rlp.Fields {
return &rlp.Fields{
Required: []any{&b.Header, &b.Txs, &b.Uncles},
Optional: []any{&b.Withdrawals},
}
}

func (NOOPBlockBodyHooks) BodyRLPFieldsForEncoding(b *Body) *rlp.Fields {
return &rlp.Fields{
Required: []any{b.Transactions, b.Uncles},
Expand Down
209 changes: 209 additions & 0 deletions core/types/block.libevm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"fmt"
"io"
"reflect"
"slices"
"strings"
"testing"

Expand All @@ -33,6 +34,7 @@ import (
"github.com/ava-labs/libevm/internal/libevm/pseudo"
"github.com/ava-labs/libevm/libevm/ethtest"
"github.com/ava-labs/libevm/rlp"
"github.com/ava-labs/libevm/trie"
)

type stubHeaderHooks struct {
Expand Down Expand Up @@ -272,3 +274,210 @@ func TestBlockWithX(t *testing.T) {
})
}
}

// bodyPayload is a [types.BlockBodyHooks] implementation carrying an extra
// field that is {en,de}coded as if it was a regular RLP field of both the
// [types.Block] and [types.Body].
type bodyPayload struct {
Data []byte

NOOPBlockBodyHooks
}

func (p *bodyPayload) Copy() *bodyPayload {
return &bodyPayload{
Data: slices.Clone(p.Data),
}
}

var rlpBodyPayloads pseudo.Accessor[*Body, *bodyPayload]

func (*bodyPayload) BodyRLPFieldsForEncoding(b *Body) *rlp.Fields {
// Rather than using the receiver directly, we access it through b. This
// demonstrates that the hooks can access their own payload via the
// [types.Body] they are passed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is an anti-pattern because it doesn't demonstrate that the hook was called on the correct payload. The idiomatic implementation is to use the receiver as it doesn't require hooks to have access to the pseudo.Accessor.

Is there a specific reason you'd want a hook to access its payload via the carrying struct?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is an anti-pattern because it doesn't demonstrate that the hook was called on the correct payload. The idiomatic implementation is to use the receiver as it doesn't require hooks to have access to the pseudo.Accessor.

I agree. This test is specifically ensuring that a "non-idiomatic" implementation works.

Is there a specific reason you'd want a hook to access its payload via the carrying struct?

I don't think an implementation SHOULD do this. But they CAN. So (imo) we MUST support that (or very clearly document that this isn't allowed, and vet that we don't do this in coreth / subnet-evm).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I iterated on this for awhile and eventually realized the thing I was asserting wasn't even checking for the bug I was trying to prevent.

I do think it could be useful to test that the receiver == the provided arg... But really the concern with this change is that we need to put the block extra (without a copy) as the body extra so that marshal and unmarshal works correctly.

By switching just to this I was able to reuse blockPayload and reduce the testing surface.

I had initially considered adding this test as part of TestBlockWithX (and instead making that TestBlockHooks) - but I feel like that would be missing a bunch of coverage.

p := rlpBodyPayloads.Get(b)
Comment thread
StephenButtolph marked this conversation as resolved.
Outdated
return &rlp.Fields{
Required: []any{b.Transactions, b.Uncles, p.Data},
Optional: []any{b.Withdrawals},
}
}

func (*bodyPayload) BodyRLPFieldPointersForDecoding(b *Body) *rlp.Fields {
// See above comment on why we access the receiver through b rather than
// directly.
Comment thread
StephenButtolph marked this conversation as resolved.
Outdated
p := rlpBodyPayloads.Get(b)
return &rlp.Fields{
Required: []any{&b.Transactions, &b.Uncles, &p.Data},
Optional: []any{&b.Withdrawals},
}
}

// TestBodyExtraRoundTrip demonstrates that the extra from the method receiver
// is the same as the extra from the argument for [types.BlockBodyHooks]
// functions.
func TestBodyExtraRoundTrip(t *testing.T) {
TestOnlyClearRegisteredExtras()
t.Cleanup(TestOnlyClearRegisteredExtras)

extras := RegisterExtras[
NOOPHeaderHooks, *NOOPHeaderHooks,
bodyPayload, *bodyPayload,
struct{},
]()
rlpBodyPayloads = extras.Body

rng := ethtest.NewPseudoRand(142857)
wantBlock := NewBlock(
&Header{ParentHash: rng.Hash()},
[]*Transaction{
NewTx(&LegacyTx{Nonce: rng.Uint64()}),
},
[]*Header{
{ParentHash: rng.Hash()},
},
nil,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why no receipts?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replaced NewBlock with NewBlockWithHeader. I had originally not provided receipts because they don't impact the serialized format... But this test doesn't really care about any of the fields (other than the extras) - so I just made it as minimal as I could.

trie.NewStackTrie(nil),
)
want := extras.Block.Get(wantBlock)
want.Data = rng.Bytes(8)

b, err := rlp.EncodeToBytes(wantBlock)
require.NoErrorf(t, err, "rlp.EncodeToBytes(%T)", wantBlock)

gotBlock := new(Block)
require.NoErrorf(t, rlp.DecodeBytes(b, gotBlock), "rlp.DecodeBytes(rlp.EncodeToBytes(%T), %T)", wantBlock, gotBlock)
got := extras.Block.Get(gotBlock)
assert.Equalf(t, want, got, "%T payload after RLP round trip", got)
}

// newHeader returns a [Header] with randomly populated fields.
func newHeader(rng *ethtest.PseudoRand) *Header {
return &Header{
ParentHash: rng.Hash(),
UncleHash: rng.Hash(),
Coinbase: rng.Address(),
Root: rng.Hash(),
TxHash: rng.Hash(),
ReceiptHash: rng.Hash(),
Bloom: rng.Bloom(),
Difficulty: rng.BigUint64(),
Number: rng.BigUint64(),
GasLimit: rng.Uint64(),
GasUsed: rng.Uint64(),
Time: rng.Uint64(),
Extra: rng.Bytes(32),
MixDigest: rng.Hash(),
Nonce: rng.BlockNonce(),
BaseFee: rng.BigUint64(),
}
}

// bodySize describes the number of items in the [Body] of a test case.
type bodySize struct {
txs, uncles, withdrawals int
}

// bodySizes are the [Body] shapes covered by [FuzzBlockBytes] seeds and by
// [BenchmarkBlockBytes]. They cover every combination of empty and non-empty
// fields, the last of which is optional in RLP.
var bodySizes = []bodySize{
{txs: 0, uncles: 0, withdrawals: 0},
{txs: 1, uncles: 0, withdrawals: 0},
{txs: 0, uncles: 1, withdrawals: 0},
{txs: 0, uncles: 0, withdrawals: 1},
{txs: 10, uncles: 0, withdrawals: 0},
{txs: 10, uncles: 2, withdrawals: 4},
{txs: 100, uncles: 0, withdrawals: 0},
{txs: 100, uncles: 2, withdrawals: 16},
}

// newBody returns a [Body] with randomly populated fields, holding the number
// of items described by `size`.
func newBody(rng *ethtest.PseudoRand, size bodySize) *Body {
body := &Body{}
for range size.txs {
body.Transactions = append(body.Transactions, NewTx(&LegacyTx{
Nonce: rng.Uint64(),
GasPrice: rng.BigUint64(),
Gas: rng.Uint64(),
To: rng.AddressPtr(),
Value: rng.BigUint64(),
Data: rng.Bytes(64),
}))
}
for range size.uncles {
body.Uncles = append(body.Uncles, newHeader(rng))
}
for range size.withdrawals {
body.Withdrawals = append(body.Withdrawals, &Withdrawal{
Index: rng.Uint64(),
Validator: rng.Uint64(),
Address: rng.Address(),
Amount: rng.Uint64(),
})
}
Comment thread
StephenButtolph marked this conversation as resolved.
return body
}

// encodeRLP RLP-encodes `v`, failing the test if it can't be encoded.
func encodeRLP(tb testing.TB, v any) []byte {
tb.Helper()
b, err := rlp.EncodeToBytes(v)
require.NoErrorf(tb, err, "rlp.EncodeToBytes(%T)", v)
return b
}

// referenceBlockBytes is the reference implementation against which
// [BlockBytes] is tested and benchmarked.
func referenceBlockBytes(headerBytes, bodyBytes []byte) ([]byte, error) {
header := new(Header)
if err := rlp.DecodeBytes(headerBytes, header); err != nil {
return nil, err
}
body := new(Body)
if err := rlp.DecodeBytes(bodyBytes, body); err != nil {
return nil, err
}
block := NewBlockWithHeader(header).
WithBody(*body).
WithWithdrawals(body.Withdrawals)
return rlp.EncodeToBytes(block)
}

// FuzzBlockBytes demonstrates that [BlockBytes] is equivalent to
// [referenceBlockBytes] for all inputs that the latter accepts. The seed corpus
// covers every shape in [bodySizes].
func FuzzBlockBytes(f *testing.F) {
rng := ethtest.NewPseudoRand(20250806)
for _, size := range bodySizes {
f.Add(
encodeRLP(f, newHeader(rng)),
encodeRLP(f, newBody(rng, size)),
)
}

f.Fuzz(func(t *testing.T, headerBytes, bodyBytes []byte) {
want, err := referenceBlockBytes(headerBytes, bodyBytes)
if err != nil {
t.Skip("invalid input bytes")
}

got, err := BlockBytes(headerBytes, bodyBytes)
require.NoError(t, err, "BlockBytes()")
assert.Equal(t, want, got, "referenceBlockBytes() == BlockBytes()")
})
}

func BenchmarkBlockBytes(b *testing.B) {
for _, size := range bodySizes {
rng := ethtest.NewPseudoRand(2718281828)
headerBytes := encodeRLP(b, newHeader(rng))
bodyBytes := encodeRLP(b, newBody(rng, size))
b.Run(fmt.Sprintf("%d_txs_%d_uncles_%d_withdrawals", size.txs, size.uncles, size.withdrawals), func(b *testing.B) {
for b.Loop() {
_, _ = BlockBytes(headerBytes, bodyBytes)
}
})
}
}
Loading
Loading