Skip to content

Commit 38b244d

Browse files
koenmtb1claude
andauthored
fix: find exits on any rollup version, not just canonical and deposit-time (#125)
The dashboard looked for an attester's live record on the canonical rollup, the rollup the stake was deposited on, and the indexer's hint. An exit stays locked on the rollup it started on, and slashing into ZOMBIE emits no WithdrawInitiated, so the indexer moves its hint along with every cutover. A stake deposited on one rollup, migrated to a second and slashed there was therefore invisible once canonical moved on: the dashboard showed status NONE and offered no withdraw actions. Now every rollup version from /api/rollups is probed in one multicall. The indexer hint still wins when the chain shows a record there, so stakes that resolved correctly before resolve to the same rollup. On mainnet this unsticks 34 exitable stakes (~6.3M AZTEC): 17 zombies on V4 and 15 zombies plus 2 slash-exits on V5. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 240182f commit 38b244d

2 files changed

Lines changed: 135 additions & 112 deletions

File tree

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import type { Address } from "viem"
2+
import { SequencerStatus } from "./sequencerStatus"
3+
4+
const sameAddress = (a: Address, b: Address) => a.toLowerCase() === b.toLowerCase()
5+
6+
/**
7+
* Every rollup an attester's live record could be on, deduplicated, in the
8+
* order the selection below prefers them when several hold a record:
9+
* canonical first, then the Registry's historical versions newest-first, then
10+
* the deposit-time rollup and indexer hint in case either is missing from the
11+
* version list (e.g. `/api/rollups` lagging behind a cutover).
12+
*
13+
* Probing every known version (rather than just canonical + deposit-time +
14+
* hint) is what makes exits on an intermediate rollup findable. Example: a
15+
* stake deposited on rollup A auto-migrates to B, is slashed into ZOMBIE on
16+
* B, then canonical moves to C. Slashing emits no `WithdrawInitiated`, so the
17+
* indexer moves its hint to C, while the exit stays locked on B. Neither A, C
18+
* nor the hint would find it.
19+
*/
20+
export function attesterRollupCandidates(
21+
canonicalRollup: Address,
22+
versionAddressesOldestFirst: readonly Address[],
23+
depositRollup: Address | undefined,
24+
hintRollup: Address | undefined,
25+
): Address[] {
26+
const ordered = [
27+
canonicalRollup,
28+
...[...versionAddressesOldestFirst].reverse(),
29+
...(depositRollup ? [depositRollup] : []),
30+
...(hintRollup ? [hintRollup] : []),
31+
]
32+
const out: Address[] = []
33+
for (const address of ordered) {
34+
if (!out.some((seen) => sameAddress(seen, address))) out.push(address)
35+
}
36+
return out
37+
}
38+
39+
/**
40+
* Picks which candidate's `getAttesterView` result describes the stake.
41+
* `statuses[i]` is the status read from `candidates[i]` (undefined while
42+
* loading or on a failed read).
43+
*
44+
* Preference, highest first:
45+
* 1. The indexer hint, if the chain shows a record there. When an attester
46+
* has records on two rollups (a pending exit on an old rollup and a
47+
* re-deposit on canonical), the hint points at the one the indexer
48+
* tracks, so this keeps the exit reachable until it's finalized.
49+
* 2. Any other rollup with a record, in candidate order (canonical, then
50+
* newest historical version first).
51+
* 3. No record anywhere: the hint if one was given, so the stake keeps a
52+
* stable "where it last lived" target, otherwise canonical. Callers gate
53+
* their actions on the NONE status either way.
54+
*
55+
* The chain is authoritative: the hint only wins when its own read found a
56+
* record.
57+
*/
58+
export function selectAttesterRollup(
59+
candidates: readonly Address[],
60+
statuses: readonly (number | undefined)[],
61+
hintRollup: Address | undefined,
62+
): number {
63+
const hasRecord = (i: number) => statuses[i] !== undefined && statuses[i] !== SequencerStatus.NONE
64+
const hintIndex = hintRollup ? candidates.findIndex((c) => sameAddress(c, hintRollup)) : -1
65+
66+
if (hintIndex !== -1 && hasRecord(hintIndex)) return hintIndex
67+
const live = candidates.findIndex((_, i) => hasRecord(i))
68+
if (live !== -1) return live
69+
return hintIndex !== -1 ? hintIndex : 0
70+
}
Lines changed: 65 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,38 @@
1+
import { useMemo } from "react"
12
import type { Address } from "viem"
2-
import { useAttesterView } from "./useAttesterView"
3-
import { SequencerStatus } from "./sequencerStatus"
4-
import { contracts } from "@/contracts"
3+
import { useReadContracts } from "wagmi"
4+
import { contracts, getRollupVersions } from "@/contracts"
5+
import { attesterRollupCandidates, selectAttesterRollup } from "./attesterRollupSelection"
56

67
/**
78
* Optional indexer-supplied hint about which rollup currently holds the
89
* live record. The indexer captures `moveWithRollup` from the originating
910
* tx's calldata and bulk-updates `effectiveRollup` whenever the canonical
10-
* rollup rotates. If the caller has these handy, this hook uses them as
11-
* the **preferred** probe candidate — saving an RPC roundtrip in the
12-
* common case. The on-chain probe still runs as a safety net: if it sees a
13-
* live record on a different rollup than the hint suggested, the probe's
14-
* answer wins (the chain is authoritative).
11+
* rollup rotates. It's used to break ties when the chain shows records on
12+
* more than one rollup; it never overrides the chain (see
13+
* `selectAttesterRollup`).
1514
*/
1615
export interface AttesterViewHint {
1716
effectiveRollup: Address
1817
moveWithRollup: boolean | null
1918
}
2019

2120
/**
22-
* Looks up an attester via `getAttesterView` against the candidate rollups
23-
* (canonical, legacy/deposit-time, and an optional indexer hint) and returns
24-
* whichever view recognises the attester (non-NONE status). Covers:
21+
* Looks up an attester via `getAttesterView` on every rollup version the
22+
* Registry has made canonical (plus the deposit-time rollup and indexer hint,
23+
* if not already among them), in one multicall, and returns whichever view
24+
* recognises the attester (non-NONE status). Covers:
2525
*
26-
* - Active sequencer on canonical rollup with old delegation record — the
27-
* legacy view returns NONE; we fall through to canonical.
28-
* This is the `moveWithRollup = true` (auto-migrating) case: the stake
29-
* follows the canonical rollup as it upgrades.
30-
* - Legacy stake mid-withdrawal — canonical returns NONE; we fall through
31-
* to legacy so the exit data is still visible.
32-
* This is the `moveWithRollup = false` case: the stake stays on the
33-
* rollup it was originally deposited on.
34-
* - Genuinely unregistered — both NONE; we return the canonical view so
35-
* callers still get a well-defined result.
26+
* - Auto-migrating stakes (`moveWithRollup = true`): the record follows
27+
* canonical, so the deposit-time rollup returns NONE and canonical has it.
28+
* - Pinned stakes (`moveWithRollup = false`): the record stays on the
29+
* deposit-time rollup.
30+
* - Exits on an intermediate rollup: an exit (voluntary, or a ZOMBIE from
31+
* slashing) stays locked on the rollup it started on, which may be
32+
* neither the deposit-time rollup nor canonical, and the indexer hint may
33+
* have moved past it. Probing every version finds it anyway.
34+
* - Genuinely unregistered: every read is NONE; we return a well-defined
35+
* NONE result (see `selectAttesterRollup` for which rollup it's from).
3636
*
3737
* Returns the **effective rollup** the live record was found on, alongside
3838
* the view data. Callers that need to send writes (unstake, etc.) should
@@ -49,107 +49,60 @@ export function useAttesterViewBestEffort(
4949
hint?: AttesterViewHint | null,
5050
) {
5151
const canonicalRollup = contracts.rollup.address
52-
const isLegacyDifferent =
53-
!!rollupAddress && rollupAddress.toLowerCase() !== canonicalRollup.toLowerCase()
54-
55-
// The indexer hint may match canonical or legacy or a third address. Only
56-
// probe the hint separately when it doesn't coincide with one we'd probe
57-
// anyway. This keeps the common case to exactly two reads (canonical +
58-
// legacy) while still supporting unusual hints.
5952
const hintRollup = hint?.effectiveRollup
60-
const hintIsCanonical =
61-
!!hintRollup && hintRollup.toLowerCase() === canonicalRollup.toLowerCase()
62-
const hintIsLegacy =
63-
!!hintRollup && !!rollupAddress && hintRollup.toLowerCase() === rollupAddress.toLowerCase()
64-
const hintNeedsOwnProbe = !!hintRollup && !hintIsCanonical && !hintIsLegacy
6553

66-
const canonicalView = useAttesterView(attesterAddress, canonicalRollup)
67-
const legacyView = useAttesterView(
68-
attesterAddress,
69-
isLegacyDifferent ? rollupAddress : undefined,
70-
)
71-
const hintView = useAttesterView(
72-
attesterAddress,
73-
hintNeedsOwnProbe ? hintRollup : undefined,
54+
const candidates = useMemo(
55+
() =>
56+
attesterRollupCandidates(
57+
canonicalRollup,
58+
getRollupVersions().map((v) => v.address),
59+
rollupAddress,
60+
hintRollup,
61+
),
62+
[canonicalRollup, rollupAddress, hintRollup],
7463
)
7564

76-
const canonicalHasRecord =
77-
canonicalView.status !== undefined && canonicalView.status !== SequencerStatus.NONE
78-
const legacyHasRecord =
79-
isLegacyDifferent &&
80-
legacyView.status !== undefined &&
81-
legacyView.status !== SequencerStatus.NONE
82-
// `hintHasRecord` is gated on `hintNeedsOwnProbe` — by construction it's
83-
// only true when the hint address differs from both canonical and legacy
84-
// (i.e. a third probe was actually issued). When the hint coincides with
85-
// canonical or legacy, the corresponding `*HasRecord` flag already
86-
// covers it; this variable would be redundant there. Don't widen this
87-
// without also reworking the resolution order, or you'll double-count
88-
// the hint coverage.
89-
const hintHasRecord =
90-
hintNeedsOwnProbe &&
91-
hintView.status !== undefined &&
92-
hintView.status !== SequencerStatus.NONE
65+
const { data, isLoading, error, refetch } = useReadContracts({
66+
contracts: candidates.map((address) => ({
67+
address,
68+
abi: contracts.rollup.abi,
69+
functionName: "getAttesterView" as const,
70+
args: attesterAddress ? ([attesterAddress] as const) : undefined,
71+
})),
72+
query: {
73+
enabled: !!attesterAddress,
74+
},
75+
})
76+
77+
const views = candidates.map((_, i) => {
78+
const entry = data?.[i]
79+
return entry?.status === "success" ? entry.result : undefined
80+
})
81+
const selected = selectAttesterRollup(
82+
candidates,
83+
views.map((v) => v?.status),
84+
hintRollup,
85+
)
86+
const attesterView = views[selected]
9387

94-
// Resolution preference, from highest to lowest priority:
95-
// 1. Indexer hint, if its own probe found a record (it's the most
96-
// specific signal — captured from on-chain calldata + migration
97-
// tracking).
98-
// 2. Canonical wins when it has a record (covers moveWithRollup=true).
99-
// 3. Otherwise, legacy if it has a record (covers moveWithRollup=false
100-
// or mid-withdrawal on a legacy rollup).
101-
// 4. Otherwise, fall back to canonical for a well-defined NONE result.
102-
//
103-
// The chain is always authoritative: the hint never overrides a probe
104-
// that found the validator on a different rollup. This keeps the design
105-
// future-proof against indexer regressions.
106-
let preferred = canonicalView
107-
let effectiveRollup: Address = canonicalRollup
108-
if (hintHasRecord && hintRollup) {
109-
preferred = hintView
110-
effectiveRollup = hintRollup
111-
} else if (canonicalHasRecord) {
112-
preferred = canonicalView
113-
effectiveRollup = canonicalRollup
114-
} else if (legacyHasRecord && rollupAddress) {
115-
preferred = legacyView
116-
effectiveRollup = rollupAddress
117-
} else if (hint && hintRollup && (hintIsCanonical || hintIsLegacy)) {
118-
// No probe found a record, but the indexer hint coincided with one
119-
// we already probed. Honour the hint as the "where this stake last
120-
// lived" answer rather than forcing canonical, so the operator at
121-
// least sees a stable target. The probe NONE will still gate the
122-
// unstake button via `useSequencerStatus`.
123-
//
124-
// Also align `preferred` with the chosen rollup. Otherwise the
125-
// returned status/exit/balance would come from the canonicalView
126-
// probe while `effectiveRollup` points at legacy — a self-
127-
// inconsistent result. Both views returned NONE in this branch, so
128-
// the swap is purely cosmetic (loading/error states stay correct),
129-
// but it keeps the contract "preferred describes effectiveRollup".
130-
effectiveRollup = hintRollup
131-
if (hintIsLegacy) {
132-
preferred = legacyView
133-
}
134-
}
88+
// A failed read could be the one rollup holding the record, so surface it
89+
// rather than silently presenting a NONE result.
90+
const readError =
91+
error ?? data?.find((entry) => entry.status === "failure")?.error ?? null
13592

13693
return {
137-
...preferred,
94+
attesterView,
95+
status: attesterView?.status,
96+
effectiveBalance: attesterView?.effectiveBalance,
97+
exit: attesterView?.exit,
98+
config: attesterView?.config,
13899
/** The rollup the live record was found on. Use this as the target for
139100
* any unstake / withdraw write — see hook docstring. */
140-
effectiveRollup,
141-
isLoading:
142-
canonicalView.isLoading ||
143-
(isLegacyDifferent && legacyView.isLoading) ||
144-
(hintNeedsOwnProbe && hintView.isLoading),
145-
error:
146-
preferred.error ||
147-
(isLegacyDifferent ? legacyView.error : undefined) ||
148-
(hintNeedsOwnProbe ? hintView.error : undefined),
101+
effectiveRollup: candidates[selected],
102+
isLoading,
103+
error: readError,
149104
refetch: () => {
150-
canonicalView.refetch()
151-
if (isLegacyDifferent) legacyView.refetch()
152-
if (hintNeedsOwnProbe) hintView.refetch()
105+
refetch()
153106
},
154107
}
155108
}

0 commit comments

Comments
 (0)