Skip to content

Commit e89bc08

Browse files
authored
Merge pull request #4465 from OffchainLabs/classicdb-tests
Add regression tests for arbitrum classic database
2 parents 0f763ff + 318b031 commit e89bc08

6 files changed

Lines changed: 370 additions & 19 deletions

File tree

changelog/jcolvin-classic-ci.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
### Ignored
2+
- Add regression tests for classic database and fix off-by-one error that would just error in a different way

execution/gethexec/classicMessage.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,40 @@ import (
1313
"github.com/ethereum/go-ethereum/common"
1414
"github.com/ethereum/go-ethereum/crypto"
1515
"github.com/ethereum/go-ethereum/ethdb"
16+
"github.com/ethereum/go-ethereum/log"
17+
"github.com/ethereum/go-ethereum/node"
18+
19+
"github.com/offchainlabs/nitro/util/dbutil"
1620
)
1721

1822
type ClassicOutboxRetriever struct {
1923
db ethdb.Database
2024
}
2125

26+
// OpenClassicOutboxFromStack opens the classic-msg database from a node stack
27+
// and returns a ClassicOutboxRetriever, or nil if the database does not exist.
28+
func OpenClassicOutboxFromStack(stack *node.Node) (*ClassicOutboxRetriever, error) {
29+
classicMsgDB, err := stack.OpenDatabaseWithOptions("classic-msg", node.DatabaseOptions{
30+
MetricsNamespace: "classicmsg/",
31+
Cache: 0, // will be sanitized to minimum
32+
Handles: 0, // will be sanitized to minimum
33+
ReadOnly: true,
34+
NoFreezer: true,
35+
})
36+
if dbutil.IsNotExistError(err) {
37+
log.Warn("Classic Msg Database not found", "err", err)
38+
return nil, nil
39+
}
40+
if err != nil {
41+
return nil, fmt.Errorf("Failed to open classic-msg database: %w", err)
42+
}
43+
if err := dbutil.UnfinishedConversionCheck(classicMsgDB); err != nil {
44+
classicMsgDB.Close()
45+
return nil, fmt.Errorf("classic-msg unfinished database conversion check error: %w", err)
46+
}
47+
return NewClassicOutboxRetriever(classicMsgDB), nil
48+
}
49+
2250
func NewClassicOutboxRetriever(db ethdb.Database) *ClassicOutboxRetriever {
2351
return &ClassicOutboxRetriever{
2452
db: db,
@@ -47,7 +75,7 @@ func (m *ClassicOutboxRetriever) GetMsg(batchNum *big.Int, index uint64) (*Class
4775
lowest := uint64(0)
4876
var root common.Hash
4977
copy(root[:], batchHeader[8:40])
50-
if merkleSize < index {
78+
if merkleSize <= index {
5179
return nil, fmt.Errorf("batch %d only has %d indexes", batchNum, merkleSize)
5280
}
5381
proofNodes := [][32]byte{}
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright 2024-2026, Offchain Labs, Inc.
2+
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md
3+
4+
package gethexec
5+
6+
import (
7+
"fmt"
8+
"math/big"
9+
"testing"
10+
11+
"github.com/ethereum/go-ethereum/core/rawdb"
12+
)
13+
14+
func TestClassicOutboxRetrieverGetMsg(t *testing.T) {
15+
t.Parallel()
16+
db := rawdb.NewMemoryDatabase()
17+
18+
leaves := [][]byte{
19+
[]byte("message-0"),
20+
[]byte("message-1"),
21+
[]byte("message-2"),
22+
[]byte("message-3"),
23+
}
24+
root, merkleSize, err := BuildClassicMerkleTree(db, leaves)
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
if err := WriteClassicBatchHeader(db, big.NewInt(0), root, merkleSize); err != nil {
29+
t.Fatal(err)
30+
}
31+
32+
retriever := NewClassicOutboxRetriever(db)
33+
34+
for i, expected := range leaves {
35+
msg, err := retriever.GetMsg(big.NewInt(0), uint64(i)) //#nosec G115
36+
if err != nil {
37+
t.Fatalf("GetMsg(batch=0, index=%d) error: %v", i, err)
38+
}
39+
if string(msg.Data) != string(expected) {
40+
t.Errorf("GetMsg(batch=0, index=%d) data = %q, want %q", i, msg.Data, expected)
41+
}
42+
if msg.PathInt == nil {
43+
t.Errorf("GetMsg(batch=0, index=%d) PathInt is nil", i)
44+
}
45+
// 4-leaf tree has depth 2, so proof should have 2 sibling nodes
46+
if len(msg.ProofNodes) != 2 {
47+
t.Errorf("GetMsg(batch=0, index=%d) proof length = %d, want 2", i, len(msg.ProofNodes))
48+
}
49+
}
50+
}
51+
52+
func TestClassicOutboxRetrieverSingleLeaf(t *testing.T) {
53+
t.Parallel()
54+
db := rawdb.NewMemoryDatabase()
55+
56+
leaves := [][]byte{[]byte("only-message")}
57+
root, merkleSize, err := BuildClassicMerkleTree(db, leaves)
58+
if err != nil {
59+
t.Fatal(err)
60+
}
61+
if err := WriteClassicBatchHeader(db, big.NewInt(1), root, merkleSize); err != nil {
62+
t.Fatal(err)
63+
}
64+
65+
retriever := NewClassicOutboxRetriever(db)
66+
msg, err := retriever.GetMsg(big.NewInt(1), 0)
67+
if err != nil {
68+
t.Fatalf("GetMsg error: %v", err)
69+
}
70+
if string(msg.Data) != "only-message" {
71+
t.Errorf("data = %q, want %q", msg.Data, "only-message")
72+
}
73+
// Single leaf: no merkle traversal needed, so no proof nodes
74+
if len(msg.ProofNodes) != 0 {
75+
t.Errorf("proof length = %d, want 0", len(msg.ProofNodes))
76+
}
77+
}
78+
79+
func TestClassicOutboxRetrieverNonPowerOfTwoLeaves(t *testing.T) {
80+
t.Parallel()
81+
db := rawdb.NewMemoryDatabase()
82+
83+
// 3 leaves exercises the non-power-of-two branch (bits.OnesCount64 != 1)
84+
leaves := [][]byte{
85+
[]byte("leaf-0"),
86+
[]byte("leaf-1"),
87+
[]byte("leaf-2"),
88+
}
89+
root, merkleSize, err := BuildClassicMerkleTree(db, leaves)
90+
if err != nil {
91+
t.Fatal(err)
92+
}
93+
if err := WriteClassicBatchHeader(db, big.NewInt(0), root, merkleSize); err != nil {
94+
t.Fatal(err)
95+
}
96+
97+
retriever := NewClassicOutboxRetriever(db)
98+
for i, expected := range leaves {
99+
msg, err := retriever.GetMsg(big.NewInt(0), uint64(i)) //#nosec G115
100+
if err != nil {
101+
t.Fatalf("GetMsg(index=%d) error: %v", i, err)
102+
}
103+
if string(msg.Data) != string(expected) {
104+
t.Errorf("GetMsg(index=%d) data = %q, want %q", i, msg.Data, expected)
105+
}
106+
}
107+
}
108+
109+
func TestClassicOutboxRetrieverErrors(t *testing.T) {
110+
t.Parallel()
111+
db := rawdb.NewMemoryDatabase()
112+
113+
leaves := [][]byte{[]byte("msg-0"), []byte("msg-1")}
114+
root, merkleSize, err := BuildClassicMerkleTree(db, leaves)
115+
if err != nil {
116+
t.Fatal(err)
117+
}
118+
if err := WriteClassicBatchHeader(db, big.NewInt(0), root, merkleSize); err != nil {
119+
t.Fatal(err)
120+
}
121+
122+
retriever := NewClassicOutboxRetriever(db)
123+
124+
// Non-existent batch
125+
_, err = retriever.GetMsg(big.NewInt(99), 0)
126+
if err == nil {
127+
t.Error("expected error for non-existent batch, got nil")
128+
}
129+
130+
// Index out of range
131+
_, err = retriever.GetMsg(big.NewInt(0), 999)
132+
if err == nil {
133+
t.Error("expected error for out-of-range index, got nil")
134+
}
135+
136+
// Index exactly equal to merkleSize (one past last valid index).
137+
// Valid indices for merkleSize=2 are 0 and 1; index 2 should be rejected.
138+
_, err = retriever.GetMsg(big.NewInt(0), merkleSize)
139+
if err == nil {
140+
t.Errorf("expected error for index == merkleSize (%d), got nil", merkleSize)
141+
}
142+
}
143+
144+
func TestClassicOutboxRetrieverBoundaryIndex(t *testing.T) {
145+
t.Parallel()
146+
// Test the boundary between valid and invalid indices across different tree sizes.
147+
// The last valid index is merkleSize-1; merkleSize itself must be rejected.
148+
treeSizes := []int{1, 2, 3, 4, 5, 7, 8}
149+
for _, size := range treeSizes {
150+
size := size
151+
t.Run(fmt.Sprintf("size-%d", size), func(t *testing.T) {
152+
t.Parallel()
153+
db := rawdb.NewMemoryDatabase()
154+
leaves := make([][]byte, size)
155+
for i := range leaves {
156+
leaves[i] = []byte(fmt.Sprintf("leaf-%d", i))
157+
}
158+
root, merkleSize, err := BuildClassicMerkleTree(db, leaves)
159+
if err != nil {
160+
t.Fatal(err)
161+
}
162+
if err := WriteClassicBatchHeader(db, big.NewInt(0), root, merkleSize); err != nil {
163+
t.Fatal(err)
164+
}
165+
retriever := NewClassicOutboxRetriever(db)
166+
167+
// Last valid index should succeed
168+
lastValid := merkleSize - 1
169+
msg, err := retriever.GetMsg(big.NewInt(0), lastValid)
170+
if err != nil {
171+
t.Fatalf("GetMsg(index=%d) should succeed for merkleSize=%d, got: %v", lastValid, merkleSize, err)
172+
}
173+
expected := fmt.Sprintf("leaf-%d", lastValid)
174+
if string(msg.Data) != expected {
175+
t.Errorf("GetMsg(index=%d) data = %q, want %q", lastValid, msg.Data, expected)
176+
}
177+
178+
// First invalid index (== merkleSize) should fail
179+
_, err = retriever.GetMsg(big.NewInt(0), merkleSize)
180+
if err == nil {
181+
t.Errorf("GetMsg(index=%d) should fail for merkleSize=%d", merkleSize, merkleSize)
182+
}
183+
184+
// One beyond that should also fail
185+
_, err = retriever.GetMsg(big.NewInt(0), merkleSize+1)
186+
if err == nil {
187+
t.Errorf("GetMsg(index=%d) should fail for merkleSize=%d", merkleSize+1, merkleSize)
188+
}
189+
})
190+
}
191+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
// Copyright 2024-2026, Offchain Labs, Inc.
2+
// For license information, see https://github.com/OffchainLabs/nitro/blob/master/LICENSE.md
3+
4+
package gethexec
5+
6+
import (
7+
"encoding/binary"
8+
"fmt"
9+
"math/big"
10+
11+
"github.com/ethereum/go-ethereum/common"
12+
"github.com/ethereum/go-ethereum/crypto"
13+
"github.com/ethereum/go-ethereum/ethdb"
14+
)
15+
16+
// BuildClassicMerkleTree populates db with a binary merkle tree over the given leaf data.
17+
// Returns the root hash and the number of leaves (merkleSize).
18+
// Exported for use in tests across packages.
19+
func BuildClassicMerkleTree(db ethdb.KeyValueWriter, leaves [][]byte) (common.Hash, uint64, error) {
20+
if len(leaves) == 0 {
21+
return common.Hash{}, 0, fmt.Errorf("BuildClassicMerkleTree requires at least one leaf")
22+
}
23+
hashes := make([]common.Hash, len(leaves))
24+
for i, leaf := range leaves {
25+
h := crypto.Keccak256Hash(leaf)
26+
hashes[i] = h
27+
if err := db.Put(h.Bytes(), leaf); err != nil {
28+
return common.Hash{}, 0, fmt.Errorf("failed to store leaf %d: %w", i, err)
29+
}
30+
}
31+
for len(hashes) > 1 {
32+
var next []common.Hash
33+
for i := 0; i < len(hashes); i += 2 {
34+
if i+1 < len(hashes) {
35+
var nodeData [64]byte
36+
copy(nodeData[0:32], hashes[i].Bytes())
37+
copy(nodeData[32:64], hashes[i+1].Bytes())
38+
parentHash := crypto.Keccak256Hash(nodeData[:])
39+
if err := db.Put(parentHash.Bytes(), nodeData[:]); err != nil {
40+
return common.Hash{}, 0, fmt.Errorf("failed to store internal node: %w", err)
41+
}
42+
next = append(next, parentHash)
43+
} else {
44+
next = append(next, hashes[i])
45+
}
46+
}
47+
hashes = next
48+
}
49+
return hashes[0], uint64(len(leaves)), nil
50+
}
51+
52+
// WriteClassicBatchHeader writes a classic-msg batch header (8-byte merkleSize + 32-byte root)
53+
// keyed by keccak256("msgBatch" || batchNum.Bytes()).
54+
// Exported for use in tests across packages.
55+
func WriteClassicBatchHeader(db ethdb.KeyValueWriter, batchNum *big.Int, root common.Hash, merkleSize uint64) error {
56+
key := msgBatchKey(batchNum)
57+
header := make([]byte, 40)
58+
binary.BigEndian.PutUint64(header[0:8], merkleSize)
59+
copy(header[8:40], root.Bytes())
60+
if err := db.Put(key, header); err != nil {
61+
return fmt.Errorf("failed to write batch header: %w", err)
62+
}
63+
return nil
64+
}

execution/gethexec/node.go

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,6 @@ import (
3939
"github.com/offchainlabs/nitro/util"
4040
"github.com/offchainlabs/nitro/util/arbmath"
4141
"github.com/offchainlabs/nitro/util/containers"
42-
"github.com/offchainlabs/nitro/util/dbutil"
4342
"github.com/offchainlabs/nitro/util/headerreader"
4443
"github.com/offchainlabs/nitro/util/rpcclient"
4544
"github.com/offchainlabs/nitro/util/rpcserver"
@@ -340,23 +339,10 @@ func CreateExecutionNode(
340339
var classicOutbox *ClassicOutboxRetriever
341340

342341
if l2BlockChain.Config().ArbitrumChainParams.GenesisBlockNum > 0 {
343-
classicMsgDB, err := stack.OpenDatabaseWithOptions("classic-msg", node.DatabaseOptions{
344-
MetricsNamespace: "classicmsg/",
345-
Cache: 0, // will be sanitized to minimum
346-
Handles: 0, // will be sanitized to minimum
347-
ReadOnly: true,
348-
NoFreezer: true,
349-
})
350-
if dbutil.IsNotExistError(err) {
351-
log.Warn("Classic Msg Database not found", "err", err)
352-
classicOutbox = nil
353-
} else if err != nil {
354-
return nil, fmt.Errorf("Failed to open classic-msg database: %w", err)
355-
} else {
356-
if err := dbutil.UnfinishedConversionCheck(classicMsgDB); err != nil {
357-
return nil, fmt.Errorf("classic-msg unfinished database conversion check error: %w", err)
358-
}
359-
classicOutbox = NewClassicOutboxRetriever(classicMsgDB)
342+
var err error
343+
classicOutbox, err = OpenClassicOutboxFromStack(stack)
344+
if err != nil {
345+
return nil, err
360346
}
361347
}
362348

0 commit comments

Comments
 (0)