Skip to content

Commit 01f663f

Browse files
test(exit-certificate): cover state-dump re-seek; make verification retry tunable
TestCollectAccountsViaStateDump_Paginates asserted the old "stop on empty cursor" behaviour (exactly 2 calls), which broke once the dump re-seeks to verify the trie end. Update it to model the end-of-walk verification re-seek, and add TestCollectAccountsViaStateDump_ReseeksPastPrematureEmptyCursor which proves the dump recovers accounts a premature empty cursor would otherwise drop. The end-of-walk re-seek retry count and delay are now package vars so tests can shrink them (no production backoff during unit tests). Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0d42f51 commit 01f663f

2 files changed

Lines changed: 76 additions & 7 deletions

File tree

tools/exit_certificate/step_a.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,15 @@ var transferTopic = common.HexToHash(transferEventSignature)
4444
// const) so tests can shrink it to exercise the truncation guard.
4545
var maxAccountRangePages = 25_000_000
4646

47+
// accountRangeEmptyRetries and accountRangeEmptyRetryDelay control the end-of-walk verification
48+
// re-seek: how many times an empty re-seek result is retried (to ride out the endpoint's transient
49+
// truncated/empty pages) before concluding the trie is exhausted, and how long to pause between
50+
// tries. They are vars so tests can shrink them.
51+
var (
52+
accountRangeEmptyRetries = 3
53+
accountRangeEmptyRetryDelay = 500 * time.Millisecond
54+
)
55+
4756
// accountRangeDialect distinguishes the two incompatible debug_accountRange ABIs in the wild.
4857
//
4958
// - geth/op-geth: AccountRange(block, start hexutil.Bytes, maxResults, nocode, nostorage,
@@ -272,9 +281,8 @@ func incrementKey(key []byte) []byte {
272281
func reseekPastFrontier(
273282
ctx context.Context, rpcURL, blockTag string, frontier []byte, dialect accountRangeDialect,
274283
) (*accountRangeResult, bool, error) {
275-
const emptyRetries = 3
276284
start := incrementKey(frontier)
277-
for attempt := 0; attempt < emptyRetries; attempt++ {
285+
for attempt := 0; attempt < accountRangeEmptyRetries; attempt++ {
278286
res, err := debugAccountRange(ctx, rpcURL, blockTag, start, accountRangePageSize, dialect)
279287
if err != nil {
280288
return nil, false, err
@@ -284,7 +292,9 @@ func reseekPastFrontier(
284292
return res, false, nil
285293
}
286294
}
287-
time.Sleep(500 * time.Millisecond) //nolint:mnd // brief pause before retrying an empty page
295+
if accountRangeEmptyRetryDelay > 0 {
296+
time.Sleep(accountRangeEmptyRetryDelay) // brief pause before retrying an empty page
297+
}
288298
}
289299
return nil, true, nil
290300
}

tools/exit_certificate/step_a_test.go

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,20 @@ func encodeRPC(t *testing.T, id int, result string) []byte {
3636
return b
3737
}
3838

39+
// withFastAccountRangeReseek shrinks the end-of-walk verification re-seek (one try, no delay) so
40+
// tests don't pay the production retry/backoff. It restores the originals on cleanup. It mutates
41+
// package vars, so callers must not run in parallel.
42+
func withFastAccountRangeReseek(t *testing.T) {
43+
t.Helper()
44+
origRetries, origDelay := accountRangeEmptyRetries, accountRangeEmptyRetryDelay
45+
accountRangeEmptyRetries, accountRangeEmptyRetryDelay = 1, 0
46+
t.Cleanup(func() {
47+
accountRangeEmptyRetries, accountRangeEmptyRetryDelay = origRetries, origDelay
48+
})
49+
}
50+
3951
func TestCollectAccountsViaStateDump_Paginates(t *testing.T) {
40-
t.Parallel()
52+
withFastAccountRangeReseek(t)
4153

4254
var calls int
4355
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -48,15 +60,19 @@ func TestCollectAccountsViaStateDump_Paginates(t *testing.T) {
4860
w.Header().Set("Content-Type", "application/json")
4961

5062
var result string
51-
if calls == 1 {
63+
switch calls {
64+
case 1:
5265
// Non-zero base64 next cursor → tool must request a second page.
5366
next := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
5467
result = `{"root":"0x0","accounts":{` +
5568
`"` + stepAAddr1 + `":{"address":"` + stepAAddr1 + `"},` +
5669
`"` + stepAAddr2 + `":{"address":"` + stepAAddr2 + `"}},"next":"` + next + `"}`
57-
} else {
70+
case 2:
5871
result = `{"root":"0x0","accounts":{` +
5972
`"` + stepAAddr3 + `":{"address":"` + stepAAddr3 + `"}},"next":""}`
73+
default:
74+
// End-of-walk verification re-seek past the highest key: genuinely no new accounts.
75+
result = `{"root":"0x0","accounts":{},"next":""}`
6076
}
6177
_, _ = w.Write(encodeRPC(t, req.ID, result))
6278
}))
@@ -65,14 +81,57 @@ func TestCollectAccountsViaStateDump_Paginates(t *testing.T) {
6581
cfg := &Config{L2RPCURL: server.URL}
6682
addrs, err := collectAccountsViaStateDump(context.Background(), cfg, 100)
6783
require.NoError(t, err)
68-
require.Equal(t, 2, calls, "must paginate until next is empty")
6984
require.ElementsMatch(t, []common.Address{
7085
common.HexToAddress(stepAAddr1),
7186
common.HexToAddress(stepAAddr2),
7287
common.HexToAddress(stepAAddr3),
7388
}, addrs)
7489
}
7590

91+
// TestCollectAccountsViaStateDump_ReseeksPastPrematureEmptyCursor guards the fix: the cdk-erigon
92+
// endpoint can return a valid page with an empty "next" cursor before the trie ends. The dump must
93+
// not treat that as done — it re-seeks past the highest key seen and recovers the remaining
94+
// accounts instead of silently truncating.
95+
func TestCollectAccountsViaStateDump_ReseeksPastPrematureEmptyCursor(t *testing.T) {
96+
withFastAccountRangeReseek(t)
97+
98+
var calls int
99+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
100+
var req jsonRPCRequest
101+
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
102+
require.Equal(t, rpcMethodDebugAccountRange, req.Method)
103+
calls++
104+
w.Header().Set("Content-Type", "application/json")
105+
106+
var result string
107+
switch calls {
108+
case 1:
109+
// PREMATURE empty cursor: only addr1 returned, but addr2/addr3 still remain.
110+
result = `{"root":"0x0","accounts":{` +
111+
`"` + stepAAddr1 + `":{"address":"` + stepAAddr1 + `"}},"next":""}`
112+
case 2:
113+
// Re-seek past addr1 recovers the accounts the premature cursor would have dropped.
114+
result = `{"root":"0x0","accounts":{` +
115+
`"` + stepAAddr2 + `":{"address":"` + stepAAddr2 + `"},` +
116+
`"` + stepAAddr3 + `":{"address":"` + stepAAddr3 + `"}},"next":""}`
117+
default:
118+
// Re-seek past addr3: genuinely empty → the walk is complete.
119+
result = `{"root":"0x0","accounts":{},"next":""}`
120+
}
121+
_, _ = w.Write(encodeRPC(t, req.ID, result))
122+
}))
123+
defer server.Close()
124+
125+
cfg := &Config{L2RPCURL: server.URL}
126+
addrs, err := collectAccountsViaStateDump(context.Background(), cfg, 100)
127+
require.NoError(t, err)
128+
require.ElementsMatch(t, []common.Address{
129+
common.HexToAddress(stepAAddr1),
130+
common.HexToAddress(stepAAddr2),
131+
common.HexToAddress(stepAAddr3),
132+
}, addrs, "re-seek must recover accounts dropped by a premature empty cursor")
133+
}
134+
76135
func TestFetchTransferHoldersInRange_ExtractsFromAndTo(t *testing.T) {
77136
t.Parallel()
78137

0 commit comments

Comments
 (0)