Skip to content

Commit 86d1117

Browse files
authored
Merge pull request OffchainLabs#3308 from OffchainLabs/raul/mel-replay-receipt-walking
[MEL] - Implement a Receipt Trie Reader for the MEL Replay Binary Using Preimages
2 parents b2990c1 + ab62202 commit 86d1117

File tree

3 files changed

+288
-57
lines changed

3 files changed

+288
-57
lines changed

cmd/mel-replay/receipt_fetcher.go

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
// Copyright 2025-2026, Offchain Labs, Inc.
2+
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md
3+
4+
package main
5+
6+
import (
7+
"bytes"
8+
"context"
9+
"errors"
10+
"fmt"
11+
12+
"github.com/ethereum/go-ethereum/common"
13+
"github.com/ethereum/go-ethereum/core/types"
14+
"github.com/ethereum/go-ethereum/rlp"
15+
16+
"github.com/offchainlabs/nitro/arbutil"
17+
)
18+
19+
type receiptFetcherForBlock struct {
20+
header *types.Header
21+
preimageResolver preimageResolver
22+
}
23+
24+
// ReceiptForTransactionIndex fetches a receipt for a specific transaction index by walking
25+
// the receipt trie of the block header. It uses the preimage resolver to fetch the preimages
26+
// of the trie nodes as needed.
27+
func (rf *receiptFetcherForBlock) ReceiptForTransactionIndex(
28+
ctx context.Context,
29+
txIndex uint,
30+
) (*types.Receipt, error) {
31+
return fetchReceiptFromBlock(rf.header.ReceiptHash, txIndex, rf.preimageResolver)
32+
}
33+
34+
// Fetches a specific receipt index from a block's receipt trie by navigating its
35+
// Merkle Patricia Trie structure. It uses the preimage resolver to fetch preimages
36+
// of trie nodes as needed, and determines how to navigate depending on the structure of the trie nodes.
37+
func fetchReceiptFromBlock(
38+
receiptsRoot common.Hash,
39+
receiptIndex uint,
40+
preimageResolver preimageResolver,
41+
) (*types.Receipt, error) {
42+
currentNodeHash := receiptsRoot
43+
currentPath := []byte{} // Track nibbles consumed so far.
44+
receiptKey, err := rlp.EncodeToBytes(receiptIndex)
45+
if err != nil {
46+
return nil, err
47+
}
48+
targetNibbles := keyToNibbles(receiptKey)
49+
for {
50+
nodeData, err := preimageResolver.ResolveTypedPreimage(arbutil.Keccak256PreimageType, currentNodeHash)
51+
if err != nil {
52+
return nil, err
53+
}
54+
var node []any
55+
if err = rlp.DecodeBytes(nodeData, &node); err != nil {
56+
return nil, fmt.Errorf("failed to decode RLP node: %w", err)
57+
}
58+
switch len(node) {
59+
case 17:
60+
// We hit a branch node, which has 16 children and a value.
61+
if len(currentPath) == len(targetNibbles) {
62+
// A branch node's 17th item could be the value, so we check if it contains the receipt.
63+
if valueBytes, ok := node[16].([]byte); ok && len(valueBytes) > 0 {
64+
// This branch node has the actual value as the last item, so we decode the receipt
65+
return decodeReceipt(valueBytes)
66+
}
67+
return nil, fmt.Errorf("no receipt found at target key")
68+
}
69+
// Get the next nibble to follow.
70+
targetNibble := targetNibbles[len(currentPath)]
71+
childData, ok := node[targetNibble].([]byte)
72+
if !ok || len(childData) == 0 {
73+
return nil, fmt.Errorf("no child at nibble %d", targetNibble)
74+
}
75+
// Move to the child node, which is the next hash we have to navigate.
76+
currentNodeHash = common.BytesToHash(childData)
77+
currentPath = append(currentPath, targetNibble)
78+
case 2:
79+
keyPath, ok := node[0].([]byte)
80+
if !ok {
81+
return nil, fmt.Errorf("invalid key path in node")
82+
}
83+
key := extractKeyNibbles(keyPath)
84+
expectedPath := make([]byte, 0)
85+
expectedPath = append(expectedPath, currentPath...)
86+
expectedPath = append(expectedPath, key...)
87+
88+
// Check if it is a leaf or extension node.
89+
leaf, err := isLeaf(keyPath)
90+
if err != nil {
91+
return nil, err
92+
}
93+
if leaf {
94+
// Check that the keyPath matches the target nibbles,
95+
// otherwise, the receipt does not exist in the trie.
96+
if !bytes.Equal(expectedPath, targetNibbles) {
97+
return nil, fmt.Errorf("leaf key does not match target nibbles")
98+
}
99+
rawData, ok := node[1].([]byte)
100+
if !ok {
101+
return nil, fmt.Errorf("invalid receipt data in leaf node")
102+
}
103+
return decodeReceipt(rawData)
104+
}
105+
// If the node is not a leaf node, it is an extension node.
106+
// Check if our target key matches this extension path.
107+
if len(expectedPath) > len(targetNibbles) || !bytes.Equal(expectedPath, targetNibbles[:len(expectedPath)]) {
108+
return nil, fmt.Errorf("extension path mismatch")
109+
}
110+
nextNodeBytes, ok := node[1].([]byte)
111+
if !ok {
112+
return nil, fmt.Errorf("invalid next node in extension")
113+
}
114+
// We navigate to the next node in the trie.
115+
currentNodeHash = common.BytesToHash(nextNodeBytes)
116+
currentPath = expectedPath
117+
default:
118+
return nil, fmt.Errorf("invalid node structure: unexpected length %d", len(node))
119+
}
120+
}
121+
}
122+
123+
// Converts a byte slice key into a slice of nibbles (4-bit values).
124+
// Keys are encoded in big endian format, which is required by Ethereum MPTs.
125+
func keyToNibbles(key []byte) []byte {
126+
nibbles := make([]byte, len(key)*2)
127+
for i, b := range key {
128+
nibbles[i*2] = b >> 4
129+
nibbles[i*2+1] = b & 0x0f
130+
}
131+
return nibbles
132+
}
133+
134+
// Extracts the key nibbles from a key path, handling odd/even length cases.
135+
func extractKeyNibbles(keyPath []byte) []byte {
136+
if len(keyPath) == 0 {
137+
return nil
138+
}
139+
nibbles := keyToNibbles(keyPath)
140+
if nibbles[0]&1 != 0 {
141+
return nibbles[1:]
142+
}
143+
return nibbles[2:]
144+
}
145+
146+
func isLeaf(keyPath []byte) (bool, error) {
147+
firstByte := keyPath[0]
148+
firstNibble := firstByte >> 4
149+
// 2 or 3 indicates leaf, while 0 or 1 indicates extension nodes in the Ethereum MPT specification.
150+
if firstNibble > 3 {
151+
return false, errors.New("first nibble cannot be greater than 3")
152+
}
153+
return firstNibble >= 2, nil
154+
}
155+
156+
func decodeReceipt(data []byte) (*types.Receipt, error) {
157+
if len(data) == 0 {
158+
return nil, errors.New("empty data cannot be decoded into receipt")
159+
}
160+
rpt := new(types.Receipt)
161+
if err := rpt.UnmarshalBinary(data); err != nil {
162+
return nil, err
163+
}
164+
return rpt, nil
165+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// Copyright 2025-2026, Offchain Labs, Inc.
2+
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md
3+
4+
package main
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"math/big"
10+
"testing"
11+
12+
"github.com/stretchr/testify/require"
13+
14+
"github.com/ethereum/go-ethereum/common"
15+
"github.com/ethereum/go-ethereum/core/types"
16+
"github.com/ethereum/go-ethereum/crypto"
17+
"github.com/ethereum/go-ethereum/trie"
18+
19+
"github.com/offchainlabs/nitro/arbutil"
20+
)
21+
22+
func TestFetchReceiptFromBlock_Multiple(t *testing.T) {
23+
ctx := context.Background()
24+
// Creates a block with 42 transactions and receipts.
25+
numReceipts := 42
26+
receipts := createTestReceipts(numReceipts)
27+
hasher := newRecordingHasher()
28+
receiptsRoot := types.DeriveSha(types.Receipts(receipts), hasher)
29+
header := &types.Header{}
30+
txes := make([]*types.Transaction, numReceipts)
31+
for i := 0; i < numReceipts; i++ {
32+
txes[i] = types.NewTransaction(uint64(i), common.Address{}, big.NewInt(0), 21000, big.NewInt(1), nil) // #nosec G115
33+
}
34+
body := &types.Body{
35+
Transactions: txes,
36+
}
37+
blk := types.NewBlock(header, body, receipts, hasher)
38+
require.Equal(t, blk.ReceiptHash(), receiptsRoot)
39+
preimages := hasher.GetPreimages()
40+
mockPreimageResolver := &mockPreimageResolver{
41+
preimages: preimages,
42+
}
43+
receiptFetcher := &receiptFetcherForBlock{
44+
header: blk.Header(),
45+
preimageResolver: mockPreimageResolver,
46+
}
47+
for i := 0; i < numReceipts; i++ {
48+
receipt, err := receiptFetcher.ReceiptForTransactionIndex(ctx, uint(i)) // #nosec G115
49+
require.NoError(t, err)
50+
require.Equal(t, receipts[i].CumulativeGasUsed, receipt.CumulativeGasUsed)
51+
}
52+
}
53+
54+
type mockPreimageResolver struct {
55+
preimages map[common.Hash][]byte
56+
}
57+
58+
func (m *mockPreimageResolver) ResolveTypedPreimage(preimageType arbutil.PreimageType, hash common.Hash) ([]byte, error) {
59+
if preimage, exists := m.preimages[hash]; exists {
60+
return preimage, nil
61+
}
62+
return nil, fmt.Errorf("preimage not found for hash: %s", hash.Hex())
63+
}
64+
65+
// Implements a hasher that captures preimages of hashes as it computes them.
66+
type preimageRecordingHasher struct {
67+
trie *trie.StackTrie
68+
preimages map[common.Hash][]byte
69+
}
70+
71+
func newRecordingHasher() *preimageRecordingHasher {
72+
h := &preimageRecordingHasher{
73+
preimages: make(map[common.Hash][]byte),
74+
}
75+
// OnTrieNode callback captures all trie nodes.
76+
onTrieNode := func(path []byte, hash common.Hash, blob []byte) {
77+
// Deep copy the blob since the callback warns contents may change, so this is required.
78+
h.preimages[hash] = common.CopyBytes(blob)
79+
}
80+
81+
h.trie = trie.NewStackTrie(onTrieNode)
82+
return h
83+
}
84+
85+
func (h *preimageRecordingHasher) Reset() {
86+
onTrieNode := func(path []byte, hash common.Hash, blob []byte) {
87+
h.preimages[hash] = common.CopyBytes(blob)
88+
}
89+
h.trie = trie.NewStackTrie(onTrieNode)
90+
}
91+
92+
func (h *preimageRecordingHasher) Update(key, value []byte) error {
93+
valueHash := crypto.Keccak256Hash(value)
94+
h.preimages[valueHash] = common.CopyBytes(value)
95+
return h.trie.Update(key, value)
96+
}
97+
98+
func (h *preimageRecordingHasher) Hash() common.Hash {
99+
return h.trie.Hash()
100+
}
101+
102+
func (h *preimageRecordingHasher) GetPreimages() map[common.Hash][]byte {
103+
return h.preimages
104+
}
105+
106+
func createTestReceipts(count int) types.Receipts {
107+
receipts := make(types.Receipts, count)
108+
for i := 0; i < count; i++ {
109+
receipt := &types.Receipt{
110+
Status: 1,
111+
CumulativeGasUsed: 50_000 + uint64(i), // #nosec G115
112+
TxHash: common.Hash{},
113+
ContractAddress: common.Address{},
114+
Logs: []*types.Log{},
115+
BlockHash: common.BytesToHash([]byte("foobar")),
116+
BlockNumber: big.NewInt(100),
117+
TransactionIndex: uint(i), // #nosec G115
118+
}
119+
receipt.Bloom = types.BytesToBloom(make([]byte, 256))
120+
receipts[i] = receipt
121+
}
122+
return receipts
123+
}

cmd/mel-replay/txs_fetcher_test.go

Lines changed: 0 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,13 @@ package main
22

33
import (
44
"context"
5-
"fmt"
65
"math/big"
76
"testing"
87

98
"github.com/stretchr/testify/require"
109

1110
"github.com/ethereum/go-ethereum/common"
1211
"github.com/ethereum/go-ethereum/core/types"
13-
"github.com/ethereum/go-ethereum/crypto"
14-
"github.com/ethereum/go-ethereum/trie"
15-
16-
"github.com/offchainlabs/nitro/arbutil"
1712
)
1813

1914
func TestFetchTransactionsForBlockHeader_DynamicFeeTxs(t *testing.T) {
@@ -80,55 +75,3 @@ func TestFetchTransactionsForBlockHeader_LegacyTxs(t *testing.T) {
8075
require.Equal(t, uint64(i), tx.Nonce()) // #nosec G115
8176
}
8277
}
83-
84-
type mockPreimageResolver struct {
85-
preimages map[common.Hash][]byte
86-
}
87-
88-
func (m *mockPreimageResolver) ResolveTypedPreimage(preimageType arbutil.PreimageType, hash common.Hash) ([]byte, error) {
89-
if preimage, exists := m.preimages[hash]; exists {
90-
return preimage, nil
91-
}
92-
return nil, fmt.Errorf("preimage not found for hash: %s", hash.Hex())
93-
}
94-
95-
// Implements a hasher that captures preimages of hashes as it computes them.
96-
type preimageRecordingHasher struct {
97-
trie *trie.StackTrie
98-
preimages map[common.Hash][]byte
99-
}
100-
101-
func newRecordingHasher() *preimageRecordingHasher {
102-
h := &preimageRecordingHasher{
103-
preimages: make(map[common.Hash][]byte),
104-
}
105-
// OnTrieNode callback captures all trie nodes.
106-
onTrieNode := func(path []byte, hash common.Hash, blob []byte) {
107-
// Deep copy the blob since the callback warns contents may change, so this is required.
108-
h.preimages[hash] = common.CopyBytes(blob)
109-
}
110-
111-
h.trie = trie.NewStackTrie(onTrieNode)
112-
return h
113-
}
114-
115-
func (h *preimageRecordingHasher) Reset() {
116-
onTrieNode := func(path []byte, hash common.Hash, blob []byte) {
117-
h.preimages[hash] = common.CopyBytes(blob)
118-
}
119-
h.trie = trie.NewStackTrie(onTrieNode)
120-
}
121-
122-
func (h *preimageRecordingHasher) Update(key, value []byte) error {
123-
valueHash := crypto.Keccak256Hash(value)
124-
h.preimages[valueHash] = common.CopyBytes(value)
125-
return h.trie.Update(key, value)
126-
}
127-
128-
func (h *preimageRecordingHasher) Hash() common.Hash {
129-
return h.trie.Hash()
130-
}
131-
132-
func (h *preimageRecordingHasher) GetPreimages() map[common.Hash][]byte {
133-
return h.preimages
134-
}

0 commit comments

Comments
 (0)