From 0eb0992cdb21eec1cd901451dc41df675ac475c8 Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 15 May 2026 14:37:22 +0400 Subject: [PATCH 1/2] chore: follow what rollups each stake is active on --- atp-indexer/ponder.schema.ts | 31 +++ atp-indexer/src/api/handlers/atp/details.ts | 12 ++ .../handlers/staking/beneficiary-overview.ts | 21 ++ atp-indexer/src/api/types/atp.types.ts | 18 +- atp-indexer/src/api/types/staking.types.ts | 35 +++- .../registry/canonical-rollup-updated.ts | 197 ++++++++++++++++-- atp-indexer/src/events/rollup/deposit.ts | 21 +- atp-indexer/src/events/staker/staked.ts | 12 +- .../staking-registry/staked-with-provider.ts | 16 +- atp-indexer/src/utils/move-with-rollup.ts | 163 +++++++++++++++ db-schemas.json | 4 +- .../ATPDetailsDelegationItem.tsx | 20 +- .../ATPDetailsDirectStakeItem.tsx | 20 +- .../ATPDetailsModal/ATPDetailsModal.tsx | 2 - .../ATPDetailsModal/WithdrawalActions.tsx | 68 +++++- .../Registration/WalletDirectStakingFlow.tsx | 9 +- .../WalletDelegationItem.tsx | 14 +- .../WalletDirectStakeItem.tsx | 19 +- .../src/hooks/atp/useATPDetails.ts | 36 +++- .../src/hooks/atp/useAggregatedStakingData.ts | 61 +++++- .../hooks/rollup/useAttesterViewBestEffort.ts | 124 ++++++++++- .../src/hooks/rollup/useRollupVersionFor.ts | 67 ++++++ .../src/hooks/rollup/useSequencerStatus.ts | 17 +- .../src/utils/pendingDirectStakes.ts | 7 + 24 files changed, 913 insertions(+), 81 deletions(-) create mode 100644 atp-indexer/src/utils/move-with-rollup.ts create mode 100644 staking-dashboard/src/hooks/rollup/useRollupVersionFor.ts diff --git a/atp-indexer/ponder.schema.ts b/atp-indexer/ponder.schema.ts index f36f0bee9..74c1d6a3d 100644 --- a/atp-indexer/ponder.schema.ts +++ b/atp-indexer/ponder.schema.ts @@ -74,6 +74,21 @@ export const stakedWithProvider = onchainTable("staked_with_provider", (t) => ({ stakedAmount: t.bigint().notNull(), providerTakeRate: t.integer().notNull(), providerRewardsRecipient: t.hex().notNull(), + /** + * Whether this stake was deposited with `_moveWithLatestRollup = true`. + * Decoded from the originating tx's calldata at insert time. Nullable + * for rows where decoding failed (unknown entry point, calldata mangled, + * etc.) — callers should treat null as "consult the on-chain probe". + */ + moveWithRollup: t.boolean(), + /** + * The rollup that currently holds the live record for this stake. + * Starts equal to `rollupAddress`. Updated to the new canonical rollup + * by the `Registry:CanonicalRollupUpdated` handler whenever + * `moveWithRollup = true`. The dashboard reads this as a fast hint and + * still falls back to the on-chain probe for safety. + */ + effectiveRollup: t.hex().notNull(), txHash: t.hex().notNull(), blockNumber: t.bigint().notNull(), logIndex: t.integer().notNull(), @@ -82,6 +97,7 @@ export const stakedWithProvider = onchainTable("staked_with_provider", (t) => ({ atpAddressIdx: index().on(table.atpAddress), providerIdentifierIdx: index().on(table.providerIdentifier), attesterAddressIdx: index().on(table.attesterAddress), + effectiveRollupIdx: index().on(table.effectiveRollup), })); export const stakedWithProviderRelations = relations(stakedWithProvider, ({ one }) => ({ @@ -109,6 +125,10 @@ export const erc20StakedWithProvider = onchainTable("erc20_staked_with_provider" stakedAmount: t.bigint().notNull(), providerTakeRate: t.integer().notNull(), providerRewardsRecipient: t.hex().notNull(), + /** See `stakedWithProvider.moveWithRollup`. */ + moveWithRollup: t.boolean(), + /** See `stakedWithProvider.effectiveRollup`. */ + effectiveRollup: t.hex().notNull(), txHash: t.hex().notNull(), blockNumber: t.bigint().notNull(), logIndex: t.integer().notNull(), @@ -117,6 +137,7 @@ export const erc20StakedWithProvider = onchainTable("erc20_staked_with_provider" stakerAddressIdx: index().on(table.stakerAddress), providerIdentifierIdx: index().on(table.providerIdentifier), attesterAddressIdx: index().on(table.attesterAddress), + effectiveRollupIdx: index().on(table.effectiveRollup), })); export const erc20StakedWithProviderRelations = relations(erc20StakedWithProvider, ({ one }) => ({ @@ -137,6 +158,10 @@ export const staked = onchainTable("staked", (t) => ({ attesterAddress: t.hex().notNull(), rollupAddress: t.hex().notNull(), stakedAmount: t.bigint().notNull(), + /** See `stakedWithProvider.moveWithRollup`. */ + moveWithRollup: t.boolean(), + /** See `stakedWithProvider.effectiveRollup`. */ + effectiveRollup: t.hex().notNull(), txHash: t.hex().notNull(), blockNumber: t.bigint().notNull(), logIndex: t.integer().notNull(), @@ -144,6 +169,7 @@ export const staked = onchainTable("staked", (t) => ({ }), (table) => ({ atpAddressIdx: index().on(table.atpAddress), attesterAddressIdx: index().on(table.attesterAddress), + effectiveRollupIdx: index().on(table.effectiveRollup), })); export const stakedRelations = relations(staked, ({ one }) => ({ @@ -321,12 +347,17 @@ export const deposit = onchainTable("deposit", (t) => ({ proofOfPossessionX: t.bigint().notNull(), proofOfPossessionY: t.bigint().notNull(), amount: t.bigint().notNull(), + /** See `stakedWithProvider.moveWithRollup`. */ + moveWithRollup: t.boolean(), + /** See `stakedWithProvider.effectiveRollup`. */ + effectiveRollup: t.hex().notNull(), txHash: t.hex().notNull(), blockNumber: t.bigint().notNull(), logIndex: t.integer().notNull(), timestamp: t.bigint().notNull(), }), (table) => ({ attesterAddressIdx: index().on(table.attesterAddress), + effectiveRollupIdx: index().on(table.effectiveRollup), })); /** diff --git a/atp-indexer/src/api/handlers/atp/details.ts b/atp-indexer/src/api/handlers/atp/details.ts index 64f602042..6ae6b62de 100644 --- a/atp-indexer/src/api/handlers/atp/details.ts +++ b/atp-indexer/src/api/handlers/atp/details.ts @@ -30,6 +30,13 @@ function formatDirectStakes( attesterAddress: checksumAddress(stake.attesterAddress), operatorAddress: checksumAddress(stake.operatorAddress), rollupAddress: checksumAddress(stake.rollupAddress), + // Fast-path hint for unstake routing (see `staked.effectiveRollup`). + // Falls back to `rollupAddress` for rows backfilled before the + // column existed. + moveWithRollup: stake.moveWithRollup ?? null, + effectiveRollup: stake.effectiveRollup + ? checksumAddress(stake.effectiveRollup) + : checksumAddress(stake.rollupAddress), stakedAmount: activationThreshold, totalSlashed: totalSlashed.toString(), txHash: stake.txHash, @@ -65,6 +72,11 @@ function formatDelegations( providerLogo: metadata?.providerLogoUrl || '', operatorAddress: checksumAddress(op.attesterAddress), rollupAddress: checksumAddress(op.rollupAddress), + // See formatDirectStakes for the rationale. + moveWithRollup: op.moveWithRollup ?? null, + effectiveRollup: op.effectiveRollup + ? checksumAddress(op.effectiveRollup) + : checksumAddress(op.rollupAddress), stakedAmount: activationThreshold, totalSlashed: totalSlashed.toString(), splitContract: checksumAddress(op.splitContractAddress), diff --git a/atp-indexer/src/api/handlers/staking/beneficiary-overview.ts b/atp-indexer/src/api/handlers/staking/beneficiary-overview.ts index 3ea96d34b..e3d048890 100644 --- a/atp-indexer/src/api/handlers/staking/beneficiary-overview.ts +++ b/atp-indexer/src/api/handlers/staking/beneficiary-overview.ts @@ -130,6 +130,13 @@ export async function handleBeneficiaryStakingOverview(c: Context): Promise { const { instance, version } = event.args; const { db } = context; + const newCanonical = normalizeAddress(instance) as `0x${string}`; - await db.insert(rollupVersion).values({ - version, - address: normalizeAddress(instance) as `0x${string}`, - blockNumber: event.block.number, - txHash: event.transaction.hash, - logIndex: event.log.logIndex, - timestamp: event.block.timestamp, - }); + await db + .insert(rollupVersion) + .values({ + version, + address: newCanonical, + blockNumber: event.block.number, + txHash: event.transaction.hash, + logIndex: event.log.logIndex, + timestamp: event.block.timestamp, + }) + .onConflictDoNothing(); + + // WHERE moveWithRollup = true OR moveWithRollup IS NULL → matches + // explicit-true and unknown rows, excludes explicit-false. The column + // is 3-valued (true/false/null); these two predicates partition the + // "should migrate" set unambiguously. + const isMaybeMigrating = (table: { moveWithRollup: unknown }) => + or( + eq(table.moveWithRollup as Parameters[0], true), + isNull(table.moveWithRollup as Parameters[0]), + ); + + // Find attesters CURRENTLY mid-exit (latest withdrawInitiated has no + // later withdrawFinalized). The exit is locked to the rollup where + // initiate-withdraw was called, so these rows must NOT have their + // `effectiveRollup` rewritten. + // + // Aggregate in SQL: one row per attester per event type. Without + // GROUP BY we'd be scanning the full event-history tables on every + // canonical event — fine today, painful on a long-running chain. + // + // Note: we only iterate `latestInitiate` and look up against + // `latestFinalize`. A finalize-only attester (no preceding initiate) + // is impossible by protocol — the contract requires an initiate + // before finalize — so the asymmetry doesn't drop any real exiting + // rows. + // + // Lifecycle handled: initiate → finalize → re-deposit → re-initiate + // appears as exiting (newer initiate ts > newer finalize ts). A + // finalized-and-not-re-initiated validator does NOT appear, so their + // row migrates with canonical rotations. + const [latestInitiate, latestFinalize] = await Promise.all([ + db.sql + .select({ + attesterAddress: withdrawInitiated.attesterAddress, + maxTimestamp: sql`MAX(${withdrawInitiated.timestamp})`.as("max_ts"), + }) + .from(withdrawInitiated) + .groupBy(withdrawInitiated.attesterAddress), + db.sql + .select({ + attesterAddress: withdrawFinalized.attesterAddress, + maxTimestamp: sql`MAX(${withdrawFinalized.timestamp})`.as("max_ts"), + }) + .from(withdrawFinalized) + .groupBy(withdrawFinalized.attesterAddress), + ]); + const latestFinalizeByAttester = new Map(); + for (const r of latestFinalize) { + latestFinalizeByAttester.set(r.attesterAddress, r.maxTimestamp); + } + const exitingAttesters: `0x${string}`[] = []; + for (const r of latestInitiate) { + const finalizeTs = latestFinalizeByAttester.get(r.attesterAddress); + if (finalizeTs === undefined || r.maxTimestamp > finalizeTs) { + exitingAttesters.push(r.attesterAddress as `0x${string}`); + } + } + + const isNotExiting = (table: { attesterAddress: unknown }) => + notInArray( + table.attesterAddress as Parameters[0], + exitingAttesters, + ); + + const tablesToMigrate = [ + { table: deposit, label: "deposit" }, + { table: staked, label: "staked" }, + { table: stakedWithProvider, label: "stakedWithProvider" }, + { table: erc20StakedWithProvider, label: "erc20StakedWithProvider" }, + ] as const; + + let totalUpdated = 0; + for (const { table, label } of tablesToMigrate) { + try { + const result = await db.sql + .update(table) + .set({ effectiveRollup: newCanonical }) + .where(and(isMaybeMigrating(table), isNotExiting(table))); + const rowCount = (result as unknown as { rowCount?: number }).rowCount ?? null; + if (rowCount !== null) { + totalUpdated += rowCount; + console.log(` effectiveRollup migration: ${label} updated ${rowCount} rows`); + } + } catch (err) { + console.error(` effectiveRollup migration failed for ${label}:`, err); + // Re-throw so the indexer retries this event rather than leaving + // half-migrated state. The raw SQL update is idempotent: re-running + // it against rows already pointing at `newCanonical` is a no-op. + throw err; + } + } console.log( - `Canonical rollup updated: version ${version}, instance ${instance}` + `Canonical rollup updated: version ${version}, instance ${instance}, effectiveRollup-migrated rows: ${totalUpdated || "(driver did not report count)"}`, ); }); diff --git a/atp-indexer/src/events/rollup/deposit.ts b/atp-indexer/src/events/rollup/deposit.ts index 47f8faad4..1c10fb8be 100644 --- a/atp-indexer/src/events/rollup/deposit.ts +++ b/atp-indexer/src/events/rollup/deposit.ts @@ -1,21 +1,32 @@ import { ponder } from "ponder:registry"; import { normalizeAddress } from "../../utils/address"; import { deposit } from "ponder:schema"; +import { decodeMoveWithRollup } from "../../utils/move-with-rollup"; /** - * Handle Deposit event from Rollup contract - * Records successful validator deposits with BLS keys + * Handle Deposit event from Rollup contract. + * Records successful validator deposits with BLS keys. + * + * Also captures `moveWithRollup` from the originating tx's calldata (not in + * the event payload, but recoverable from the call args) so the dashboard + * can resolve which rollup currently holds the live stake without an + * on-chain probe. `effectiveRollup` starts equal to the deposit rollup; + * the canonical-rollup-updated handler later rewrites it for rows where + * `moveWithRollup = true`. */ ponder.on("Rollup:Deposit", async ({ event, context }) => { const { attester, withdrawer, publicKeyInG1, publicKeyInG2, proofOfPossession, amount } = event.args; const { db } = context; + const depositRollup = normalizeAddress(event.log.address) as `0x${string}`; + const moveWithRollup = decodeMoveWithRollup(event.transaction.input); + await db.insert(deposit).values({ id: `${event.transaction.hash}-${event.log.logIndex}`, attesterAddress: normalizeAddress(attester) as `0x${string}`, withdrawerAddress: normalizeAddress(withdrawer) as `0x${string}`, - rollupAddress: normalizeAddress(event.log.address) as `0x${string}`, + rollupAddress: depositRollup, publicKeyG1X: publicKeyInG1.x, publicKeyG1Y: publicKeyInG1.y, publicKeyG2X0: publicKeyInG2.x0, @@ -25,6 +36,8 @@ ponder.on("Rollup:Deposit", async ({ event, context }) => { proofOfPossessionX: proofOfPossession.x, proofOfPossessionY: proofOfPossession.y, amount, + moveWithRollup, + effectiveRollup: depositRollup, txHash: event.transaction.hash, blockNumber: event.block.number, logIndex: event.log.logIndex, @@ -32,6 +45,6 @@ ponder.on("Rollup:Deposit", async ({ event, context }) => { }) console.log( - `Deposit recorded: attester ${attester}, withdrawer ${withdrawer}, amount ${amount}` + `Deposit recorded: attester ${attester}, withdrawer ${withdrawer}, amount ${amount}, moveWithRollup=${moveWithRollup}` ); }); diff --git a/atp-indexer/src/events/staker/staked.ts b/atp-indexer/src/events/staker/staked.ts index fb1200fb0..37ef9019e 100644 --- a/atp-indexer/src/events/staker/staked.ts +++ b/atp-indexer/src/events/staker/staked.ts @@ -2,6 +2,7 @@ import { ponder } from "ponder:registry"; import { normalizeAddress } from "../../utils/address"; import { staked, atpPosition } from "ponder:schema"; import { getActivationThreshold } from "../../utils/rollup"; +import { decodeMoveWithRollup } from "../../utils/move-with-rollup"; ponder.on("Staker:Staked", async ({ event, context }) => { const { staker, attester, rollup } = event.args; @@ -21,6 +22,13 @@ ponder.on("Staker:Staked", async ({ event, context }) => { const activationThreshold = await getActivationThreshold(rollup, client); + const rollupAddress = normalizeAddress(rollup) as `0x${string}`; + // `moveWithRollup` is an arg of the originating tx (e.g. + // StakingRegistry.stake / Staker.stake), not in this Staked event. + // Decode from calldata; null means the entry point isn't one we + // recognise and the dashboard should fall back to the on-chain probe. + const moveWithRollup = decodeMoveWithRollup(event.transaction.input); + await db.insert(staked).values({ id: `${event.transaction.hash}-${event.log.logIndex}`, atpAddress: normalizeAddress(atp.address) as `0x${string}`, @@ -28,7 +36,9 @@ ponder.on("Staker:Staked", async ({ event, context }) => { stakedAmount: BigInt(activationThreshold), operatorAddress: normalizeAddress(atp.operatorAddress || atp.address) as `0x${string}`, attesterAddress: normalizeAddress(attester) as `0x${string}`, - rollupAddress: normalizeAddress(rollup) as `0x${string}`, + rollupAddress, + moveWithRollup, + effectiveRollup: rollupAddress, txHash: event.transaction.hash, blockNumber: event.block.number, logIndex: event.log.logIndex, diff --git a/atp-indexer/src/events/staking-registry/staked-with-provider.ts b/atp-indexer/src/events/staking-registry/staked-with-provider.ts index 0c3ea24d2..d35b0e748 100644 --- a/atp-indexer/src/events/staking-registry/staked-with-provider.ts +++ b/atp-indexer/src/events/staking-registry/staked-with-provider.ts @@ -2,6 +2,7 @@ import { ponder } from "ponder:registry"; import { normalizeAddress } from "../../utils/address"; import { getActivationThreshold } from "../../utils/rollup"; import { stakedWithProvider, erc20StakedWithProvider, atpPosition, provider } from "ponder:schema"; +import { decodeMoveWithRollup } from "../../utils/move-with-rollup"; ponder.on("StakingRegistry:StakedWithProvider", async ({ event, context }) => { try { @@ -33,6 +34,13 @@ ponder.on("StakingRegistry:StakedWithProvider", async ({ event, context }) => { return; // Don't throw - provider config is metadata, not critical financial data } + const normalizedRollupAddress = normalizeAddress(rollupAddress) as `0x${string}`; + // `moveWithRollup` lives in the originating tx's calldata + // (StakingRegistry.stake takes `_moveWithLatestRollup`). Decode it so + // the dashboard can short-circuit its on-chain rollup-resolution probe + // for known rows. null → the dashboard treats it as unknown. + const moveWithRollup = decodeMoveWithRollup(event.transaction.input); + // Branch based on whether ATP exists if (atp) { // ATP-based staking - insert into stakedWithProvider @@ -43,11 +51,13 @@ ponder.on("StakingRegistry:StakedWithProvider", async ({ event, context }) => { operatorAddress: normalizeAddress(atp.operatorAddress || atp.address) as `0x${string}`, splitContractAddress: normalizeAddress(coinbaseSplitContractAddress) as `0x${string}`, providerIdentifier: providerIdentifier.toString(), - rollupAddress: normalizeAddress(rollupAddress) as `0x${string}`, + rollupAddress: normalizedRollupAddress, attesterAddress: normalizeAddress(attester) as `0x${string}`, stakedAmount: BigInt(activationThreshold), providerTakeRate: providerConfig.providerTakeRate, providerRewardsRecipient: providerConfig.rewardsRecipient, + moveWithRollup, + effectiveRollup: normalizedRollupAddress, txHash: event.transaction.hash, blockNumber: event.block.number, logIndex: event.log.logIndex, @@ -60,11 +70,13 @@ ponder.on("StakingRegistry:StakedWithProvider", async ({ event, context }) => { stakerAddress, splitContractAddress: normalizeAddress(coinbaseSplitContractAddress) as `0x${string}`, providerIdentifier: providerIdentifier.toString(), - rollupAddress: normalizeAddress(rollupAddress) as `0x${string}`, + rollupAddress: normalizedRollupAddress, attesterAddress: normalizeAddress(attester) as `0x${string}`, stakedAmount: BigInt(activationThreshold), providerTakeRate: providerConfig.providerTakeRate, providerRewardsRecipient: providerConfig.rewardsRecipient, + moveWithRollup, + effectiveRollup: normalizedRollupAddress, txHash: event.transaction.hash, blockNumber: event.block.number, logIndex: event.log.logIndex, diff --git a/atp-indexer/src/utils/move-with-rollup.ts b/atp-indexer/src/utils/move-with-rollup.ts new file mode 100644 index 000000000..e6fb44148 --- /dev/null +++ b/atp-indexer/src/utils/move-with-rollup.ts @@ -0,0 +1,163 @@ +import { decodeFunctionData, toFunctionSelector, type Abi, type AbiFunction } from "viem" + +/** + * Minimal ABI fragments for the user-facing entry points that take a + * `_moveWithLatestRollup` / `_moveWithRollup` boolean. We only need the + * function signatures to decode calldata — never to encode or send — so we + * keep these fragments local to the decoder rather than bloating the + * indexer's main ABI files (which are scoped to events + view reads). + * + * If a new entry point is added to the protocol, append its signature here. + */ +const ROLLUP_DEPOSIT_ABI = [ + { + type: "function", + name: "deposit", + inputs: [ + { name: "_attester", type: "address" }, + { name: "_withdrawer", type: "address" }, + { + name: "_publicKeyInG1", + type: "tuple", + components: [ + { name: "x", type: "uint256" }, + { name: "y", type: "uint256" }, + ], + }, + { + name: "_publicKeyInG2", + type: "tuple", + components: [ + { name: "x0", type: "uint256" }, + { name: "x1", type: "uint256" }, + { name: "y0", type: "uint256" }, + { name: "y1", type: "uint256" }, + ], + }, + { + name: "_proofOfPossession", + type: "tuple", + components: [ + { name: "x", type: "uint256" }, + { name: "y", type: "uint256" }, + ], + }, + { name: "_moveWithRollup", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const satisfies Abi + +const STAKING_REGISTRY_STAKE_ABI = [ + { + type: "function", + name: "stake", + inputs: [ + { name: "_providerIdentifier", type: "uint256" }, + { name: "_rollupVersion", type: "uint256" }, + { name: "_withdrawalAddress", type: "address" }, + { name: "_expectedProviderTakeRate", type: "uint16" }, + { name: "_userRewardsRecipient", type: "address" }, + { name: "_moveWithLatestRollup", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const satisfies Abi + +const STAKER_STAKE_ABI = [ + { + type: "function", + name: "stake", + inputs: [ + { name: "_providerIdentifier", type: "uint256" }, + { name: "_rollupVersion", type: "uint256" }, + { name: "_attester", type: "address" }, + { name: "_expectedProviderTakeRate", type: "uint16" }, + { name: "_userRewardsRecipient", type: "address" }, + { name: "_moveWithLatestRollup", type: "bool" }, + ], + outputs: [], + stateMutability: "nonpayable", + }, +] as const satisfies Abi + +/** + * Selector → (abi, target function) lookup table, computed once at module + * load. Dispatching on the 4-byte selector is the deterministic way to + * decode: it eliminates the "wrong-ABI happens to decode because all args + * are 32-byte words" hazard that an exception-driven trial-and-error + * approach would have. + * + * Note that `StakingRegistry.stake(uint256,uint256,address,uint16,address,bool)` + * and `Staker.stake(uint256,uint256,address,uint16,address,bool)` share the + * same Solidity signature once the `_rollupVersion`/`_attester` parameter + * names are erased — so they share the same selector and either ABI + * decodes the result identically. We pick StakingRegistry as canonical and + * note this in the lookup. + */ +function buildSelectorTable(): Map { + const out = new Map() + const candidates: Abi[] = [ROLLUP_DEPOSIT_ABI, STAKING_REGISTRY_STAKE_ABI, STAKER_STAKE_ABI] + + for (const abi of candidates) { + for (const item of abi) { + if (item.type !== "function") continue + const fn = item as AbiFunction + const selector = toFunctionSelector(fn) + // First wins. The StakingRegistry and Staker `stake(...)` have the + // same selector by Solidity's signature rules; either decodes the + // calldata to the same args, so it doesn't matter which one we keep. + if (!out.has(selector)) out.set(selector, { abi, fn }) + } + } + return out +} + +const SELECTOR_TABLE = buildSelectorTable() + +/** + * Try to decode the `moveWithRollup` boolean from a transaction's calldata. + * + * The on-chain `Deposit` event doesn't include the flag — it's an arg to + * the originating function call — so we recover it from the originating + * tx's input. We dispatch on the 4-byte function selector to pick the + * correct ABI deterministically (rather than try-each-ABI-and-hope). + * + * Returns: + * - `true` / `false` when the selector matches a known entry point and + * the matched function's arg with name `_moveWithLatestRollup` or + * `_moveWithRollup` decodes to a boolean. + * - `null` when the selector matches no known entry point (e.g. the + * deposit was issued via Safe.execTransaction, MultiSend, a router, + * or any contract we haven't catalogued here), or the decode fails + * for any other reason. Indexer rows persisted with `null` are + * treated by the canonical-rollup migration handler as "presume + * migrating" — the dashboard's on-chain probe is the safety net + * for the rare case the user actually deposited with `false`. + */ +export function decodeMoveWithRollup(calldata: `0x${string}`): boolean | null { + // Every Solidity call carries at least a 4-byte selector (0x + 8 hex chars). + if (!calldata || calldata.length < 10) return null + + const selector = calldata.slice(0, 10) as `0x${string}` + const match = SELECTOR_TABLE.get(selector) + if (!match) return null + + try { + const decoded = decodeFunctionData({ abi: match.abi, data: calldata }) + const idx = match.fn.inputs.findIndex( + (inp) => inp.name === "_moveWithLatestRollup" || inp.name === "_moveWithRollup", + ) + if (idx < 0) return null + + const value = (decoded.args as readonly unknown[])[idx] + return typeof value === "boolean" ? value : null + } catch { + // Malformed calldata for the matched selector — exceedingly rare in + // practice (would require an on-chain tx that crashed before + // executing). Treat as unknown. + return null + } +} diff --git a/db-schemas.json b/db-schemas.json index c36df4a88..3f47c571a 100644 --- a/db-schemas.json +++ b/db-schemas.json @@ -1,6 +1,6 @@ { "atp-indexer": { - "testnet": "atp-indexer-testnet-v03", - "prod": "atp-indexer-prod-v21" + "testnet": "atp-indexer-testnet-v04", + "prod": "atp-indexer-prod-v22" } } diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx index 0eec93099..01e3a4bdf 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx @@ -14,6 +14,7 @@ import { getExplorerTxUrl, getExplorerAddressUrl } from "@/utils/explorerUtils" import { useSequencerStatus, SequencerStatus } from "@/hooks/rollup/useSequencerStatus" import { useStakeHealth } from "@/hooks/rollup/useStakeHealth" import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" +import { useRollupVersionFor } from "@/hooks/rollup/useRollupVersionFor" import { useGovernanceConfig } from "@/hooks/governance" import { WithdrawalActions } from "./WithdrawalActions" import type { Delegation, StakeWithProviderReward } from "@/hooks" @@ -23,7 +24,6 @@ interface ATPDetailsDelegationItemProps { delegationRewards: StakeWithProviderReward isLoadingDelegationRewards: boolean stakerAddress: Address - rollupVersion: bigint onClaimClick: (delegation: { splitContract: string providerName: string | null @@ -46,7 +46,6 @@ export const ATPDetailsDelegationItem = ({ delegationRewards, isLoadingDelegationRewards, stakerAddress, - rollupVersion, onClaimClick, onWithdrawSuccess, atpType, @@ -59,7 +58,19 @@ export const ATPDetailsDelegationItem = ({ const { isRewardsClaimable } = useIsRewardsClaimable() const delegationRollupAddress = delegation.rollupAddress as Address - const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, refetch: refetchStatus } = useSequencerStatus(delegation.operatorAddress as Address, delegationRollupAddress) + // `effectiveRollup` is the rollup that holds the live attester record + // right now — it may differ from the indexer's deposit-time + // `delegationRollupAddress` once the stake has migrated (see + // `useAttesterViewBestEffort`). Any unstake write must target the + // effective rollup, AND must pass the matching on-chain version to + // `Staker.initiateWithdraw(version, attester)` so the Staker routes + // to the same rollup. Indexer hint short-circuits the probe. + const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, effectiveRollup, refetch: refetchStatus } = useSequencerStatus( + delegation.operatorAddress as Address, + delegationRollupAddress, + { effectiveRollup: delegation.effectiveRollup, moveWithRollup: delegation.moveWithRollup }, + ) + const { version: effectiveRollupVersion } = useRollupVersionFor(effectiveRollup) const { withdrawalDelayDays } = useGovernanceConfig() const { @@ -461,8 +472,7 @@ export const ATPDetailsDelegationItem = ({ void onWithdrawSuccess?: () => void @@ -36,7 +36,7 @@ interface ATPDetailsDirectStakeItemProps { * Individual self stake item component * Displays sequencer address, transaction info, and links to explorers */ -export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, atp, onClaimSuccess, onWithdrawSuccess, atpType, registryAddress, milestoneId }: ATPDetailsDirectStakeItemProps) => { +export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, atp, onClaimSuccess, onWithdrawSuccess, atpType, registryAddress, milestoneId }: ATPDetailsDirectStakeItemProps) => { const [isExpanded, setIsExpanded] = useState(false) const [isClaimModalOpen, setIsClaimModalOpen] = useState(false) const { symbol, decimals } = useStakingAssetTokenDetails() @@ -44,7 +44,18 @@ export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, const { isRewardsClaimable } = useIsRewardsClaimable() const stakeRollupAddress = stake.rollupAddress as Address - const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, refetch: refetchStatus } = useSequencerStatus(stake.attesterAddress as Address, stakeRollupAddress) + // See `useAttesterViewBestEffort`: the live record may live on a + // different rollup than the indexer-recorded deposit rollup, especially + // for stakes deposited with `moveWithRollup = true`. Use `effectiveRollup` + // (and its matching on-chain version) for any unstake write so the + // Staker routes to where the stake actually lives. Indexer hint + // short-circuits the probe when present. + const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, effectiveRollup, refetch: refetchStatus } = useSequencerStatus( + stake.attesterAddress as Address, + stakeRollupAddress, + { effectiveRollup: stake.effectiveRollup, moveWithRollup: stake.moveWithRollup }, + ) + const { version: effectiveRollupVersion } = useRollupVersionFor(effectiveRollup) const { withdrawalDelayDays } = useGovernanceConfig() const { @@ -391,8 +402,7 @@ export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, {isInitiateQueued ? ( @@ -186,7 +226,13 @@ export const WithdrawalActions = ({ ? "bg-chartreuse/20 border border-chartreuse/40 text-chartreuse hover:bg-chartreuse/30" : "bg-chartreuse text-ink hover:bg-chartreuse/90" }`} - title={isMilestoneGated ? milestoneBlockError || undefined : undefined} + title={ + isVersionResolving + ? "Resolving rollup version…" + : isMilestoneGated + ? milestoneBlockError || undefined + : undefined + } > {isFinalizeQueued ? ( diff --git a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx index e6828b659..5b0e93d71 100644 --- a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx +++ b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx @@ -126,18 +126,23 @@ export const WalletDirectStakingFlow = ({ return } - // Add to localStorage so it appears in UI immediately + // Add to localStorage so it appears in UI immediately. Capture + // `moveWithRollup` from the deposit-flow's current value rather + // than hardcoding — if a future flow exposes a toggle, the + // pending row reflects the operator's actual choice and the + // aggregator's hint stays correct. addPendingDirectStake(address, { attesterAddress: attesterAddress as Address, withdrawerAddress: address as Address, stakedAmount: activationThreshold.toString(), txHash, timestamp: Math.floor(Date.now() / 1000), + moveWithRollup: moveWithLatestRollup, }) addedPendingStakesRef.current.add(attesterAddress) }) - }, [depositTxs, address, activationThreshold]) + }, [depositTxs, address, activationThreshold, moveWithLatestRollup]) // Track when all deposits complete useEffect(() => { diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx index a22805afc..4a572fad1 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx @@ -43,8 +43,18 @@ export const WalletDelegationItem = ({ const { date, time } = formatBlockTimestamp(delegation.timestamp) const { isRewardsClaimable } = useIsRewardsClaimable() + // See WalletDirectStakeItem for the rationale: `delegationRollupAddress` + // is the deposit-time rollup. The protocol may have migrated the live + // record to the canonical rollup (`moveWithRollup = true` flow), so any + // unstake / withdraw write must target `effectiveRollup` — the rollup + // that holds the live attester record right now. Indexer hint short- + // circuits the probe; on-chain probe still runs as the safety net. const delegationRollupAddress = delegation.rollupAddress as Address - const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, refetch: refetchStatus } = useSequencerStatus(delegation.attesterAddress as Address, delegationRollupAddress) + const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, effectiveRollup, refetch: refetchStatus } = useSequencerStatus( + delegation.attesterAddress as Address, + delegationRollupAddress, + { effectiveRollup: delegation.effectiveRollup, moveWithRollup: delegation.moveWithRollup }, + ) const { withdrawalDelayDays } = useGovernanceConfig() const { @@ -359,7 +369,7 @@ export const WalletDelegationItem = ({ ({ + ...stake, + effectiveRollup: (stake.effectiveRollup ?? stake.rollupAddress) as `0x${string}`, + moveWithRollup: stake.moveWithRollup ?? null, + })), delegations: response.delegations.map(delegation => ({ ...delegation, - stakedAmount: stringToBigInt(delegation.stakedAmount) + stakedAmount: stringToBigInt(delegation.stakedAmount), + effectiveRollup: (delegation.effectiveRollup ?? delegation.rollupAddress) as `0x${string}`, + moveWithRollup: delegation.moveWithRollup ?? null, })) } } diff --git a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts index e3fcb67a8..27a98ad60 100644 --- a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts +++ b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts @@ -17,7 +17,19 @@ import { cleanupStalePendingStakes, } from '@/utils/pendingDirectStakes' -export interface DirectStakeBreakdown { +/** + * Hint fields the indexer attaches to every stake-shaped row. The + * dashboard uses these to pre-target unstake writes at the correct + * rollup without an extra on-chain probe. `moveWithRollup = null` means + * the indexer couldn't decode the flag (unrecognised entry point) and the + * caller should fall back to the on-chain probe. + */ +export interface EffectiveRollupHints { + effectiveRollup: Address + moveWithRollup: boolean | null +} + +export interface DirectStakeBreakdown extends EffectiveRollupHints { atpAddress: Address attesterAddress: Address rollupAddress: Address @@ -36,7 +48,7 @@ export interface DirectStakeBreakdown { providerLogo?: string } -export interface DelegationBreakdown { +export interface DelegationBreakdown extends EffectiveRollupHints { atpAddress: Address providerId: number providerName?: string @@ -64,7 +76,7 @@ export interface DelegationBreakdown { splitContractBalance: bigint } -export interface Erc20DelegationBreakdown { +export interface Erc20DelegationBreakdown extends EffectiveRollupHints { providerId: number providerName?: string providerLogo?: string @@ -89,7 +101,7 @@ export interface Erc20DelegationBreakdown { splitContractBalance: bigint } -export interface Erc20DirectStakeBreakdown { +export interface Erc20DirectStakeBreakdown extends EffectiveRollupHints { attesterAddress: Address withdrawerAddress: Address rollupAddress: Address @@ -122,7 +134,14 @@ export interface AggregatedStakingData { refetch: () => void } -interface ApiDirectStake { +// API-shape mirror of `EffectiveRollupHints` — same fields but string types +// because the API hasn't been through parsing yet. +interface ApiEffectiveRollupHints { + effectiveRollup?: string + moveWithRollup?: boolean | null +} + +interface ApiDirectStake extends ApiEffectiveRollupHints { atpAddress: string attesterAddress: string rollupAddress: string @@ -141,7 +160,7 @@ interface ApiDirectStake { providerLogo?: string } -interface ApiDelegation { +interface ApiDelegation extends ApiEffectiveRollupHints { atpAddress: string providerId: number providerName?: string @@ -161,7 +180,7 @@ interface ApiDelegation { blockNumber: number } -interface ApiErc20Delegation { +interface ApiErc20Delegation extends ApiEffectiveRollupHints { providerId: number providerName?: string providerLogo?: string @@ -180,7 +199,7 @@ interface ApiErc20Delegation { blockNumber: number } -interface ApiErc20DirectStake { +interface ApiErc20DirectStake extends ApiEffectiveRollupHints { attesterAddress: string withdrawerAddress: string rollupAddress: string @@ -220,6 +239,21 @@ async function fetchStakingData(beneficiary: Address): Promise { attesterAddress: stake.attesterAddress, withdrawerAddress: stake.withdrawerAddress, rollupAddress: contracts.rollup.address, + // Pending (locally-tracked, pre-indexer) stakes always target the + // current canonical rollup — that's where the dashboard's deposit + // flow submits. `moveWithRollup` comes from the deposit-flow's + // captured value; older localStorage entries (pre-plumbing) + // default to `true`, matching what the flow used at the time. + effectiveRollup: contracts.rollup.address, + moveWithRollup: stake.moveWithRollup ?? true, stakedAmount: BigInt(stake.stakedAmount), hasFailedDeposit: false, failedDepositTxHash: null, diff --git a/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts b/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts index de7682d39..953b83f3f 100644 --- a/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts +++ b/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts @@ -4,48 +4,152 @@ import { SequencerStatus } from "./sequencerStatus" import { contracts } from "@/contracts" /** - * Looks up an attester via `getAttesterView` against both the canonical rollup - * and the delegation's legacy rollup (when different), and returns whichever - * view recognises the attester (non-NONE status). Covers: + * Optional indexer-supplied hint about which rollup currently holds the + * live record. The indexer captures `moveWithRollup` from the originating + * tx's calldata and bulk-updates `effectiveRollup` whenever the canonical + * rollup rotates. If the caller has these handy, this hook uses them as + * the **preferred** probe candidate — saving an RPC roundtrip in the + * common case. The on-chain probe still runs as a safety net: if it sees a + * live record on a different rollup than the hint suggested, the probe's + * answer wins (the chain is authoritative). + */ +export interface AttesterViewHint { + effectiveRollup: Address + moveWithRollup: boolean | null +} + +/** + * Looks up an attester via `getAttesterView` against the candidate rollups + * (canonical, legacy/deposit-time, and an optional indexer hint) and returns + * whichever view recognises the attester (non-NONE status). Covers: * * - Active sequencer on canonical rollup with old delegation record — the * legacy view returns NONE; we fall through to canonical. + * This is the `moveWithRollup = true` (auto-migrating) case: the stake + * follows the canonical rollup as it upgrades. * - Legacy stake mid-withdrawal — canonical returns NONE; we fall through * to legacy so the exit data is still visible. + * This is the `moveWithRollup = false` case: the stake stays on the + * rollup it was originally deposited on. * - Genuinely unregistered — both NONE; we return the canonical view so * callers still get a well-defined result. * + * Returns the **effective rollup** the live record was found on, alongside + * the view data. Callers that need to send writes (unstake, etc.) should + * use `effectiveRollup` as the target — NOT the indexer-supplied + * `rollupAddress`, which records where the deposit happened and may + * disagree with where the stake currently lives. + * * Used by `useSequencerStatus` and `useStakeHealth` to avoid duplicating the * preference logic. */ export function useAttesterViewBestEffort( attesterAddress: Address | undefined, rollupAddress: Address | undefined, + hint?: AttesterViewHint | null, ) { const canonicalRollup = contracts.rollup.address const isLegacyDifferent = !!rollupAddress && rollupAddress.toLowerCase() !== canonicalRollup.toLowerCase() + // The indexer hint may match canonical or legacy or a third address. Only + // probe the hint separately when it doesn't coincide with one we'd probe + // anyway. This keeps the common case to exactly two reads (canonical + + // legacy) while still supporting unusual hints. + const hintRollup = hint?.effectiveRollup + const hintIsCanonical = + !!hintRollup && hintRollup.toLowerCase() === canonicalRollup.toLowerCase() + const hintIsLegacy = + !!hintRollup && !!rollupAddress && hintRollup.toLowerCase() === rollupAddress.toLowerCase() + const hintNeedsOwnProbe = !!hintRollup && !hintIsCanonical && !hintIsLegacy + const canonicalView = useAttesterView(attesterAddress, canonicalRollup) const legacyView = useAttesterView( attesterAddress, isLegacyDifferent ? rollupAddress : undefined, ) + const hintView = useAttesterView( + attesterAddress, + hintNeedsOwnProbe ? hintRollup : undefined, + ) - const preferred = + const canonicalHasRecord = canonicalView.status !== undefined && canonicalView.status !== SequencerStatus.NONE - ? canonicalView - : isLegacyDifferent && legacyView.status !== undefined && legacyView.status !== SequencerStatus.NONE - ? legacyView - : canonicalView + const legacyHasRecord = + isLegacyDifferent && + legacyView.status !== undefined && + legacyView.status !== SequencerStatus.NONE + // `hintHasRecord` is gated on `hintNeedsOwnProbe` — by construction it's + // only true when the hint address differs from both canonical and legacy + // (i.e. a third probe was actually issued). When the hint coincides with + // canonical or legacy, the corresponding `*HasRecord` flag already + // covers it; this variable would be redundant there. Don't widen this + // without also reworking the resolution order, or you'll double-count + // the hint coverage. + const hintHasRecord = + hintNeedsOwnProbe && + hintView.status !== undefined && + hintView.status !== SequencerStatus.NONE + + // Resolution preference, from highest to lowest priority: + // 1. Indexer hint, if its own probe found a record (it's the most + // specific signal — captured from on-chain calldata + migration + // tracking). + // 2. Canonical wins when it has a record (covers moveWithRollup=true). + // 3. Otherwise, legacy if it has a record (covers moveWithRollup=false + // or mid-withdrawal on a legacy rollup). + // 4. Otherwise, fall back to canonical for a well-defined NONE result. + // + // The chain is always authoritative: the hint never overrides a probe + // that found the validator on a different rollup. This keeps the design + // future-proof against indexer regressions. + let preferred = canonicalView + let effectiveRollup: Address = canonicalRollup + if (hintHasRecord && hintRollup) { + preferred = hintView + effectiveRollup = hintRollup + } else if (canonicalHasRecord) { + preferred = canonicalView + effectiveRollup = canonicalRollup + } else if (legacyHasRecord && rollupAddress) { + preferred = legacyView + effectiveRollup = rollupAddress + } else if (hint && hintRollup && (hintIsCanonical || hintIsLegacy)) { + // No probe found a record, but the indexer hint coincided with one + // we already probed. Honour the hint as the "where this stake last + // lived" answer rather than forcing canonical, so the operator at + // least sees a stable target. The probe NONE will still gate the + // unstake button via `useSequencerStatus`. + // + // Also align `preferred` with the chosen rollup. Otherwise the + // returned status/exit/balance would come from the canonicalView + // probe while `effectiveRollup` points at legacy — a self- + // inconsistent result. Both views returned NONE in this branch, so + // the swap is purely cosmetic (loading/error states stay correct), + // but it keeps the contract "preferred describes effectiveRollup". + effectiveRollup = hintRollup + if (hintIsLegacy) { + preferred = legacyView + } + } return { ...preferred, - isLoading: canonicalView.isLoading || (isLegacyDifferent && legacyView.isLoading), - error: preferred.error || (isLegacyDifferent ? legacyView.error : undefined), + /** The rollup the live record was found on. Use this as the target for + * any unstake / withdraw write — see hook docstring. */ + effectiveRollup, + isLoading: + canonicalView.isLoading || + (isLegacyDifferent && legacyView.isLoading) || + (hintNeedsOwnProbe && hintView.isLoading), + error: + preferred.error || + (isLegacyDifferent ? legacyView.error : undefined) || + (hintNeedsOwnProbe ? hintView.error : undefined), refetch: () => { canonicalView.refetch() if (isLegacyDifferent) legacyView.refetch() + if (hintNeedsOwnProbe) hintView.refetch() }, } } diff --git a/staking-dashboard/src/hooks/rollup/useRollupVersionFor.ts b/staking-dashboard/src/hooks/rollup/useRollupVersionFor.ts new file mode 100644 index 000000000..d1051e8e0 --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useRollupVersionFor.ts @@ -0,0 +1,67 @@ +import { useMemo } from "react" +import { useReadContract } from "wagmi" +import type { Address } from "viem" +import { contracts, getRollupVersions } from "@/contracts" + +/** + * Resolve the on-chain `getVersion()` for a specific rollup address. + * + * For ATP-delegated unstake flows we need the rollup version of the + * rollup that *currently* holds the stake (see `useAttesterViewBestEffort` + * — `effectiveRollup`), NOT the canonical rollup's version. Passing the + * wrong version into `Staker.initiateWithdraw(version, attester)` makes the + * Staker route to the wrong rollup and the call reverts. + * + * Fast path: the indexer's `/api/rollups` list contains the on-chain + * version per rollup (a uint256 string assigned by the Registry), so we + * try a synchronous lookup against the cached list first. Falls back to + * `Rollup.getVersion()` if the address isn't in the cache (e.g., the + * indexer hasn't surfaced it yet, or it's a brand-new rollup the user is + * interacting with before `/api/rollups` refreshes). + * + * Flicker note: when the cache misses on first render, this hook returns + * `{ version: undefined, isLoading: true }` until the RPC settles. If the + * cache hydrates between renders (e.g., `/api/rollups` arrives), the + * `useReadContract` enabled flag flips to `false` and we return the + * cached version instead — callers see a brief `undefined → bigint` + * transition. Downstream `useEffect`s that act on `version` should be + * keyed on it so they re-run once it materialises; we never return a + * stale/wrong version, only "not yet known". + */ +export function useRollupVersionFor(rollupAddress: Address | undefined): { + version: bigint | undefined + isLoading: boolean +} { + const cachedVersion = useMemo(() => { + if (!rollupAddress) return undefined + const target = rollupAddress.toLowerCase() + const hit = getRollupVersions().find((v) => v.address.toLowerCase() === target) + if (!hit) return undefined + try { + return BigInt(hit.version) + } catch { + // The schema in `contracts/index.ts` validates version-as-string at + // ingest, so BigInt parse failure here would be a schema regression. + // Defensive: fall through to on-chain read rather than blow up. + return undefined + } + }, [rollupAddress]) + + // Only fire the on-chain read when the cache miss matters: caller has a + // rollup address but we couldn't resolve a version locally. + const { data, isLoading } = useReadContract({ + address: rollupAddress, + abi: contracts.rollup.abi, + functionName: "getVersion", + query: { + enabled: !!rollupAddress && cachedVersion === undefined, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + return { + version: cachedVersion ?? (data as bigint | undefined), + isLoading: cachedVersion === undefined && isLoading, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts index 48127fe0f..4721c2d0a 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts @@ -1,6 +1,6 @@ import type { Address } from "viem"; import { useBlock } from "wagmi"; -import { useAttesterViewBestEffort } from "./useAttesterViewBestEffort"; +import { useAttesterViewBestEffort, type AttesterViewHint } from "./useAttesterViewBestEffort"; import { useGovernanceWithdrawal } from "../governance/useGovernanceWithdrawal"; import { SequencerStatus, getStatusLabel } from "./sequencerStatus"; @@ -16,13 +16,19 @@ export { SequencerStatus, getStatusLabel }; * @param sequencerAddress - The address of the sequencer * @param rollupAddress - The delegation's original rollup. May be undefined * while the caller's data is still loading. + * @param hint - Optional indexer-supplied hint. When the indexer has + * `effectiveRollup` + `moveWithRollup` for this stake (the + * fast path), pass them through and the underlying probe will + * use them to short-circuit. Probe still runs as a safety + * net — chain is always authoritative. */ export function useSequencerStatus( sequencerAddress: Address | undefined, rollupAddress: Address | undefined, + hint?: AttesterViewHint | null, ) { - const { status, effectiveBalance, exit, isLoading, error, refetch } = - useAttesterViewBestEffort(sequencerAddress, rollupAddress); + const { status, effectiveBalance, exit, effectiveRollup, isLoading, error, refetch } = + useAttesterViewBestEffort(sequencerAddress, rollupAddress, hint); // Query the governance withdrawal to get the REAL unlock time const { withdrawal, isLoading: isLoadingWithdrawal } = useGovernanceWithdrawal(exit?.withdrawalId); @@ -67,6 +73,11 @@ export function useSequencerStatus( isZombie, isExiting, canFinalize, + /** The rollup the live attester record was found on. Send any + * unstake / withdraw write to this address, NOT to the + * indexer-supplied `rollupAddress` (which reflects the deposit + * event's rollup and may diverge once the stake migrates). */ + effectiveRollup, isLoading: isLoading || isLoadingWithdrawal, error, refetch, diff --git a/staking-dashboard/src/utils/pendingDirectStakes.ts b/staking-dashboard/src/utils/pendingDirectStakes.ts index 1fb8fdbc3..b71a73b5b 100644 --- a/staking-dashboard/src/utils/pendingDirectStakes.ts +++ b/staking-dashboard/src/utils/pendingDirectStakes.ts @@ -41,6 +41,13 @@ export interface PendingDirectStake { txHash: string timestamp: number createdAt: number // When the entry was added (for cleanup) + /** + * Whether the deposit was submitted with `moveWithRollup = true`. Optional + * for backward compatibility with entries written before this field + * existed; the aggregator defaults missing values to `true` because every + * pre-existing dashboard deposit used that path. + */ + moveWithRollup?: boolean } /** From 5e8942f3eb1e3b0f9f1aa64aa1914ec2bf1047a6 Mon Sep 17 00:00:00 2001 From: Koen Date: Fri, 15 May 2026 17:34:43 +0400 Subject: [PATCH 2/2] fix: FinalizeWithdrawStaker deprecation --- .../ATPDetailsDelegationItem.tsx | 1 + .../ATPDetailsDirectStakeItem.tsx | 1 + .../ATPDetailsModal/WithdrawalActions.tsx | 76 ++++++++++--------- .../contexts/TransactionCartContextType.ts | 16 +++- staking-dashboard/src/utils/unstakeCart.ts | 46 +++-------- 5 files changed, 68 insertions(+), 72 deletions(-) diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx index 01e3a4bdf..3276b1e5e 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDelegationItem.tsx @@ -472,6 +472,7 @@ export const ATPDetailsDelegationItem = ({ {isFinalizeQueued ? ( diff --git a/staking-dashboard/src/contexts/TransactionCartContextType.ts b/staking-dashboard/src/contexts/TransactionCartContextType.ts index 18ced970c..5490520e8 100644 --- a/staking-dashboard/src/contexts/TransactionCartContextType.ts +++ b/staking-dashboard/src/contexts/TransactionCartContextType.ts @@ -47,9 +47,21 @@ export enum UnstakeStepType { InitiateWithdrawGovernance = "unstake:initiate-governance", /** Governance.initiateWithdraw(to, amount) — direct-deposit ERC20 holders. */ InitiateWithdrawGovernanceWallet = "unstake:initiate-governance-wallet", - /** Rollup.finalizeWithdraw(attester) — wallet ERC20 direct-staker path. */ + /** + * Rollup.finalizeWithdraw(attester) — used by BOTH the wallet ERC20 + * direct-staker path AND the ATP staker path. ATP finalize sidesteps + * the Staker because `Staker.finalizeWithdraw` internally calls + * `Rollup.finaliseWithdraw` (British spelling, doesn't exist) and + * reverts. See `useFinalizeWithdraw.ts` for the original note. + */ FinalizeWithdrawRollup = "unstake:finalize-rollup", - /** Staker.finalizeWithdraw(version, attester) — ATP staker path. */ + /** + * @deprecated Do NOT use — the Staker's finalize forwarder reverts + * due to a British-vs-American spelling mismatch. Kept here only so + * any localStorage cart entries persisted under this step type from + * an older build still deserialize cleanly. All new finalize entries + * should use {@link FinalizeWithdrawRollup}. + */ FinalizeWithdrawStaker = "unstake:finalize-staker", /** Governance.finalizeWithdraw(withdrawalId) — governance path (different contract). */ FinalizeWithdrawGovernance = "unstake:finalize-governance", diff --git a/staking-dashboard/src/utils/unstakeCart.ts b/staking-dashboard/src/utils/unstakeCart.ts index 8335b9702..387ecbf9a 100644 --- a/staking-dashboard/src/utils/unstakeCart.ts +++ b/staking-dashboard/src/utils/unstakeCart.ts @@ -87,22 +87,12 @@ export function buildStakerInitiateWithdrawTx( } } -/** `Staker.finalizeWithdraw(version, attester)` raw tx. */ -export function buildStakerFinalizeWithdrawTx( - stakerAddress: Address, - version: bigint, - attester: Address, -): RawTransaction { - return { - to: stakerAddress, - data: encodeFunctionData({ - abi: ATPWithdrawableStakerAbi, - functionName: "finalizeWithdraw", - args: [version, attester], - }), - value: 0n, - } -} +// NOTE: there is intentionally no `buildStakerFinalizeWithdrawTx`. The +// Staker's `finalizeWithdraw` forwarder internally calls +// `Rollup.finaliseWithdraw` (British spelling) which doesn't exist on +// Rollup, and the tx reverts. Finalize must go direct to the rollup +// via `buildRollupFinalizeWithdrawTx`. See the long-standing comment +// in `useFinalizeWithdraw.ts` for the original incident note. // ───────────────────────────────────────────────────────────────────────────── // Governance path (initiate is on the Staker contract, finalize is on Governance) @@ -243,24 +233,12 @@ export function buildStakerInitiateWithdrawEntry(inputs: StakerUnstakeInputs): U } } -export function buildStakerFinalizeWithdrawEntry(inputs: StakerUnstakeInputs): UnstakeCartEntry { - const { stakerAddress, version, attester, amount, providerName } = inputs - return { - type: "unstake", - label: "Finalize unstake", - description: providerName ? `From ${providerName}` : undefined, - transaction: buildStakerFinalizeWithdrawTx(stakerAddress, version, attester), - metadata: { - stepType: UnstakeStepType.FinalizeWithdrawStaker, - stepGroupIdentifier: positionGroup(attester, stakerAddress), - attesterAddress: attester, - stakerAddress, - version, - amount, - providerName, - }, - } -} +// NOTE: there is intentionally no `buildStakerFinalizeWithdrawEntry`. +// See the matching note above `buildStakerInitiateWithdrawTx`/around +// where the Staker variant would live: finalize must go direct to the +// rollup. The Staker-forwarded path reverts due to a long-standing +// British-vs-American spelling mismatch in the Staker's `finalizeWithdraw` +// implementation. interface GovernanceInitiateInputs { stakerAddress: Address