From a7d398a293380843851327123850c5b5017721d9 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 09:09:07 +0200 Subject: [PATCH 01/18] feat: add rollup registry discovery via on-chain IRegistry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Discover all rollup versions at runtime from the Aztec governance Registry contract instead of hardcoding a single rollup via VITE_ROLLUP_ADDRESS. - Add RollupRegistry ABI (IRegistry: numberOfVersions, getVersion, getRollup, getCanonicalRollup) - Add useRollupRegistry hook that chains StakingRegistry.ROLLUP_REGISTRY() → Registry enumeration → builds rollup list with canonical detection - Export RollupInstance type and useRollupRegistry from rollup hooks barrel - Register rollupRegistry ABI in contracts/index.ts (address discovered at runtime, not configured statically) --- .../src/contracts/abis/RollupRegistry.ts | 127 +++++++++++++ staking-dashboard/src/contracts/index.ts | 6 + staking-dashboard/src/hooks/rollup/index.ts | 5 + .../src/hooks/rollup/useRollupRegistry.ts | 177 ++++++++++++++++++ 4 files changed, 315 insertions(+) create mode 100644 staking-dashboard/src/contracts/abis/RollupRegistry.ts create mode 100644 staking-dashboard/src/hooks/rollup/useRollupRegistry.ts diff --git a/staking-dashboard/src/contracts/abis/RollupRegistry.ts b/staking-dashboard/src/contracts/abis/RollupRegistry.ts new file mode 100644 index 000000000..9cb7e82f9 --- /dev/null +++ b/staking-dashboard/src/contracts/abis/RollupRegistry.ts @@ -0,0 +1,127 @@ +// Aztec governance Registry (IRegistry). +// Source: aztec-packages next branch @ commit 9f7257e619 — l1-contracts/src/governance/interfaces/IRegistry.sol +// Discovered at runtime via stakingRegistry.ROLLUP_REGISTRY(); no env var required. +export const RollupRegistryAbi = [ + { + "type": "function", + "name": "getCanonicalRollup", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IHaveVersion" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRollup", + "inputs": [ + { + "name": "_chainId", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IHaveVersion" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "numberOfVersions", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getVersion", + "inputs": [ + { + "name": "_index", + "type": "uint256", + "internalType": "uint256" + } + ], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getGovernance", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "address" + } + ], + "stateMutability": "view" + }, + { + "type": "function", + "name": "getRewardDistributor", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IRewardDistributor" + } + ], + "stateMutability": "view" + }, + { + "type": "event", + "name": "CanonicalRollupUpdated", + "inputs": [ + { + "name": "instance", + "type": "address", + "indexed": true, + "internalType": "address" + }, + { + "name": "version", + "type": "uint256", + "indexed": true, + "internalType": "uint256" + } + ], + "anonymous": false + }, + { + "type": "event", + "name": "RewardDistributorUpdated", + "inputs": [ + { + "name": "rewardDistributor", + "type": "address", + "indexed": true, + "internalType": "address" + } + ], + "anonymous": false + } +] as const diff --git a/staking-dashboard/src/contracts/index.ts b/staking-dashboard/src/contracts/index.ts index 5dac2bd64..812a1c7e4 100644 --- a/staking-dashboard/src/contracts/index.ts +++ b/staking-dashboard/src/contracts/index.ts @@ -9,6 +9,7 @@ import { GenesisSequencerSale } from "./abis/GenesisSequencerSale"; import { ATPWithdrawableAndClaimableStakerAbi } from "./abis/ATPWithdrawableAndClaimableStaker"; import { GovernanceAbi } from "./abis/Governance"; import { GSEAbi } from "./abis/GSE"; +import { RollupRegistryAbi } from "./abis/RollupRegistry"; // Define a reusable schema for Ethereum addresses const addressSchema = z @@ -75,6 +76,11 @@ const contracts = { address: env.VITE_GSE_ADDRESS, abi: GSEAbi, }, + // The rollup registry's address is not configured statically — it is discovered at runtime via + // stakingRegistry.ROLLUP_REGISTRY(). Only the ABI is exported here so callers can pass it to wagmi. + rollupRegistry: { + abi: RollupRegistryAbi, + }, } as const; export { contracts }; diff --git a/staking-dashboard/src/hooks/rollup/index.ts b/staking-dashboard/src/hooks/rollup/index.ts index d35471714..449c9f638 100644 --- a/staking-dashboard/src/hooks/rollup/index.ts +++ b/staking-dashboard/src/hooks/rollup/index.ts @@ -3,6 +3,7 @@ export { useActivationThresholdFormatted } from "./useActivationThresholdFormatt export { useSequencerRewards } from "./useSequencerRewards"; export { useClaimSequencerRewards } from "./useClaimSequencerRewards"; export { useIsRewardsClaimable } from "./useIsRewardsClaimable"; +export { useIsRewardsClaimableAcrossRollups } from "./useIsRewardsClaimableAcrossRollups"; export { useEjectionThreshold } from "./useEjectionThreshold"; export { useStakeHealth } from "./useStakeHealth"; export type { StakeHealth } from "./useStakeHealth"; @@ -10,3 +11,7 @@ export { useFinalizeWithdraw } from "./useFinalizeWithdraw"; export { useWalletInitiateWithdraw } from "./useWalletInitiateWithdraw"; export { useWalletDirectStake } from "./useWalletDirectStake"; export { useSequencerStatus, SequencerStatus, getStatusLabel } from "./useSequencerStatus"; +export { useRollupRegistry } from "./useRollupRegistry"; +export type { RollupInstance } from "./useRollupRegistry"; +export { useAttesterStakeLocation } from "./useAttesterStakeLocation"; +export type { AttesterStakeLocation } from "./useAttesterStakeLocation"; diff --git a/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts b/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts new file mode 100644 index 000000000..2e76fdf03 --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts @@ -0,0 +1,177 @@ +import { useMemo } from "react" +import { useReadContract, useReadContracts } from "wagmi" +import { contracts } from "@/contracts" +import type { Address } from "viem" + +/** + * One discovered rollup instance from the Aztec governance Registry. + */ +export interface RollupInstance { + version: bigint + address: Address +} + +/** + * Discovers all rollup instances from the Aztec governance Registry contract. + * + * The Registry address is read lazily from `stakingRegistry.ROLLUP_REGISTRY()` so no env var + * is required. The hook then enumerates `numberOfVersions()` and multicalls + * `getVersion(i)` + `getRollup(version)` to build the full list. The canonical rollup is read + * via `getCanonicalRollup()`. + * + * `isStale` is true when the configured `VITE_ROLLUP_ADDRESS` no longer matches the canonical + * rollup — this is what surfaces the "your dashboard is pinned to an old rollup" banner. + * + * Cached forever (`staleTime: Infinity`); the list only changes when governance rotates a rollup, + * which is extremely rare. + */ +export function useRollupRegistry() { + // Step 1: discover the rollup registry address via the staking registry. + const registryAddressQuery = useReadContract({ + abi: contracts.stakingRegistry.abi, + address: contracts.stakingRegistry.address, + functionName: "ROLLUP_REGISTRY", + query: { + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const registryAddress = registryAddressQuery.data as Address | undefined + + // Step 2: enumerate counts + canonical from the registry. + const headerQuery = useReadContracts({ + contracts: registryAddress + ? [ + { + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "numberOfVersions", + } as const, + { + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "getCanonicalRollup", + } as const, + ] + : undefined, + query: { + enabled: !!registryAddress, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const numberOfVersions = headerQuery.data?.[0].result as bigint | undefined + const canonicalAddress = headerQuery.data?.[1].result as Address | undefined + + // Step 3: enumerate version numbers via getVersion(i). + const versionIndexes = useMemo(() => { + if (!numberOfVersions) return [] + const out: bigint[] = [] + for (let i = 0n; i < numberOfVersions; i++) out.push(i) + return out + }, [numberOfVersions]) + + const versionsQuery = useReadContracts({ + contracts: + registryAddress && versionIndexes.length > 0 + ? versionIndexes.map( + (i) => + ({ + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "getVersion", + args: [i], + }) as const, + ) + : undefined, + query: { + enabled: !!registryAddress && versionIndexes.length > 0, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + // Step 4: resolve each version → rollup address via getRollup(version). + const versions = useMemo(() => { + if (!versionsQuery.data) return [] as bigint[] + return versionsQuery.data + .map((entry) => entry.result as bigint | undefined) + .filter((v): v is bigint => v !== undefined) + }, [versionsQuery.data]) + + const rollupsQuery = useReadContracts({ + contracts: + registryAddress && versions.length > 0 + ? versions.map( + (version) => + ({ + abi: contracts.rollupRegistry.abi, + address: registryAddress, + functionName: "getRollup", + args: [version], + }) as const, + ) + : undefined, + query: { + enabled: !!registryAddress && versions.length > 0, + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + // Combine versions + addresses into the discovered list. + const rollups = useMemo(() => { + if (!rollupsQuery.data || versions.length === 0) return [] + const out: RollupInstance[] = [] + for (let i = 0; i < versions.length; i++) { + const address = rollupsQuery.data[i]?.result as Address | undefined + if (!address) continue + out.push({ version: versions[i], address }) + } + return out + }, [rollupsQuery.data, versions]) + + const canonical = useMemo(() => { + if (!canonicalAddress) return undefined + // The canonical address is authoritative; pair it with the matching version from the + // enumerated list (or the last version, which mirrors the registry's storage layout). + const match = rollups.find((r) => r.address.toLowerCase() === canonicalAddress.toLowerCase()) + if (match) return match + return rollups.length > 0 ? rollups[rollups.length - 1] : undefined + }, [canonicalAddress, rollups]) + + const configuredAddress = contracts.rollup.address + const configured = useMemo(() => { + return rollups.find((r) => r.address.toLowerCase() === configuredAddress.toLowerCase()) + }, [rollups, configuredAddress]) + + const isStale = + !!canonical && + canonical.address.toLowerCase() !== configuredAddress.toLowerCase() + + const isLoading = + registryAddressQuery.isLoading || + headerQuery.isLoading || + versionsQuery.isLoading || + rollupsQuery.isLoading + + const error = + registryAddressQuery.error || + headerQuery.error || + versionsQuery.error || + rollupsQuery.error || + null + + return { + registryAddress, + rollups, + canonical, + configured, + configuredAddress, + isStale, + isLoading, + error, + } +} From ed60fd6c7cb49df92ba6168252f3a37a4a94f2dd Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 09:09:17 +0200 Subject: [PATCH 02/18] refactor: parameterize rollup hooks with optional rollupAddress Each rollup hook now accepts an optional rollupAddress parameter that defaults to contracts.rollup.address. This enables callers to target a specific rollup (e.g. an old rollup for stranded rewards/stakes) without changing the default behavior for existing consumers. Parameterized hooks: useSequencerRewards, useClaimSequencerRewards, useIsRewardsClaimable, useRollupData, useActivationThresholdFormatted, useEjectionThreshold, useAttesterView, useStakeHealth, useSequencerStatus, useWalletDirectStake, useWalletInitiateWithdraw, useFinalizeWithdraw, useApproveRollup. --- .../src/hooks/erc20/useApproveRollup.ts | 18 ++++++++++++------ .../rollup/useActivationThresholdFormatted.ts | 11 +++++++---- .../src/hooks/rollup/useAttesterView.ts | 13 ++++++++++--- .../hooks/rollup/useClaimSequencerRewards.ts | 13 +++++++++---- .../src/hooks/rollup/useEjectionThreshold.ts | 15 ++++++++++----- .../src/hooks/rollup/useFinalizeWithdraw.ts | 12 ++++++++---- .../src/hooks/rollup/useIsRewardsClaimable.ts | 12 +++++++++--- .../src/hooks/rollup/useRollupData.ts | 15 +++++++++++---- .../src/hooks/rollup/useSequencerRewards.ts | 12 +++++++++--- .../src/hooks/rollup/useSequencerStatus.ts | 9 ++++++--- .../src/hooks/rollup/useStakeHealth.ts | 8 ++++---- .../src/hooks/rollup/useWalletDirectStake.ts | 17 +++++++++++------ .../hooks/rollup/useWalletInitiateWithdraw.ts | 14 ++++++++++---- 13 files changed, 116 insertions(+), 53 deletions(-) diff --git a/staking-dashboard/src/hooks/erc20/useApproveRollup.ts b/staking-dashboard/src/hooks/erc20/useApproveRollup.ts index bd90f8161..6ca60df6c 100644 --- a/staking-dashboard/src/hooks/erc20/useApproveRollup.ts +++ b/staking-dashboard/src/hooks/erc20/useApproveRollup.ts @@ -5,11 +5,17 @@ import { contracts } from "@/contracts" import type { RawTransaction } from "@/contexts/TransactionCartContext" /** - * Hook for approving ERC20 tokens for the Rollup contract to spend - * Used for wallet-based direct staking (own validator registration) - * @param tokenAddress - The ERC20 token contract address + * Hook for approving ERC20 tokens for a Rollup contract to spend. + * Used for wallet-based direct staking (own validator registration). + * + * @param tokenAddress - The ERC20 token contract address + * @param rollupAddress - Optional rollup contract that will be the approval spender. Defaults + * to the configured rollup. Registration flows should pass the + * canonical rollup so the approval matches whichever rollup the + * deposit lands on. */ -export function useApproveRollup(tokenAddress?: Address) { +export function useApproveRollup(tokenAddress?: Address, rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -29,7 +35,7 @@ export function useApproveRollup(tokenAddress?: Address) { abi: ERC20Abi, address: tokenAddress, functionName: "approve", - args: [contracts.rollup.address, amount], + args: [targetRollup, amount], }) }, @@ -46,7 +52,7 @@ export function useApproveRollup(tokenAddress?: Address) { data: encodeFunctionData({ abi: ERC20Abi, functionName: "approve", - args: [contracts.rollup.address, amount], + args: [targetRollup, amount], }), value: 0n, } diff --git a/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts b/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts index cbbcf9e11..c0bd81b61 100644 --- a/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts +++ b/staking-dashboard/src/hooks/rollup/useActivationThresholdFormatted.ts @@ -1,16 +1,19 @@ import { useReadContract } from "wagmi" +import type { Address } from "viem" import { contracts } from "../../contracts" import { useStakingAssetTokenDetails } from "../stakingRegistry/useStakingAssetTokenDetails" import { formatTokenAmount } from "@/utils/atpFormatters" /** - * Hook to get formatted activation threshold with token details - * Fetches activation threshold directly from rollup contract and formats with staking asset token details + * Hook to get formatted activation threshold with token details. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -export function useActivationThresholdFormatted() { +export function useActivationThresholdFormatted(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data: activationThreshold, isLoading: isLoadingThreshold, error: thresholdError } = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getActivationThreshold", query: { staleTime: Infinity, diff --git a/staking-dashboard/src/hooks/rollup/useAttesterView.ts b/staking-dashboard/src/hooks/rollup/useAttesterView.ts index edd441645..a68631697 100644 --- a/staking-dashboard/src/hooks/rollup/useAttesterView.ts +++ b/staking-dashboard/src/hooks/rollup/useAttesterView.ts @@ -3,11 +3,18 @@ import type { Address } from "viem" import { contracts } from "@/contracts" /** - * Hook to get comprehensive attester/sequencer information including status, balance, and exit details + * Hook to get comprehensive attester/sequencer information including status, balance, and exit details. + * + * @param attesterAddress - The attester address to query + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. + * An attester active on rollup v3 but not v4 will appear with status NONE + * on the canonical rollup — pass the specific rollup address to detect + * stranded stakes on older rollups. */ -export function useAttesterView(attesterAddress: Address | undefined) { +export function useAttesterView(attesterAddress: Address | undefined, rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data, isLoading, error, refetch } = useReadContract({ - address: contracts.rollup.address, + address: targetRollup, abi: contracts.rollup.abi, functionName: "getAttesterView", args: attesterAddress ? [attesterAddress] : undefined, diff --git a/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts index 3698a4fe9..d30308f6e 100644 --- a/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts +++ b/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts @@ -3,9 +3,14 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to claim sequencer rewards to a specified coinbase address + * Hook to claim sequencer rewards to a specified coinbase address. + * + * @param rollupAddress - Optional rollup contract to claim from. Defaults to the configured + * rollup. Callers that need to claim across multiple rollups should pass + * the specific rollup address each call lives on. */ -export function useClaimSequencerRewards() { +export function useClaimSequencerRewards(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -13,10 +18,10 @@ export function useClaimSequencerRewards() { }) return { - claimRewards: (coinbaseAddress: Address) => { + claimRewards: (coinbaseAddress: Address, overrideRollup?: Address) => { return write.writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: overrideRollup ?? targetRollup, functionName: "claimSequencerRewards", args: [coinbaseAddress] }) diff --git a/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts b/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts index 1cb44c253..f21710644 100644 --- a/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts +++ b/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts @@ -1,15 +1,20 @@ import { useReadContract } from "wagmi" +import type { Address } from "viem" import { contracts } from "../../contracts" /** - * Hook to get the local ejection threshold from the rollup contract - * This is the minimum effective balance required to stay as a validator - * Below this threshold, validators are ejected from the active set + * Hook to get the local ejection threshold from a rollup contract. + * This is the minimum effective balance required to stay as a validator; + * below this threshold, validators are ejected from the active set. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. + * Each rollup version may have its own ejection threshold. */ -export function useEjectionThreshold() { +export function useEjectionThreshold(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data, isLoading, error, refetch } = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getLocalEjectionThreshold", query: { staleTime: Infinity, diff --git a/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts b/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts index cf07c27d3..80143b9c7 100644 --- a/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts +++ b/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts @@ -3,15 +3,19 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to finalize withdrawal from the rollup + * Hook to finalize withdrawal from a rollup. * * This hook calls the Rollup contract directly instead of going through the staker contract. * The staker contract has a bug where it calls `finaliseWithdraw` (British spelling) but * the actual Rollup contract uses `finalizeWithdraw` (American spelling). * + * @param rollupAddress - Optional rollup contract to finalize on. Defaults to the configured + * rollup. For stranded stakes, pass the specific rollup the exit lives on. + * * @returns Hook with finalizeWithdraw function and transaction status */ -export function useFinalizeWithdraw() { +export function useFinalizeWithdraw(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -19,10 +23,10 @@ export function useFinalizeWithdraw() { }) return { - finalizeWithdraw: (attesterAddress: Address) => { + finalizeWithdraw: (attesterAddress: Address, overrideRollup?: Address) => { return write.writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: overrideRollup ?? targetRollup, functionName: "finalizeWithdraw", args: [attesterAddress] }) diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts index b166f90a2..d79e91472 100644 --- a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts @@ -1,13 +1,19 @@ import { useReadContract } from "wagmi" import { contracts } from "@/contracts" +import type { Address } from "viem" /** - * Hook to check if rewards are claimable from the rollup contract + * Hook to check if rewards are claimable from a specific rollup contract. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. + * Each rollup has its own `isRewardsClaimable` flag, so callers iterating + * across multiple rollups must check each one independently. */ -export function useIsRewardsClaimable() { +export function useIsRewardsClaimable(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const query = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "isRewardsClaimable" }) diff --git a/staking-dashboard/src/hooks/rollup/useRollupData.ts b/staking-dashboard/src/hooks/rollup/useRollupData.ts index 187be78f4..0679ae6a3 100644 --- a/staking-dashboard/src/hooks/rollup/useRollupData.ts +++ b/staking-dashboard/src/hooks/rollup/useRollupData.ts @@ -1,13 +1,20 @@ import { useReadContract } from "wagmi"; +import type { Address } from "viem"; import { contracts } from "../../contracts"; /** - * Hook to get rollup data including version and activation threshold + * Hook to get rollup data including version and activation threshold. + * + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. + * Pass an explicit address to read version/threshold from a non-canonical + * rollup (e.g. for stranded-stake withdrawal flows). */ -export function useRollupData() { +export function useRollupData(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address; + const rollupVersionQuery = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getVersion", query: { staleTime: Infinity, @@ -17,7 +24,7 @@ export function useRollupData() { const activationThresholdQuery = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getActivationThreshold", query: { staleTime: Infinity, diff --git a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts index b78488a57..f54ad66fb 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts @@ -3,12 +3,18 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to get sequencer rewards for a specific coinbase address + * Hook to get sequencer rewards for a specific coinbase address. + * + * @param coinbaseAddress - The coinbase address to query rewards for + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup + * (contracts.rollup.address) for backwards compatibility. Pass an explicit + * rollup when querying historical/non-canonical rollups. */ -export function useSequencerRewards(coinbaseAddress: string) { +export function useSequencerRewards(coinbaseAddress: string, rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const query = useReadContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "getSequencerRewards", args: coinbaseAddress ? [coinbaseAddress as Address] : undefined, query: { diff --git a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts index c4559935b..a5e27492f 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts @@ -38,13 +38,16 @@ export function getStatusLabel(status: number | undefined): string { } /** - * Hook to get sequencer status information + * Hook to get sequencer status information. * @param sequencerAddress - The address of the sequencer + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. + * Pass an explicit address to inspect status on a non-canonical rollup + * (e.g. for stranded-stake withdrawal flows). * @returns Sequencer status, label, and related information */ -export function useSequencerStatus(sequencerAddress: Address | undefined) { +export function useSequencerStatus(sequencerAddress: Address | undefined, rollupAddress?: Address) { const { status, effectiveBalance, exit, isLoading, error, refetch } = - useAttesterView(sequencerAddress); + useAttesterView(sequencerAddress, rollupAddress); // Query the governance withdrawal to get the REAL unlock time const { withdrawal, isLoading: isLoadingWithdrawal } = useGovernanceWithdrawal(exit?.withdrawalId); diff --git a/staking-dashboard/src/hooks/rollup/useStakeHealth.ts b/staking-dashboard/src/hooks/rollup/useStakeHealth.ts index f6ffccdae..f35423cf5 100644 --- a/staking-dashboard/src/hooks/rollup/useStakeHealth.ts +++ b/staking-dashboard/src/hooks/rollup/useStakeHealth.ts @@ -28,15 +28,15 @@ const SLASH_AMOUNT = 2000n * 10n ** 18n * - isAtRisk: healthPercentage < 50 (has been slashed significantly) * - isCritical: effectiveBalance <= ejectionThreshold (imminent ejection) */ -export function useStakeHealth(attesterAddress: Address | undefined) { +export function useStakeHealth(attesterAddress: Address | undefined, rollupAddress?: Address) { const { effectiveBalance, status, isLoading: isLoadingAttester, error: attesterError, refetch: refetchAttester } = - useAttesterView(attesterAddress) + useAttesterView(attesterAddress, rollupAddress) const { ejectionThreshold, isLoading: isLoadingEjection, error: ejectionError, refetch: refetchEjection } = - useEjectionThreshold() + useEjectionThreshold(rollupAddress) const { activationThreshold, isLoading: isLoadingActivation, error: activationError } = - useActivationThresholdFormatted() + useActivationThresholdFormatted(rollupAddress) const isLoading = isLoadingAttester || isLoadingEjection || isLoadingActivation const error = attesterError || ejectionError || activationError diff --git a/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts b/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts index 138acc803..09c55f8c0 100644 --- a/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts +++ b/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts @@ -5,11 +5,16 @@ import type { RawTransaction } from "@/contexts/TransactionCartContext" import type { G1Point, G2Point } from "@/hooks/staker/types" /** - * Hook for direct ERC20 staking via Rollup.deposit() - * This is for wallet-based direct staking (own validator registration) - * User calls Rollup.deposit() directly with their BLS keys + * Hook for direct ERC20 staking via Rollup.deposit(). + * This is for wallet-based direct staking (own validator registration). + * User calls Rollup.deposit() directly with their BLS keys. + * + * @param rollupAddress - Optional rollup contract to deposit into. Defaults to the configured + * rollup. Registration flows should pass the *canonical* rollup so the + * deposit lands on the active rollup, not on a pinned/stale one. */ -export function useWalletDirectStake() { +export function useWalletDirectStake(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const write = useWriteContract() const receipt = useWaitForTransactionReceipt({ @@ -36,7 +41,7 @@ export function useWalletDirectStake() { ) => write.writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: targetRollup, functionName: "deposit", args: [ attester, @@ -70,7 +75,7 @@ export function useWalletDirectStake() { signature: G1Point, moveWithRollup: boolean, ): RawTransaction => ({ - to: contracts.rollup.address, + to: targetRollup, data: encodeFunctionData({ abi: contracts.rollup.abi, functionName: "deposit", diff --git a/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts b/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts index 2708f651f..694c381bb 100644 --- a/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts +++ b/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts @@ -3,25 +3,31 @@ import { contracts } from "@/contracts" import type { Address } from "viem" /** - * Hook to initiate withdrawal from the rollup for wallet (ERC20) stakes + * Hook to initiate withdrawal from a rollup for wallet (ERC20) stakes. * * For direct ERC20 staking, the user is the withdrawer and calls initiateWithdraw * directly on the Rollup contract. This is different from ATP staking where * withdrawals are initiated through the staker contract. * + * @param rollupAddress - Optional rollup contract to withdraw from. Defaults to the configured + * rollup. For stranded stakes (originally deposited with + * `_moveWithRollup=false`) callers must pass the rollup the stake actually + * lives on, not the canonical rollup. + * * @returns Hook with initiateWithdraw function and transaction status */ -export function useWalletInitiateWithdraw() { +export function useWalletInitiateWithdraw(rollupAddress?: Address) { + const targetRollup = rollupAddress ?? contracts.rollup.address const { data: hash, writeContract, isPending, error, reset } = useWriteContract() const { isLoading: isConfirming, isSuccess, isError: receiptError } = useWaitForTransactionReceipt({ hash, }) - const initiateWithdraw = (attesterAddress: Address, recipientAddress: Address) => { + const initiateWithdraw = (attesterAddress: Address, recipientAddress: Address, overrideRollup?: Address) => { return writeContract({ abi: contracts.rollup.abi, - address: contracts.rollup.address, + address: overrideRollup ?? targetRollup, functionName: "initiateWithdraw", args: [attesterAddress, recipientAddress], }) From 9cc2e7302631abe727eddc90d14ae1be59e20935 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 09:09:29 +0200 Subject: [PATCH 03/18] feat: multi-rollup rewards view and claim Fan out reward reads across all discovered rollups so sequencers can see and claim rewards stranded on older rollups. - Add useCoinbaseRewardsAcrossRollups: multicalls getSequencerRewards across every (rollup x coinbase) pair, returns per-rollup breakdown - Rewrite useMultipleCoinbaseRewards as thin wrapper over the new fan-out hook - Add rollupAddress/rollupVersion to CoinbaseBreakdown and ClaimTask types - Thread rollupAddress through useClaimAllRewards engine (per-task targeting) - Thread rollupAddress through useClaimCoinbaseRewards - Add useIsRewardsClaimableAcrossRollups: multicall isRewardsClaimable() per rollup for per-row claim button gating - Add useAttesterStakeLocation: scans all rollups via getAttesterView to find where a stranded stake lives --- .../src/hooks/rewards/rewardsTypes.ts | 15 ++- .../src/hooks/rewards/useClaimAllRewards.ts | 31 +++++- .../hooks/rewards/useClaimCoinbaseRewards.ts | 16 ++- .../useCoinbaseRewardsAcrossRollups.ts | 99 +++++++++++++++++++ .../rewards/useMultipleCoinbaseRewards.ts | 56 ++--------- .../hooks/rollup/useAttesterStakeLocation.ts | 89 +++++++++++++++++ .../useIsRewardsClaimableAcrossRollups.ts | 69 +++++++++++++ 7 files changed, 317 insertions(+), 58 deletions(-) create mode 100644 staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts create mode 100644 staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts create mode 100644 staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts diff --git a/staking-dashboard/src/hooks/rewards/rewardsTypes.ts b/staking-dashboard/src/hooks/rewards/rewardsTypes.ts index 23b0d8588..80e61a312 100644 --- a/staking-dashboard/src/hooks/rewards/rewardsTypes.ts +++ b/staking-dashboard/src/hooks/rewards/rewardsTypes.ts @@ -1,12 +1,25 @@ import type { Address } from 'viem' /** - * Represents a coinbase address saved by the user for tracking self-stake rewards + * Represents the rewards a coinbase address has accumulated on a single rollup. + * + * The dashboard fans out reward queries across all known rollups (canonical + historical), + * so a user with one coinbase address may produce multiple `CoinbaseBreakdown` entries — + * one per rollup that holds a non-zero balance for that coinbase. The `rollupAddress` / + * `rollupVersion` fields are how UIs disambiguate the rows and how the claim engine + * routes each `claimSequencerRewards` call to the correct rollup contract. + * + * For backwards compatibility with single-rollup deployments, callers querying only the + * configured rollup populate `rollupAddress` with `contracts.rollup.address`. */ export interface CoinbaseBreakdown { address: Address rewards: bigint source: 'manual' + /** The rollup contract these rewards live on. */ + rollupAddress: Address + /** Optional rollup version for display (e.g. "v3"). Resolved from the registry when available. */ + rollupVersion?: bigint } /** diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index 4b9c90aa1..42b3ef95e 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -28,6 +28,15 @@ export interface ClaimTask { providerTakeRate?: number // Coinbase-specific data coinbaseAddress?: Address + /** + * The rollup contract this task targets. Coinbase tasks pulled from a multi-rollup + * breakdown carry the rollup the balance lives on so the engine routes the + * `claimSequencerRewards` call to the correct contract. Delegation tasks default to + * the configured rollup (delegation rewards are split-contract scoped, but the + * underlying balance still flows from a specific rollup). + */ + rollupAddress?: Address + rollupVersion?: bigint // Sub-step tracking for delegations currentSubStep?: 'claiming' | 'distributing' | 'withdrawing' } @@ -78,10 +87,14 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { // Get current task's addresses const currentSplitContract = currentTask?.type === 'delegation' ? currentTask.splitContract : undefined const currentCoinbase = currentTask?.type === 'coinbase' ? currentTask.coinbaseAddress : undefined + // Each task carries the rollup the balance lives on. For delegation tasks (which have not + // been multi-rollup-expanded yet) and legacy callers that omit the field, fall back to the + // configured rollup so existing behavior is preserved. + const currentTaskRollup = currentTask?.rollupAddress // Fetch balances for current task (for delegations) - extract refetch functions const { warehouseAddress, isLoading: isLoadingWarehouse } = useSplitsWarehouse(currentSplitContract) - const { rewards: rollupBalance, isLoading: isLoadingRollup, refetch: refetchRollup } = useSequencerRewards(currentSplitContract || currentCoinbase || '') + const { rewards: rollupBalance, isLoading: isLoadingRollup, refetch: refetchRollup } = useSequencerRewards(currentSplitContract || currentCoinbase || '', currentTaskRollup) const { balance: splitContractBalance, isLoading: isLoadingSplitBalance, refetch: refetchSplitContract } = useERC20Balance(tokenAddress, currentSplitContract) const { balance: warehouseBalance, isLoading: isLoadingWarehouseBalance, refetch: refetchWarehouse } = useWarehouseBalance(warehouseAddress, userAddress, tokenAddress) @@ -147,12 +160,18 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { providerTakeRate: delegation.providerTakeRate })), ...coinbases.map((coinbase): ClaimTask => ({ - id: `coinbase-${coinbase.address}`, + // Include the rollup in the task id so the same coinbase address claimed across + // multiple rollups produces distinct tasks instead of collapsing into one. + id: `coinbase-${coinbase.address}-${coinbase.rollupAddress}`, type: 'coinbase', - displayName: `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)}`, + displayName: coinbase.rollupVersion !== undefined + ? `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)} (rollup v${coinbase.rollupVersion})` + : `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)}`, estimatedRewards: coinbase.rewards, status: 'pending', - coinbaseAddress: coinbase.address + coinbaseAddress: coinbase.address, + rollupAddress: coinbase.rollupAddress, + rollupVersion: coinbase.rollupVersion, })) ] @@ -246,7 +265,9 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { if (task.type === 'delegation') { delegationClaimHook.claim() } else if (task.type === 'coinbase' && task.coinbaseAddress) { - coinbaseClaimHook.claimRewards(task.coinbaseAddress) + // Pass the per-task rollup address so the claim lands on the correct rollup contract + // (the hook itself defaults to the configured rollup when no override is given). + coinbaseClaimHook.claimRewards(task.coinbaseAddress, task.rollupAddress) } }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances, delegationClaimHook, coinbaseClaimHook]) diff --git a/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts index 72c9fd6ef..886e6f969 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts @@ -2,17 +2,23 @@ import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerReward import type { Address } from "viem" /** - * Hook to claim rewards for a coinbase address - * This is a wrapper around useClaimSequencerRewards for consistency + * Hook to claim rewards for a coinbase address. + * This is a wrapper around useClaimSequencerRewards for consistency. * * Claim flow for self-stake (coinbase) rewards is 1 step: * 1. Call claimSequencerRewards(coinbaseAddress) - rewards go directly to coinbase + * + * @param rollupAddress - Optional default rollup contract to claim from. Defaults to the + * configured rollup. The returned `claimRewards` also accepts an + * optional per-call rollup override so callers iterating over + * multiple rollups can re-use a single hook instance. */ -export function useClaimCoinbaseRewards() { - const claimSequencerRewards = useClaimSequencerRewards() +export function useClaimCoinbaseRewards(rollupAddress?: Address) { + const claimSequencerRewards = useClaimSequencerRewards(rollupAddress) return { - claimRewards: (coinbaseAddress: Address) => claimSequencerRewards.claimRewards(coinbaseAddress), + claimRewards: (coinbaseAddress: Address, overrideRollup?: Address) => + claimSequencerRewards.claimRewards(coinbaseAddress, overrideRollup), reset: claimSequencerRewards.reset, txHash: claimSequencerRewards.txHash, error: claimSequencerRewards.error, diff --git a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts new file mode 100644 index 000000000..4e4b6fc91 --- /dev/null +++ b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts @@ -0,0 +1,99 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" +import type { CoinbaseBreakdown } from "./rewardsTypes" + +/** + * Fans `getSequencerRewards(coinbase)` out across every rollup discovered via the Aztec + * governance Registry, in a single multicall roundtrip. + * + * This is the multi-rollup-aware reader behind the rewards UI. The Registry enumeration + * happens via {@link useRollupRegistry} (cached forever); only when that returns rollups + * does this hook issue the actual `getSequencerRewards` reads. + * + * Returns one `CoinbaseBreakdown` entry per `(coinbase × rollup)` pair that has a non-zero + * balance, plus the running total. Callers display these as separate rows so operators can + * see exactly which rollup each balance lives on, and the claim engine routes per-row claims + * to the correct rollup contract. + * + * Falls back to the configured rollup only when registry discovery fails (so the dashboard + * keeps working in single-rollup deployments and during the registry-loading window). + */ +export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { + const { rollups, isLoading: isLoadingRegistry, error: registryError } = useRollupRegistry() + + // Fall back to the configured rollup if registry discovery hasn't produced anything yet. + // This keeps single-rollup deployments and the initial-load window working. + const effectiveRollups = useMemo(() => { + if (rollups.length > 0) return rollups + return [{ version: undefined as bigint | undefined, address: contracts.rollup.address }] + }, [rollups]) + + // Build a flat list of (rollup, coinbase) pairs and the matching multicall contracts. + const pairs = useMemo(() => { + const out: Array<{ rollupAddress: Address; rollupVersion: bigint | undefined; coinbase: Address }> = [] + for (const rollup of effectiveRollups) { + for (const coinbase of coinbaseAddresses) { + out.push({ rollupAddress: rollup.address, rollupVersion: rollup.version, coinbase }) + } + } + return out + }, [effectiveRollups, coinbaseAddresses]) + + const { data, isLoading, isError, error, refetch } = useReadContracts({ + contracts: pairs.length > 0 + ? pairs.map( + (p) => + ({ + address: p.rollupAddress, + abi: contracts.rollup.abi, + functionName: "getSequencerRewards", + args: [p.coinbase], + }) as const, + ) + : undefined, + query: { + enabled: pairs.length > 0, + // Auto-refresh every 30 seconds to match the legacy single-rollup hook's cadence. + refetchInterval: 30 * 1000, + }, + }) + + // Expand the results into one CoinbaseBreakdown per (coinbase, rollup) with non-zero balance. + // Zero balances are filtered so the rewards UI doesn't render empty rows for inactive rollups. + const coinbaseBreakdown = useMemo(() => { + if (!data || pairs.length === 0) return [] + const out: CoinbaseBreakdown[] = [] + for (let i = 0; i < pairs.length; i++) { + const pair = pairs[i] + const result = data[i] + const rewards = + result?.status === "success" ? ((result.result as bigint | undefined) ?? 0n) : 0n + if (rewards <= 0n) continue + out.push({ + address: pair.coinbase, + rewards, + source: "manual", + rollupAddress: pair.rollupAddress, + rollupVersion: pair.rollupVersion, + }) + } + return out + }, [data, pairs]) + + const totalCoinbaseRewards = useMemo( + () => coinbaseBreakdown.reduce((total, item) => total + item.rewards, 0n), + [coinbaseBreakdown], + ) + + return { + coinbaseBreakdown, + totalCoinbaseRewards, + isLoading: isLoading || isLoadingRegistry, + isError: !!isError || !!registryError, + error: error ?? registryError, + refetch, + } +} diff --git a/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts b/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts index dce6c4cf8..8f08581f7 100644 --- a/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts @@ -1,53 +1,15 @@ -import { useReadContracts } from "wagmi" -import { contracts } from "@/contracts" import type { Address } from "viem" -import type { CoinbaseBreakdown } from "./rewardsTypes" +import { useCoinbaseRewardsAcrossRollups } from "./useCoinbaseRewardsAcrossRollups" /** - * Hook to fetch rewards for multiple coinbase addresses - * Uses batched contract calls for efficiency + * Hook to fetch rewards for multiple coinbase addresses. + * + * This is a thin compatibility wrapper around {@link useCoinbaseRewardsAcrossRollups} + * which fans the read out across every rollup discovered via the Aztec governance + * Registry. The returned `coinbaseBreakdown` may contain multiple entries for the same + * coinbase address — one per rollup that has a non-zero balance — so the rewards UI + * naturally surfaces stranded balances on older rollups. */ export function useMultipleCoinbaseRewards(coinbaseAddresses: Address[]) { - // Build contract calls for each coinbase address - const contractCalls = coinbaseAddresses.map(address => ({ - address: contracts.rollup.address, - abi: contracts.rollup.abi, - functionName: "getSequencerRewards" as const, - args: [address] - })) - - const { data, isLoading, isError, error, refetch } = useReadContracts({ - contracts: contractCalls, - query: { - enabled: coinbaseAddresses.length > 0, - refetchInterval: 30 * 1000 // Auto-refresh every 30 seconds - } - }) - - // Parse results into CoinbaseBreakdown objects - const coinbaseBreakdown: CoinbaseBreakdown[] = coinbaseAddresses.map((address, index) => { - const result = data?.[index] - const rewards = (result?.status === "success" ? result.result as bigint : 0n) ?? 0n - - return { - address, - rewards, - source: "manual" as const - } - }) - - // Calculate total rewards - const totalCoinbaseRewards = coinbaseBreakdown.reduce( - (total, item) => total + item.rewards, - 0n - ) - - return { - coinbaseBreakdown, - totalCoinbaseRewards, - isLoading, - isError, - error, - refetch - } + return useCoinbaseRewardsAcrossRollups(coinbaseAddresses) } diff --git a/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts b/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts new file mode 100644 index 000000000..8cdc44eef --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts @@ -0,0 +1,89 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" +import { useRollupRegistry } from "./useRollupRegistry" + +/** + * Locates the rollup where a given attester's stake actually lives. + * + * In the multi-rollup world, an attester can be stranded on an older rollup if they originally + * deposited with `_moveWithLatestRollup=false`. In that case, calling `getAttesterView` on the + * canonical rollup returns `Status.NONE`, even though the stake is real and recoverable. + * + * This hook fans `getAttesterView(attester)` across every rollup discovered via the Aztec + * governance Registry, picks the one returning a non-NONE status, and returns that rollup's + * address + version. The withdrawal flow uses this to target the correct rollup contract for + * `initiateWithdraw` / `finalizeWithdraw`. + * + * Tie-break order if the attester appears on multiple rollups (rare): prefer the canonical + * rollup, then the latest non-canonical. + */ + +const STATUS_NONE = 0 + +export interface AttesterStakeLocation { + rollupAddress: Address + rollupVersion: bigint | undefined + /** Status from `getAttesterView` on the resolved rollup. */ + status: number +} + +export function useAttesterStakeLocation(attesterAddress: Address | undefined) { + const { rollups, canonical, isLoading: isLoadingRegistry, error: registryError } = useRollupRegistry() + + // Fall back to the configured rollup when registry discovery hasn't produced anything yet so + // single-rollup deployments and the loading window keep working. + const effectiveRollups = useMemo(() => { + if (rollups.length > 0) return rollups + return [{ version: undefined as bigint | undefined, address: contracts.rollup.address }] + }, [rollups]) + + const { data, isLoading, error } = useReadContracts({ + contracts: attesterAddress + ? effectiveRollups.map( + (r) => + ({ + address: r.address, + abi: contracts.rollup.abi, + functionName: "getAttesterView", + args: [attesterAddress], + }) as const, + ) + : undefined, + query: { + enabled: !!attesterAddress && effectiveRollups.length > 0, + }, + }) + + const location = useMemo(() => { + if (!data || effectiveRollups.length === 0) return undefined + const matches: AttesterStakeLocation[] = [] + for (let i = 0; i < effectiveRollups.length; i++) { + const rollup = effectiveRollups[i] + const result = data[i] + if (result?.status !== "success") continue + const view = result.result as { status: number } | undefined + if (!view || view.status === STATUS_NONE) continue + matches.push({ + rollupAddress: rollup.address, + rollupVersion: rollup.version, + status: view.status, + }) + } + if (matches.length === 0) return undefined + // Prefer the canonical rollup if the attester is active on it. + if (canonical) { + const onCanonical = matches.find((m) => m.rollupAddress.toLowerCase() === canonical.address.toLowerCase()) + if (onCanonical) return onCanonical + } + // Otherwise return the latest non-canonical match (registry order = oldest → newest, so last is latest). + return matches[matches.length - 1] + }, [data, effectiveRollups, canonical]) + + return { + location, + isLoading: isLoading || isLoadingRegistry, + error: error ?? registryError, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts new file mode 100644 index 000000000..cfbb6e79b --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts @@ -0,0 +1,69 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" + +/** + * Reads `isRewardsClaimable()` from a list of rollup contracts in a single multicall. + * + * Each rollup has its own `isRewardsClaimable` storage flag (toggled by the rollup owner), + * so a row showing rewards on rollup v2 may be claimable while a row on v3 is locked, or + * vice versa. This hook returns a Map keyed by the lowercased rollup address so callers can + * cheaply look up the right flag per row. + * + * Returns `undefined` for rollups that haven't loaded yet; consumers should treat + * `undefined` as "still loading" and not as "unclaimable". + */ +export function useIsRewardsClaimableAcrossRollups(rollupAddresses: Address[]) { + const uniqueAddresses = useMemo(() => { + const seen = new Set() + const out: Address[] = [] + for (const a of rollupAddresses) { + const key = a.toLowerCase() + if (seen.has(key)) continue + seen.add(key) + out.push(a) + } + return out + }, [rollupAddresses]) + + const { data, isLoading, error } = useReadContracts({ + contracts: uniqueAddresses.length > 0 + ? uniqueAddresses.map( + (address) => + ({ + address, + abi: contracts.rollup.abi, + functionName: "isRewardsClaimable", + }) as const, + ) + : undefined, + query: { + enabled: uniqueAddresses.length > 0, + }, + }) + + const claimableByRollup = useMemo(() => { + const map = new Map() + if (!data) return map + for (let i = 0; i < uniqueAddresses.length; i++) { + const result = data[i] + if (result?.status === "success") { + map.set(uniqueAddresses[i].toLowerCase(), result.result as boolean) + } + } + return map + }, [data, uniqueAddresses]) + + /** Convenience lookup that returns `undefined` while loading, then `true`/`false`. */ + const isClaimable = (rollupAddress: Address): boolean | undefined => { + return claimableByRollup.get(rollupAddress.toLowerCase()) + } + + return { + claimableByRollup, + isClaimable, + isLoading, + error, + } +} From 3c935793c58c4844e0f3facc22de3586ed1274cf Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 09:09:47 +0200 Subject: [PATCH 04/18] =?UTF-8?q?feat:=20multi-rollup=20UI=20=E2=80=94=20p?= =?UTF-8?q?er-rollup=20rewards,=20canonical=20registration,=20disclaimers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI components updated to surface multi-rollup data: Rewards: - CoinbaseAddressList: per-rollup rows with version badges, per-row claim targeting specific rollup, per-row claimability gating - ClaimSelfStakeRewardsModal: per-rollup balances with individual claim buttons - ClaimAllRewardsSummary: rollup version badges on coinbase rows - ClaimAllRewardsProgress: truncate long error messages to prevent overflow - ATPStakingOverviewClaimableRewards: gate Claim All on T&C acceptance Registration: - RegistrationStake: read activation threshold from canonical rollup - WalletDirectStakingFlow: deposit targets canonical rollup address Withdrawals: - WalletDirectStakeItem: resolve stake location via useAttesterStakeLocation - WalletWithdrawalActions: accept rollupAddress prop for stranded-stake withdrawals Disclaimers: - Add IndexerRollupDisclaimer component (shown when rollups.length > 1) - Add to StakingProvidersPage and StakingProviderDetailPage --- .../ATPStakingOverviewClaimableRewards.tsx | 9 +- .../ClaimAllRewardsProgress.tsx | 6 +- .../ClaimAllRewardsSummary.tsx | 38 +++-- .../ClaimSelfStakeRewardsModal.tsx | 153 ++++++++++-------- .../IndexerRollupDisclaimer.tsx | 26 +++ .../IndexerRollupDisclaimer/index.ts | 1 + .../Registration/RegistrationStake.tsx | 9 +- .../Registration/WalletDirectStakingFlow.tsx | 22 ++- .../RewardsManagement/CoinbaseAddressList.tsx | 56 ++++++- .../WalletDirectStakeItem.tsx | 13 +- .../WalletWithdrawalActions.tsx | 10 +- .../Providers/StakingProviderDetailPage.tsx | 3 + .../pages/Providers/StakingProvidersPage.tsx | 3 + 13 files changed, 245 insertions(+), 104 deletions(-) create mode 100644 staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx create mode 100644 staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx index d057fb0dc..608b75a42 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewClaimableRewards.tsx @@ -4,6 +4,7 @@ import { TooltipIcon } from "@/components/Tooltip" import { formatTokenAmount, formatTokenAmountFull } from "@/utils/atpFormatters" import { ManageRewardsAddressesModal } from "@/components/RewardsManagement" import { ClaimAllRewardsModal } from "@/components/ClaimAllRewardsModal" +import { useTermsModal } from "@/contexts/TermsModalContext" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" import type { CoinbaseBreakdown } from "@/hooks/rewards/rewardsTypes" @@ -28,6 +29,7 @@ export const ATPStakingOverviewClaimableRewards = forwardRef { const [isManageModalOpen, setIsManageModalOpen] = useState(false) const [isClaimAllModalOpen, setIsClaimAllModalOpen] = useState(false) + const { requireTermsAcceptance } = useTermsModal() // Combined total rewards (delegation + self-stake) const combinedTotalRewards = totalRewards + selfStakeRewards @@ -111,13 +113,14 @@ export const ATPStakingOverviewClaimableRewards = forwardRef - {/* Claim All Button */} + {/* Claim All Button — no longer gated by the configured rollup's claimability, + since each per-task rollup gates its own claim and engine surfaces per-task errors. */} {/* Transaction Info */} - {hasRewards && isRewardsClaimable && ( + {hasRewards && (

This will require multiple transactions. Each claim will prompt for approval.

diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 19d09f749..57d360d6a 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -5,9 +5,9 @@ import { CopyButton } from "@/components/CopyButton" import { formatTokenAmount } from "@/utils/atpFormatters" import { debounce } from "@/utils/debounce" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" -import { useSequencerRewards } from "@/hooks/rollup/useSequencerRewards" import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" -import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" +import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup/useIsRewardsClaimableAcrossRollups" +import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewardsAcrossRollups" import { useAlert } from "@/contexts/AlertContext" import type { ATPData } from "@/hooks/atp" import type { Address } from "viem" @@ -43,11 +43,29 @@ export const ClaimSelfStakeRewardsModal = ({ const [hasCheckedRewards, setHasCheckedRewards] = useState(false) const [isDebouncing, setIsDebouncing] = useState(false) + const isValidAddress = coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x') + // Empty array while typing prevents firing reads against an invalid coinbase. + const coinbasesForQuery = useMemo( + () => (isValidAddress ? [coinbaseAddress as Address] : []), + [coinbaseAddress, isValidAddress], + ) + + // Fan the read out across every rollup discovered via the Aztec governance Registry, so a + // sequencer with stranded balances on older rollups sees them all listed (one row per rollup). const { - rewards, + coinbaseBreakdown, + totalCoinbaseRewards, isLoading: isLoadingRewards, - refetch: checkRewards - } = useSequencerRewards(coinbaseAddress) + refetch: checkRewards, + } = useCoinbaseRewardsAcrossRollups(coinbasesForQuery) + + // Multicall isRewardsClaimable() across the same rollups so the per-row claim button reflects + // the right rollup's gating, not the configured rollup's. + const rollupAddressesInBreakdown = useMemo( + () => coinbaseBreakdown.map((row) => row.rollupAddress), + [coinbaseBreakdown], + ) + const { isClaimable: isClaimableForRollup } = useIsRewardsClaimableAcrossRollups(rollupAddressesInBreakdown) const { claimRewards, @@ -58,8 +76,6 @@ export const ClaimSelfStakeRewardsModal = ({ reset } = useClaimSequencerRewards() - const { isRewardsClaimable } = useIsRewardsClaimable() - // Create debounced check function that manages debouncing state const debouncedCheckRewards = useMemo( () => debounce(() => { @@ -81,8 +97,10 @@ export const ClaimSelfStakeRewardsModal = ({ } }, [coinbaseAddress, debouncedCheckRewards]) - const handleClaim = () => { - claimRewards(coinbaseAddress as Address) + // Per-rollup claim helper — passes the rollup the row's balance lives on so the + // `claimSequencerRewards` tx is sent to the correct contract. + const handleClaim = (rollupAddress: Address) => { + claimRewards(coinbaseAddress as Address, rollupAddress) } // Handle success @@ -118,8 +136,6 @@ export const ClaimSelfStakeRewardsModal = ({ } } - const isValidAddress = coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x') - if (!isOpen) return null return createPortal( @@ -204,43 +220,74 @@ export const ClaimSelfStakeRewardsModal = ({ )} - {/* Rewards Display */} + {/* Rewards Display — one row per rollup with a non-zero balance for this coinbase. */} {hasCheckedRewards && !isLoadingRewards && !isDebouncing && ( <> - {rewards !== undefined ? ( -
-
- Available Rewards + {coinbaseBreakdown.length > 0 ? ( +
+
+
+ Available Rewards +
+
+ Total: {decimals && symbol ? formatTokenAmount(totalCoinbaseRewards, decimals, symbol) : '-'} +
-
- {decimals && symbol ? formatTokenAmount(rewards, decimals, symbol) : '-'} -
- {rewards === 0n && ( -

- No rewards available for this coinbase address -

- )} + {coinbaseBreakdown.map((row) => { + const perRollupClaimable = isClaimableForRollup(row.rollupAddress) + // Default to allowing the claim while loading; the contract will revert if it's + // genuinely locked. Disable explicitly only when we've confirmed false. + const rowIsClaimable = perRollupClaimable !== false + return ( +
+
+ {row.rollupVersion !== undefined ? ( + + Rollup v{row.rollupVersion.toString()} + + ) : ( + + Configured rollup + + )} +
+ {decimals && symbol ? formatTokenAmount(row.rewards, decimals, symbol) : '-'} +
+
+ +
+ ) + })}
) : ( -
-
- Coinbase Not Found -
-
- Cannot find rewards for this coinbase address. Please verify the address is correct. -
-
- )} - - {/* Rewards Not Claimable Warning */} - {isRewardsClaimable === false && ( -
-
- Rewards Currently Locked -
-
- All rewards are currently locked by the network protocol (rollup). Claiming will be enabled once the protocol unlocks rewards. +
+
+ Available Rewards
+

+ No rewards found for this coinbase address on any known rollup. +

)} @@ -262,27 +309,7 @@ export const ClaimSelfStakeRewardsModal = ({ onClick={handleClose} className="px-6 py-3 border border-parchment/30 text-parchment font-oracle-standard font-bold text-sm uppercase tracking-wider hover:bg-parchment/10 transition-all" > - Cancel - -
diff --git a/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx b/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx new file mode 100644 index 000000000..18b03a0e8 --- /dev/null +++ b/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx @@ -0,0 +1,26 @@ +import { Icon } from "@/components/Icon" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" + +/** + * Small inline disclaimer shown on pages that surface historical aggregates from the indexer + * (providers list, provider details, StakePortal summaries). It only renders when the + * dashboard detects more than one rollup in the governance Registry so operators aren't + * confused by a missing disclaimer on single-rollup deployments. + */ +export const IndexerRollupDisclaimer = () => { + const { rollups, isLoading } = useRollupRegistry() + + // Only show when there's more than one known rollup; single-rollup deployments have + // complete data so the disclaimer would be misleading. + if (isLoading || rollups.length <= 1) return null + + return ( +
+ + + Historical statistics reflect the configured rollup only and may not include data from + older or canonical rollups. + +
+ ) +} diff --git a/staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts b/staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts new file mode 100644 index 000000000..1d5ec99c9 --- /dev/null +++ b/staking-dashboard/src/components/IndexerRollupDisclaimer/index.ts @@ -0,0 +1 @@ +export { IndexerRollupDisclaimer } from "./IndexerRollupDisclaimer" diff --git a/staking-dashboard/src/components/Registration/RegistrationStake.tsx b/staking-dashboard/src/components/Registration/RegistrationStake.tsx index 80bfc289d..5a25dd802 100644 --- a/staking-dashboard/src/components/Registration/RegistrationStake.tsx +++ b/staking-dashboard/src/components/Registration/RegistrationStake.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect, useCallback, useMemo } from "react" import { useRollupData } from "@/hooks/rollup/useRollupData" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" import { useERC20TokenDetails } from "@/hooks/erc20/useERC20TokenDetails" import { useATPStakingStepsContext, ATPStakingStepsWithTransaction, buildConditionalDependencies } from "@/contexts/ATPStakingStepsContext" import { useTransactionCart } from "@/contexts/TransactionCartContext" @@ -29,7 +30,11 @@ interface RegistrationStakeProps { export const RegistrationStake = ({ onComplete }: RegistrationStakeProps) => { const { formData, handlePrevStep } = useATPStakingStepsContext() const { selectedAtp, uploadedKeystores, transactionType } = formData - const { activationThreshold, version: rollupVersion, isLoading: isLoadingRollup } = useRollupData() + // Resolve the canonical rollup so registrations target the latest one even if the dashboard + // was deployed against an older `VITE_ROLLUP_ADDRESS`. The activation threshold and version + // both come from the canonical rollup so the staked amount and `_rollupVersion` arg agree. + const { canonical: canonicalRollup, isLoading: isLoadingRegistry } = useRollupRegistry() + const { activationThreshold, version: rollupVersion, isLoading: isLoadingRollup } = useRollupData(canonicalRollup?.address) const { symbol, decimals, isLoading: isLoadingToken } = useERC20TokenDetails(selectedAtp?.token!) const { addTransaction, checkTransactionInQueue, isSafe, openCart } = useTransactionCart() const { showAlert } = useAlert() @@ -170,7 +175,7 @@ export const RegistrationStake = ({ onComplete }: RegistrationStakeProps) => { onComplete() } - const isLoading = isLoadingRollup || isLoadingToken + const isLoading = isLoadingRollup || isLoadingToken || isLoadingRegistry return (
diff --git a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx index e6828b659..801af0744 100644 --- a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx +++ b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx @@ -4,6 +4,7 @@ import { Icon } from "@/components/Icon" import { StepIndicator } from "@/components/StepIndicator" import { SuccessAlert } from "@/components/SuccessAlert" import { useRollupData } from "@/hooks/rollup/useRollupData" +import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry/useStakingAssetTokenDetails" import { useAllowance } from "@/hooks/erc20/useAllowance" import { useApproveRollup } from "@/hooks/erc20/useApproveRollup" @@ -41,7 +42,12 @@ export const WalletDirectStakingFlow = ({ onComplete, }: WalletDirectStakingFlowProps) => { const { address } = useAccount() - const { activationThreshold, isLoading: isLoadingRollup } = useRollupData() + // Discover the canonical rollup so registrations target whichever rollup is currently + // canonical, even if the dashboard was deployed against an older `VITE_ROLLUP_ADDRESS`. + // Falls back to the configured rollup while the registry is loading or if discovery fails. + const { canonical: canonicalRollup, isLoading: isLoadingRegistry } = useRollupRegistry() + const targetRollupAddress = canonicalRollup?.address ?? contracts.rollup.address + const { activationThreshold, isLoading: isLoadingRollup } = useRollupData(targetRollupAddress) const { stakingAssetAddress, symbol, decimals, isLoading: isLoadingToken } = useStakingAssetTokenDetails() const { addTransaction, openCart, transactions, checkTransactionInQueue } = useTransactionCart() const { showAlert } = useAlert() @@ -70,18 +76,20 @@ export const WalletDirectStakingFlow = ({ return activationThreshold * BigInt(stakeCount) }, [activationThreshold, stakeCount]) - // Check current allowance for Rollup contract + // Check current allowance against the *canonical* rollup, since that's where the deposit + // will be sent. The approval and the deposit must agree on the spender address. const { allowance, isLoading: isLoadingAllowance, refetch: refetchAllowance } = useAllowance({ tokenAddress: stakingAssetAddress, owner: address, - spender: contracts.rollup.address, + spender: targetRollupAddress, }) const hasEnoughAllowance = allowance !== undefined && allowance >= totalAmount - // Hooks for building transactions - const approveHook = useApproveRollup(stakingAssetAddress) - const depositHook = useWalletDirectStake() + // Hooks for building transactions — both bound to the canonical rollup so the approval and + // deposit land on the same contract. + const approveHook = useApproveRollup(stakingAssetAddress, targetRollupAddress) + const depositHook = useWalletDirectStake(targetRollupAddress) // Track transactions in the queue const approvalTx = useMemo(() => { @@ -342,7 +350,7 @@ export const WalletDirectStakingFlow = ({ setShowSuccessAlert(false) } - const isLoading = isLoadingRollup || isLoadingToken || isLoadingAllowance + const isLoading = isLoadingRollup || isLoadingToken || isLoadingAllowance || isLoadingRegistry const canProceedToStep2 = uploadedKeystores.length > 0 && validatorRunningConfirmed && !uploadError const canProceedToStep3 = hasEnoughAllowance || isApprovalInQueue || isApprovalCompleted diff --git a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx index 47f3862e7..8d492683a 100644 --- a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx +++ b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx @@ -1,21 +1,38 @@ +import { useMemo } from "react" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { formatTokenAmountFull } from "@/utils/atpFormatters" import { useClaimCoinbaseRewards, useRemoveCoinbaseAddress } from "@/hooks/rewards" +import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup" import type { CoinbaseBreakdown } from "@/hooks/rewards" import type { Address } from "viem" interface CoinbaseAddressListProps { + /** + * Reward breakdown produced by `useCoinbaseRewardsAcrossRollups` (or its compatibility + * wrapper `useMultipleCoinbaseRewards`). May contain multiple entries for the same + * coinbase address — one per rollup that holds a non-zero balance. + */ coinbaseBreakdown: CoinbaseBreakdown[] decimals: number symbol: string + /** + * Configured-rollup claimability flag, kept for backwards compatibility. Used as a fallback + * for rows whose specific rollup hasn't loaded yet. The component itself fetches per-rollup + * `isRewardsClaimable()` for every distinct rollup it sees in the breakdown so each row's + * button reflects its own rollup's state. + */ isRewardsClaimable: boolean isLoading?: boolean onRefetch?: () => void } /** - * Display list of coinbase addresses with their rewards + * Display list of coinbase addresses with their rewards. + * + * Renders one row per (coinbase, rollup) pair with rewards > 0, so operators can see + * stranded balances on older rollups and claim them individually. Each claim button issues + * a `claimSequencerRewards` tx against the specific rollup the row's balance lives on. */ export const CoinbaseAddressList = ({ coinbaseBreakdown, @@ -26,15 +43,24 @@ export const CoinbaseAddressList = ({ onRefetch }: CoinbaseAddressListProps) => { const { removeCoinbaseAddress, isPending: isRemoving } = useRemoveCoinbaseAddress() + // Hook is instantiated once and the per-row rollup is passed as the `claimRewards` override. const claimRewards = useClaimCoinbaseRewards() + // Multicall isRewardsClaimable() across every rollup represented in the breakdown so each + // row's claim button reflects its own rollup's state, not just the configured rollup. + const rollupAddressesInBreakdown = useMemo( + () => coinbaseBreakdown.map((item) => item.rollupAddress), + [coinbaseBreakdown], + ) + const { isClaimable: isClaimableForRollup } = useIsRewardsClaimableAcrossRollups(rollupAddressesInBreakdown) + const handleRemove = async (address: Address) => { await removeCoinbaseAddress(address) onRefetch?.() } - const handleClaim = async (address: Address) => { - await claimRewards.claimRewards(address) + const handleClaim = async (address: Address, rollupAddress: Address) => { + await claimRewards.claimRewards(address, rollupAddress) onRefetch?.() } @@ -60,9 +86,14 @@ export const CoinbaseAddressList = ({ return (
- {coinbaseBreakdown.map((item) => ( + {coinbaseBreakdown.map((item) => { + // Per-rollup claimability flag; fall back to the prop while the multicall is still loading. + const perRollupClaimable = isClaimableForRollup(item.rollupAddress) + const rowIsClaimable = perRollupClaimable ?? isRewardsClaimable + + return (
@@ -73,6 +104,14 @@ export const CoinbaseAddressList = ({ {item.address.slice(0, 10)}...{item.address.slice(-8)} + {item.rollupVersion !== undefined && ( + + Rollup v{item.rollupVersion.toString()} + + )}
{/* Rewards */} @@ -100,9 +139,9 @@ export const CoinbaseAddressList = ({ {/* Claim Button */} {item.rewards > 0n && (
- {isRewardsClaimable ? ( + {rowIsClaimable ? (
)}
- ))} + ) + })}
) } diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx index 13ecf97c1..6c01d4906 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx @@ -11,7 +11,7 @@ import { formatBlockTimestamp } from "@/utils/dateFormatters" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { getValidatorDashboardValidatorUrl } from "@/utils/validatorDashboardUtils" import { getExplorerTxUrl } from "@/utils/explorerUtils" -import { useSequencerStatus, SequencerStatus, useStakeHealth } from "@/hooks/rollup" +import { useSequencerStatus, SequencerStatus, useStakeHealth, useAttesterStakeLocation } from "@/hooks/rollup" import { useGovernanceConfig } from "@/hooks/governance" import { WalletWithdrawalActions } from "./WalletWithdrawalActions" import type { Erc20DirectStakeBreakdown } from "@/hooks/atp/useAggregatedStakingData" @@ -34,7 +34,13 @@ export const WalletDirectStakeItem = ({ const { symbol, decimals } = useStakingAssetTokenDetails() const { date, time } = formatBlockTimestamp(stake.timestamp) - const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, refetch: refetchStatus } = useSequencerStatus(stake.attesterAddress as Address) + // Discover which rollup the attester's stake actually lives on. For non-stranded stakes this + // returns the configured/canonical rollup; for stranded stakes it returns the specific old + // rollup so that withdraw/finalize calls target the correct contract. + const { location: stakeLocation } = useAttesterStakeLocation(stake.attesterAddress as Address) + const resolvedRollup = stakeLocation?.rollupAddress + + const { status, statusLabel, isLoading: isLoadingStatus, canFinalize, actualUnlockTime, refetch: refetchStatus } = useSequencerStatus(stake.attesterAddress as Address, resolvedRollup) const { withdrawalDelayDays } = useGovernanceConfig() const { @@ -46,7 +52,7 @@ export const WalletDirectStakeItem = ({ isAtRisk, isCritical, isLoading: isLoadingHealth - } = useStakeHealth(stake.attesterAddress as Address) + } = useStakeHealth(stake.attesterAddress as Address, resolvedRollup) const isUnstaked = stake.status === 'UNSTAKED' const isInQueue = status === SequencerStatus.NONE && !stake.hasFailedDeposit && !isUnstaked @@ -252,6 +258,7 @@ export const WalletDirectStakeItem = ({ canFinalize={canFinalize} actualUnlockTime={actualUnlockTime} withdrawalDelayDays={withdrawalDelayDays} + rollupAddress={resolvedRollup} onSuccess={() => { refetchStatus() onWithdrawSuccess?.() diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx index 51dd4d0ba..c955f6c53 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx @@ -69,6 +69,9 @@ interface WalletWithdrawalActionsProps { actualUnlockTime?: bigint withdrawalDelayDays?: number onSuccess?: () => void + /** The rollup contract the stake actually lives on. Stranded stakes pass the old rollup here + * so `initiateWithdraw` / `finalizeWithdraw` target the correct contract. */ + rollupAddress?: Address } /** @@ -83,17 +86,20 @@ export const WalletWithdrawalActions = ({ actualUnlockTime, withdrawalDelayDays, onSuccess, + rollupAddress, }: WalletWithdrawalActionsProps) => { const { showAlert } = useAlert() const isExiting = status === SequencerStatus.EXITING + // Pass the per-stake rollup so stranded stakes call initiateWithdraw/finalizeWithdraw on the + // correct rollup contract, not the configured one. const { initiateWithdraw, isPending: isInitiatingWithdraw, isConfirming: isConfirmingInitiate, isSuccess: isInitiateSuccess, error: initiateError, - } = useWalletInitiateWithdraw() + } = useWalletInitiateWithdraw(rollupAddress) const { finalizeWithdraw, @@ -101,7 +107,7 @@ export const WalletWithdrawalActions = ({ isConfirming: isConfirmingFinalize, isSuccess: isFinalizeSuccess, error: finalizeError, - } = useFinalizeWithdraw() + } = useFinalizeWithdraw(rollupAddress) const canInitiateUnstake = status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE diff --git a/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx b/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx index f0d009882..c75edaf14 100644 --- a/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx +++ b/staking-dashboard/src/pages/Providers/StakingProviderDetailPage.tsx @@ -4,6 +4,7 @@ import { ProviderStakingFlow } from "@/components/Provider/ProviderStakingFlow"; import { ProviderSequencerList } from "@/components/Provider/ProviderSequencerList"; import { ProviderDetailSkeleton } from "@/components/Provider/ProviderDetailSkeleton"; import { PageHeader } from "@/components/PageHeader"; +import { IndexerRollupDisclaimer } from "@/components/IndexerRollupDisclaimer"; import { useProviderDetail } from "@/hooks/providers/useProviderDetail"; import { Link } from "react-router-dom"; import { applyHeroItalics } from "@/utils/typographyUtils"; @@ -60,6 +61,8 @@ export default function StakingProviderDetailPage() { provider={provider} />
+ +
); } diff --git a/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx b/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx index 194946440..0be1d3cc4 100644 --- a/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx +++ b/staking-dashboard/src/pages/Providers/StakingProvidersPage.tsx @@ -1,6 +1,7 @@ import { Link } from "react-router-dom" import { Icon } from "@/components/Icon" import { DecentralizationDisclaimer } from "@/components/DecentralizationDisclaimer" +import { IndexerRollupDisclaimer } from "@/components/IndexerRollupDisclaimer" import { PageHeader } from "@/components/PageHeader" import { Pagination } from "@/components/Pagination" import { ProviderSearch } from "@/components/Provider/ProviderSearch" @@ -121,6 +122,8 @@ export default function StakingProvidersPage() { totalItems={allProviders.length} /> + + {disclaimerProvider && ( Date: Thu, 16 Apr 2026 09:10:00 +0200 Subject: [PATCH 05/18] test: add multi-rollup integration test scripts End-to-end test setup that deploys real Aztec L1 contracts to local anvil with 2 rollup versions, seeds reward state, and verifies the dashboard's multi-rollup features. - deploy-multi-rollup.sh: orchestrates full L1 deploy (DeployAztecL1Contracts + DeployRollupForUpgrade), registers v2 via anvil_impersonateAccount, deploys MockStakingRegistry, deploys Multicall3, writes contract_addresses.json - seed-multi-rollup.ts: seeds sequencer rewards and isRewardsClaimable via anvil_setStorageAt using namespaced storage layout, mints fee tokens to rollups for claim payouts - MockStakingRegistry.sol: minimal mock (only contract not in aztec-packages) - README.md: full setup guide with architecture, gotchas, troubleshooting Requires AZTEC_PACKAGES_DIR env var (set automatically by .envrc). --- .gitignore | 11 + CLAUDE.md | 126 +++++++ staking-dashboard/.tool-versions | 2 + .../contracts/mocks/MockStakingRegistry.sol | 17 + staking-dashboard/foundry.toml | 5 + .../scripts/multi-rollup-test/README.md | 184 ++++++++++ .../multi-rollup-test/deploy-multi-rollup.sh | 226 ++++++++++++ .../multi-rollup-test/seed-multi-rollup.ts | 338 ++++++++++++++++++ 8 files changed, 909 insertions(+) create mode 100644 CLAUDE.md create mode 100644 staking-dashboard/.tool-versions create mode 100644 staking-dashboard/contracts/mocks/MockStakingRegistry.sol create mode 100644 staking-dashboard/foundry.toml create mode 100644 staking-dashboard/scripts/multi-rollup-test/README.md create mode 100755 staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh create mode 100644 staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts diff --git a/.gitignore b/.gitignore index ddcbb911d..56e72a82f 100644 --- a/.gitignore +++ b/.gitignore @@ -49,5 +49,16 @@ coverage/ .cache/ .parcel-cache/ +# Foundry build artifacts (staking-dashboard mock contracts) +staking-dashboard/contracts/out/ +staking-dashboard/cache/ + +# Multi-rollup test outputs (generated by deploy/seed scripts) +deploy-output.json +test-data.json + +# direnv +.envrc + # AI .claude \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..78af80435 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,126 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository layout + +This is a monorepo for the Aztec Staking Dashboard, made up of two independent yarn workspaces plus shared data and infra: + +- `staking-dashboard/` — React 19 + Vite + TypeScript frontend (RainbowKit / wagmi / viem). Talks to Ethereum via RPC and to the indexer via REST. +- `atp-indexer/` — Ponder-based blockchain indexer with a Hono REST API on top. Indexes ATP factories, the staking registry, the rollup, and dynamic ATP/Staker contracts created at runtime. +- `providers/` and `providers-testnet/` — One JSON file per provider (name, logo, self-stake addresses, etc.). These are aggregated into the indexer at build time by `atp-indexer/scripts/aggregate-providers*.ts` and served from `/api/providers`. +- `scripts/logging.sh` — Shared bash logging helpers sourced by both `bootstrap.sh` scripts. +- `terraform/` — Shared infra; each workspace also has its own `terraform/` directory used by its `bootstrap.sh deploy-*` actions. +- `db-schemas.json` — Maps the active Ponder DB schema name per environment (e.g. `atp-indexer-prod-v20`). Bumped when the schema changes. + +There is no root `package.json`. Run `yarn` inside `staking-dashboard/` or `atp-indexer/` separately. Both use yarn 1.22 and require Node >=18.14 (frontend prefers v20+/v22). + +## Common commands + +### Frontend (`cd staking-dashboard`) + +```bash +yarn install +yarn dev # Vite dev server on http://localhost:5173 +yarn build # tsc -b && vite build (validates required VITE_* env vars) +yarn lint # eslint . +yarn type-check # tsc --noEmit +yarn format # prettier write (skips src/contracts/abis/**) +``` + +The Vite build is gated on a list of required `VITE_*` env vars (see `vite.config.ts`). It will throw with a list of missing variables rather than producing a broken bundle. + +The path alias `@/*` resolves to `staking-dashboard/src/*`. + +### Frontend bootstrap script + +`./bootstrap.sh ` wraps env-file generation, install, and `yarn dev`/`yarn build`. Common actions: + +- `./bootstrap.sh dev` — uses dev (anvil 31337) network addresses and the local indexer +- `./bootstrap.sh sepolia` — uses Sepolia + the public Sepolia indexer +- `./bootstrap.sh dev-testnet` / `dev-prod` — local dev pointing at a deployed indexer +- `./bootstrap.sh docker` — builds and runs the frontend container (sepolia env) +- `./bootstrap.sh deploy-testnet` / `deploy-prod` — Terraform + S3 + CloudFront deploy (requires AWS creds, `WALLETCONNECT_PROJECT_ID`, and `RPC_URL`/`TESTNET_RPC_URL`) + +### Indexer (`cd atp-indexer`) + +```bash +yarn install +yarn bootstrap # aggregate providers/*.json -> src/api/data/providers.json +yarn bootstrap-testnet # same, but reads providers-testnet/ +yarn codegen # ponder codegen (regenerate types) +yarn dev # rm -rf .ponder generated && ponder dev (port 42068 by default) +yarn start # ponder start (production) +yarn serve # ponder serve (API only) +yarn test # jest +yarn test -- path/to/file.test.ts # run a single test file +yarn test -- -t "name" # run tests by name +yarn typecheck # tsc --noEmit +yarn compare # tsx scripts/compare-databases.ts (compare two DB snapshots) +``` + +### Indexer bootstrap script + +`./bootstrap.sh [environment]`: + +- `./bootstrap.sh dev` — generates `.env` for the local anvil (31337) chain, runs `yarn bootstrap`, `yarn codegen`, and `yarn dev` +- `./bootstrap.sh build [testnet|prod]` — install + provider aggregate (chooses `bootstrap` vs `bootstrap-testnet` from the env arg) + codegen +- `./bootstrap.sh deploy-testnet` / `deploy-prod` — Terraform deploy. Reads `RPC_URL`/`TESTNET_RPC_URL`, `AWS_ACCOUNT`, `AWS_REGION`, plus contract addresses. Use `DRY_RUN=true` to write a plan to `terraform-plans/` instead of applying. + +`testnet` deploys land in the shared `dev` cluster but use a separate Terraform state file. + +## Architecture + +### High-level dataflow + +``` +Browser → React frontend ─┬─ RPC (viem/wagmi) ──→ Ethereum (mainnet/sepolia/anvil) + └─ REST API ─────────→ atp-indexer (Hono) ──→ Postgres / pglite + ↑ Ponder syncs events from the same chain +``` + +The frontend reads on-chain state directly through wagmi/viem and uses the indexer for aggregations that would be too expensive to compute in the browser (provider lists, historical staking events, ATP listings by beneficiary, etc.). + +### Indexer + +Ponder configuration lives in `atp-indexer/ponder.config.ts`. The indexer tracks four ATP factories (Genesis, Auction, MATP, LATP), the `StakingRegistry`, and the `Rollup`. ATP child contracts are discovered via Ponder's `factory()` pattern using the `ATPCreated` event from any of the four factories. Staker contracts are not factory-emitted — they are derived via `getStaker()`, so handlers in `src/events/staker/` filter events against known ATP positions. + +- Schema: `atp-indexer/ponder.schema.ts` (drizzle-style `onchainTable` definitions, indices, and `relations`). +- Event handlers: `atp-indexer/src/events/{atp,atp-factory,rollup,staker,staking-registry}/`. +- API: Hono app in `src/api/index.ts` mounting routes from `src/api/routes/{health,providers,staking,atp}.routes.ts` under `/api/*`. CORS is open by default; rate limiting is gated on `RATE_LIMIT_ENABLED`. +- Config: `src/config/index.ts` is a lazy `Proxy` over a Zod-validated `process.env`. Always import `config` from there rather than reading `process.env` directly. Supported chains: mainnet (1), sepolia (11155111), holesky (17000), anvil (31337). +- Database: Postgres if `POSTGRES_CONNECTION_STRING` is set (with `sslmode=require|no-verify` honored), otherwise pglite. The schema name in production is pinned by `db-schemas.json`. +- Provider data: `providers/*.json` (mainnet) and `providers-testnet/*.json` (testnet) are aggregated into `src/api/data/providers.json` by `yarn bootstrap` / `yarn bootstrap-testnet`. **Adding or editing a provider requires re-running bootstrap** before `yarn dev` will pick up the change. Use `providers/_example.json` as the schema reference. + +### Frontend + +- Entry: `src/main.tsx` → `src/App.tsx` → `src/routes/AppRoutes.tsx`. Routing uses `react-router-dom` v7. +- Pages live under `src/pages/{ATP,Governance,NotFound,Providers,RegisterValidator,StakePortal}`. The default route (`/`) is `MyPositionPage`. +- **Governance is currently disabled** — `/governance/*` redirects to `/` in `AppRoutes.tsx`, and the UI surfaces external governance frontends via `ExternalGovernanceModal`. Don't re-enable the in-app governance route without an explicit ask. +- Contracts: `src/contracts/index.ts` builds a typed `contracts` object from Zod-validated `import.meta.env` (`VITE_*_ADDRESS`). ABIs live under `src/contracts/abis/` — these are excluded from prettier formatting. +- Hooks: `src/hooks/` is grouped per contract domain (`atp/`, `atpFactory/`, `governance/`, `staking/`, `rewards/`, etc.) plus top-level cross-cutting hooks (`useTransactionManager`, `useSafeApp`, `useStakingSteps`, …). +- Wallet: RainbowKit + wagmi configured in `src/wagmi.ts`. Safe wallet is supported via `@safe-global/*` packages and the `useSafeApp`/`SafeWarningModal` flow. +- Styling: Tailwind v4 via `@tailwindcss/vite` plus Radix primitives. UI primitives live in `src/components/ui/`. `components.json` is the shadcn config. + +### Configuration model + +Both frontend and indexer accept contract addresses three ways, in this priority order: + +1. Environment variables (`VITE_*_ADDRESS` for the frontend, bare `*_ADDRESS` for the indexer) — preferred for CI/CD +2. `CONTRACT_ADDRESSES_FILE=/path/to/contract_addresses.json` +3. `contract_addresses.json` in the workspace root + +`bootstrap.sh` in each workspace handles loading from the JSON file via `jq` and writing the appropriate env file. The frontend env vars are `VITE_`-prefixed; the indexer's are not. + +## Deployment notes + +- CI/CD lives in `.github/workflows/{build,deploy-staking-dashboard,deploy-indexer}.yaml`. +- Frontend is deployed to S3 + CloudFront. Both staging and prod have **red/green** deployments (see `*-green` actions in the bootstrap scripts and the hardcoded CloudFront domains in `update_env_file`). +- Indexer is deployed to ECS via Terraform. `testnet` deploys reuse the shared `dev` ECS cluster. `prod` uses its own. Each deploy bumps an ECR image tag and force-redeploys both the API and indexer ECS services. +- **Schema bumps**: When `ponder.schema.ts` changes in a way that requires a fresh DB, bump the version in `db-schemas.json`. Recent example: `beba7cc chore: bump prod db schema to v20`. + +## Conventions + +- Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`...) — see `CONTRIBUTING.md` and recent `git log`. +- Both workspaces are TypeScript-strict; prefer `viem`/`wagmi` types over hand-rolled `Address`/`Hex` types. +- Don't reformat files under `staking-dashboard/src/contracts/abis/**` — they are intentionally excluded from prettier and changes there should be regenerated, not hand-edited. diff --git a/staking-dashboard/.tool-versions b/staking-dashboard/.tool-versions new file mode 100644 index 000000000..7d48ef57c --- /dev/null +++ b/staking-dashboard/.tool-versions @@ -0,0 +1,2 @@ +yarn 1.22.22 +nodejs 22.22.2 diff --git a/staking-dashboard/contracts/mocks/MockStakingRegistry.sol b/staking-dashboard/contracts/mocks/MockStakingRegistry.sol new file mode 100644 index 000000000..ab19d9501 --- /dev/null +++ b/staking-dashboard/contracts/mocks/MockStakingRegistry.sol @@ -0,0 +1,17 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.27; + +/// @notice Minimal mock — the real StakingRegistry isn't in aztec-packages. +/// Returns ROLLUP_REGISTRY() and STAKING_ASSET() for frontend discovery. +contract MockStakingRegistry { + address public immutable ROLLUP_REGISTRY; + address public immutable STAKING_ASSET; + address public immutable PULL_SPLIT_FACTORY; + uint256 public nextProviderIdentifier; + + constructor(address _rollupRegistry, address _stakingAsset, address _pullSplitFactory) { + ROLLUP_REGISTRY = _rollupRegistry; + STAKING_ASSET = _stakingAsset; + PULL_SPLIT_FACTORY = _pullSplitFactory; + } +} diff --git a/staking-dashboard/foundry.toml b/staking-dashboard/foundry.toml new file mode 100644 index 000000000..96f307785 --- /dev/null +++ b/staking-dashboard/foundry.toml @@ -0,0 +1,5 @@ +[profile.default] +src = "contracts" +out = "contracts/out" +libs = [] +solc = "0.8.27" diff --git a/staking-dashboard/scripts/multi-rollup-test/README.md b/staking-dashboard/scripts/multi-rollup-test/README.md new file mode 100644 index 000000000..44de48872 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/README.md @@ -0,0 +1,184 @@ +# Multi-Rollup Integration Test + +End-to-end test environment for verifying multi-rollup support (Issue #57). Deploys **real Aztec L1 contracts** to a local anvil chain with 2 rollup versions registered in the same Registry, then seeds reward state so the dashboard can discover and display per-rollup rewards. + +## What it does + +1. **Deploys the full Aztec L1 stack** via `DeployAztecL1Contracts.s.sol` from `aztec-packages/l1-contracts/` — Registry, GSE, Governance, Rollup v1, mock verifier, all the real contracts +2. **Deploys a second rollup** via `DeployRollupForUpgrade.s.sol` with a different `GENESIS_ARCHIVE_ROOT` (producing a different version hash) +3. **Registers rollup v2** in the Registry using `anvil_impersonateAccount` to impersonate Governance (which owns the Registry after handover) +4. **Deploys MockStakingRegistry** — the only mock contract. The real StakingRegistry isn't in `aztec-packages`; this one just returns `ROLLUP_REGISTRY()` and `STAKING_ASSET()` +5. **Seeds test state** via `anvil_setStorageAt` — sets sequencer rewards and `isRewardsClaimable` on both rollups using the real contract's namespaced storage layout +6. **Mints fee tokens** to both rollup contracts so `claimSequencerRewards` can actually transfer tokens +7. **Writes config files** (`contract_addresses.json`, `deploy-output.json`, `test-data.json`) for the frontend + +## Prerequisites + +- **Node.js 22+** and **yarn 1.22** (check `.tool-versions`) +- **Foundry** (forge, anvil, cast) — install via `curl -L https://foundry.paradigm.xyz | bash && foundryup` +- **yq** — `brew install yq` (needed to load network defaults) +- **aztec-packages** repo — set `AZTEC_PACKAGES_DIR` env var (auto-set by `.envrc` if aztec-packages is at `../aztec-packages/` relative to the staking-dashboard repo root) +- Frontend dependencies installed: `cd staking-dashboard && yarn install` +- MockStakingRegistry compiled: `cd staking-dashboard && forge build` + +## Quick start + +```bash +# 1. Start anvil +anvil --port 8545 & + +# 2. Deploy contracts (takes ~2 min on first run due to Solidity compilation) +cd staking-dashboard +bash scripts/multi-rollup-test/deploy-multi-rollup.sh + +# 3. Seed rewards + mint fee tokens +npx tsx scripts/multi-rollup-test/seed-multi-rollup.ts + +# 4. Start frontend (reads contract_addresses.json via .env) +# The .env should already exist from the deploy script, or run: +./bootstrap.sh dev +# Then: +yarn dev +``` + +The frontend will be at `http://localhost:5173` with `VITE_ROLLUP_ADDRESS` pointing to rollup v1 (the old one), so `useRollupRegistry` will detect `isStale = true`. + +## What to test + +### Without wallet connection +- **Registry discovery**: the dashboard silently discovers 2 rollup versions via `useRollupRegistry` +- **Indexer disclaimer**: on `/providers`, the disclaimer "Historical statistics reflect the configured rollup only..." should appear (requires `rollups.length > 1`) + +### With wallet connection (MetaMask + anvil) +Add anvil network to MetaMask: RPC `http://localhost:8545`, Chain ID `31337`. Import anvil account 0: `0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80`. + +**Important — pre-populate coinbase address first.** The Claimable Rewards section is gated behind `hasStakedPositions`, which requires ATP staking events from the indexer. Since our test uses placeholder ATP factory addresses, no staking events are indexed and the section won't appear without this step. + +Paste in the browser DevTools console (the seed script prints this exact line): +```js +localStorage.setItem('rewards_coinbase_addresses_0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266', '["0x70997970c51812dc3a010c7d01b50e0d17dc79c8"]'); location.reload(); +``` + +Then: +1. Navigate to **Positions** (`/my-position`) +2. Expand the **Claimable Rewards** card — should show **15 STK** total +3. Click **Claim All Rewards** — should show 2 tasks: + - `0x7099...79C8 (rollup v{V1})` — **5 STK** + - `0x7099...79C8 (rollup v{V2})` — **10 STK** +4. Each task targets a different rollup contract address in the transaction + +**Before claiming**: reset MetaMask nonce — Settings → Advanced → **Clear activity tab data**. The deploy script creates many transactions, and MetaMask's cached nonce will be stale. + +## Test data matrix + +| Data Point | Rollup v1 (old, configured) | Rollup v2 (canonical) | +|---|---|---| +| `getVersion()` | auto-computed | different (different genesis) | +| `isRewardsClaimable()` | true | true | +| `getSequencerRewards(coinbaseA)` | 5e18 | 10e18 | +| `getSequencerRewards(coinbaseB)` | 3e18 | 0 | + +- **coinbaseA**: `0x70997970C51812dc3A010C7d01b50e0d17dc79C8` (anvil account 1) +- **coinbaseB**: `0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC` (anvil account 2) + +## File outputs + +| File | Purpose | +|------|---------| +| `deploy-output.json` | All deployed contract addresses + versions | +| `contract_addresses.json` | Format expected by `bootstrap.sh` — consumed by frontend `.env` generation | +| `test-data.json` | Expected values for assertions (rewards, versions, addresses) | + +## Architecture + +``` +anvil (port 8545) + ├── Real Registry (from aztec-packages) + │ ├── Rollup v1 (version A) — VITE_ROLLUP_ADDRESS points here (deliberately stale) + │ └── Rollup v2 (version B) — canonical + ├── Real GSE, Governance, RewardDistributor, MockVerifier + ├── MockStakingRegistry → points to real Registry + real staking token + └── Real TestERC20 tokens (STK + FEE) + +Frontend (port 5173) + └── useRollupRegistry() discovers Registry → enumerates 2 rollups + ├── useCoinbaseRewardsAcrossRollups() reads rewards from both + └── useIsRewardsClaimableAcrossRollups() checks claimability on each +``` + +## Gotchas and troubleshooting + +### MetaMask nonce errors ("nonce too low") +**Symptom**: Transactions fail with "Nonce provided for the transaction (N) is lower than the current nonce". + +**Cause**: MetaMask caches nonces per account. After restarting anvil or redeploying, the chain nonce resets but MetaMask's cache is stale. + +**Fix**: MetaMask → Settings → Advanced → **Clear activity tab data**. This resets the nonce cache. + +### `claimSequencerRewards` reverts with `ERC20InsufficientBalance` +**Symptom**: Claim simulation fails. Error shows the Rollup contract has 0 balance of a token. + +**Cause**: The real Rollup contract pays rewards by transferring **fee asset** (not staking asset) from its own balance. The seed script sets reward amounts in storage but doesn't give the Rollup any tokens to actually pay out. + +**Fix**: The seed script now mints fee tokens to both rollup contracts. If you see this error, re-run: `npx tsx scripts/multi-rollup-test/seed-multi-rollup.ts` + +### Multicall3 not deployed (wagmi `useReadContracts` fails silently) +**Symptom**: `useRollupRegistry` hook returns `rollups: []` even though `canonical` is populated. The stale banner may work but per-rollup features don't. + +**Cause**: wagmi's `useReadContracts` uses Multicall3 (`0xcA11bde05977b3631167028862bE2a173976CA11`). Anvil doesn't deploy it by default. The `forge script --broadcast` command deploys it during the Aztec L1 deploy, but if anvil was restarted after deployment, it's gone. + +**Fix**: The deploy script includes a Phase 0 that deploys Multicall3 via `anvil_setCode` using bytecode from the compiled `l1-contracts/out/Multicall3.sol/Multicall3.json`. If you restart anvil, re-run the deploy script. + +### l1-contracts won't compile — missing `HonkVerifier.sol` +**Symptom**: `forge build` fails in `aztec-packages/l1-contracts/` with "Source not found: generated/HonkVerifier.sol". + +**Cause**: The real HonkVerifier is generated from noir-projects circuit compilation. We use MockVerifier at runtime so the real one isn't needed, but the import still exists in test files. + +**Fix**: The deploy script creates a placeholder automatically. If compiling manually: +```bash +cd aztec-packages/l1-contracts +mkdir -p generated +cat > generated/HonkVerifier.sol << 'EOF' +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; +import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; +contract HonkVerifier is IVerifier { + function verify(bytes calldata, bytes32[] calldata) external pure override returns (bool) { return true; } +} +EOF +yq -o json 'explode(.) | ."l1-contracts" // {}' ../spartan/environments/network-defaults.yml > generated/default.json +forge build +``` + +### Indexer shows "No Staking Positions Available" +**Symptom**: The Positions Overview with Claimable Rewards doesn't appear because `hasStakedPositions` is false. + +**Cause**: The indexer watches for events from ATP factory contracts. Our test uses placeholder addresses for ATP factories, so no staking events are indexed. + +**Fix**: Either run the indexer (it will still respond to API calls, just with empty data), or pre-populate coinbase addresses via localStorage as described above. The Claimable Rewards card renders once you have coinbase addresses saved, but the `hasStakedPositions` guard on the Positions Overview section may hide it. + +### Storage slot calculation for rewards +The Rollup contract uses **namespaced storage** (ERC-7201 pattern). Reward data lives at: + +``` +base = keccak256("aztec.reward.storage") // NOT abi.encode — raw UTF-8 bytes + +RewardStorage layout (relative to base): + slot 0: mapping(address => uint256) sequencerRewards + slot 1: mapping(Epoch => EpochRewards) epochRewards + slot 2: mapping(address => BitMap) proverClaimed + slot 3-4: RewardConfig struct + slot 5: CompressedTimestamp earliestRewardsClaimableTimestamp + bool isRewardsClaimable +``` + +For a mapping entry: `keccak256(abi.encode(address, base + 0))`. +For `isRewardsClaimable`: set bit at byte offset 4 (after the 4-byte CompressedTimestamp) at slot `base + 5`. + +Common mistake: using `keccak256(abi.encode("aztec.reward.storage"))` (ABI-encoded string with offset+length) instead of `keccak256("aztec.reward.storage")` (raw bytes). The former produces a different hash and silently writes to the wrong slot. + +### Governance owns the Registry — can't register rollup v2 directly +**Symptom**: `DeployRollupForUpgrade` deploys rollup v2 but doesn't auto-register it because `registry.owner() != deployer`. + +**Cause**: `DeployAztecL1Contracts` transfers Registry ownership to Governance in `_handoverToGovernance()`. After that, only Governance can call `registry.addRollup()`. + +**Fix**: The deploy script uses `anvil_impersonateAccount` to impersonate the Governance contract and call `addRollup`. This only works on anvil — on real networks you'd need to go through the governance proposal flow. diff --git a/staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh b/staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh new file mode 100755 index 000000000..5ec1668d6 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/deploy-multi-rollup.sh @@ -0,0 +1,226 @@ +#!/bin/bash +set -euo pipefail + +# Deploy real Aztec L1 contracts with 2 rollup versions on local anvil. +# Uses DeployAztecL1Contracts for v1, DeployRollupForUpgrade for v2, +# then anvil_impersonateAccount to register v2 in the Registry. +# +# Required env vars: +# AZTEC_PACKAGES_DIR — path to aztec-packages repo root +# +# Optional env vars: +# ANVIL_PORT — anvil port (default: 8545) +# DEPLOYER_PK — deployer private key (default: anvil account 0) + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +STAKING_ROOT="$SCRIPT_DIR/../.." + +# Resolve L1 contracts directory +if [ -z "${AZTEC_PACKAGES_DIR:-}" ]; then + echo "ERROR: AZTEC_PACKAGES_DIR is not set." + echo "Set it to the aztec-packages repo root, e.g.:" + echo " export AZTEC_PACKAGES_DIR=/path/to/aztec-packages" + exit 1 +fi +L1_ROOT="$AZTEC_PACKAGES_DIR/l1-contracts" +if [ ! -d "$L1_ROOT/src" ]; then + echo "ERROR: $L1_ROOT/src not found. Is AZTEC_PACKAGES_DIR correct?" + exit 1 +fi + +# Clean stale output files (prevents race conditions with seed/frontend scripts) +rm -f "$STAKING_ROOT/deploy-output.json" "$STAKING_ROOT/test-data.json" + +ANVIL_PORT="${ANVIL_PORT:-8545}" +L1_RPC_URL="http://127.0.0.1:$ANVIL_PORT" +DEPLOYER_PK="${DEPLOYER_PK:-0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80}" + +echo "=== Loading devnet defaults ===" +source "$L1_ROOT/scripts/load_network_defaults.sh" devnet 2>/dev/null + +export L1_RPC_URL +export ROLLUP_DEPLOYMENT_PRIVATE_KEY="$DEPLOYER_PK" +export REAL_VERIFIER=false + +# Ensure l1-contracts are compiled +echo "=== Ensuring l1-contracts are compiled ===" +cd "$L1_ROOT" +mkdir -p generated +if [ ! -f generated/HonkVerifier.sol ]; then + cat > generated/HonkVerifier.sol << 'SOLEOF' +// SPDX-License-Identifier: Apache-2.0 +pragma solidity >=0.8.27; +import {IVerifier} from "@aztec/core/interfaces/IVerifier.sol"; +contract HonkVerifier is IVerifier { + function verify(bytes calldata, bytes32[] calldata) external pure override returns (bool) { return true; } +} +SOLEOF +fi +if [ ! -f generated/default.json ]; then + yq -o json 'explode(.) | ."l1-contracts" // {}' "$AZTEC_PACKAGES_DIR/spartan/environments/network-defaults.yml" > generated/default.json 2>/dev/null || true +fi +forge build 2>/dev/null + +# Clean stale broadcasts +rm -rf broadcast/ + +# ====================================== +# Phase 0: Deploy Multicall3 (required by wagmi useReadContracts) +# ====================================== +echo "" +echo "=== Phase 0: Deploying Multicall3 ===" +MULTICALL3_BYTECODE=$(jq -r '.deployedBytecode.object' "$L1_ROOT/out/Multicall3.sol/Multicall3.json" 2>/dev/null) +if [ -n "$MULTICALL3_BYTECODE" ] && [ "$MULTICALL3_BYTECODE" != "null" ]; then + cast rpc anvil_setCode "0xcA11bde05977b3631167028862bE2a173976CA11" "$MULTICALL3_BYTECODE" --rpc-url "$L1_RPC_URL" > /dev/null + echo " Multicall3 deployed at 0xcA11bde05977b3631167028862bE2a173976CA11" +else + echo " WARNING: Multicall3 bytecode not found — wagmi useReadContracts may fail" +fi + +# ====================================== +# Phase 1: Deploy full L1 stack + rollup v1 +# ====================================== +echo "" +echo "=== Phase 1: Deploying L1 contracts + Rollup v1 ===" +node "$L1_ROOT/scripts/forge_broadcast.js" \ + script/deploy/DeployAztecL1Contracts.s.sol:DeployAztecL1Contracts \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$DEPLOYER_PK" \ + --json > /tmp/deploy_v1.jsonl + +DEPLOY_JSON=$(head -1 /tmp/deploy_v1.jsonl | jq -r '.logs[0]' | sed 's/JSON DEPLOY RESULT: //') + +REGISTRY=$(echo "$DEPLOY_JSON" | jq -r '.registryAddress') +ROLLUP_V1=$(echo "$DEPLOY_JSON" | jq -r '.rollupAddress') +STAKING_ASSET=$(echo "$DEPLOY_JSON" | jq -r '.stakingAssetAddress') +FEE_ASSET=$(echo "$DEPLOY_JSON" | jq -r '.feeAssetAddress') +GOVERNANCE=$(echo "$DEPLOY_JSON" | jq -r '.governanceAddress') +GSE_ADDR=$(echo "$DEPLOY_JSON" | jq -r '.gseAddress') +REWARD_DIST=$(echo "$DEPLOY_JSON" | jq -r '.rewardDistributorAddress') +V1_VERSION=$(echo "$DEPLOY_JSON" | jq -r '.rollupVersion') + +echo " Registry: $REGISTRY" +echo " Rollup v1: $ROLLUP_V1 (version: $V1_VERSION)" +echo " Staking Asset: $STAKING_ASSET" +echo " Governance: $GOVERNANCE" + +# ====================================== +# Phase 2: Deploy rollup v2 with different genesis +# ====================================== +echo "" +echo "=== Phase 2: Deploying Rollup v2 ===" + +# Different genesis → different version hash +export GENESIS_ARCHIVE_ROOT="0x$(openssl rand -hex 32)" +export REGISTRY_ADDRESS="$REGISTRY" + +node "$L1_ROOT/scripts/forge_broadcast.js" \ + script/deploy/DeployRollupForUpgrade.s.sol:DeployRollupForUpgrade \ + --rpc-url "$L1_RPC_URL" \ + --private-key "$DEPLOYER_PK" \ + --json > /tmp/deploy_v2.jsonl + +DEPLOY_V2_JSON=$(head -1 /tmp/deploy_v2.jsonl | jq -r '.logs[0]' | sed 's/JSON DEPLOY RESULT: //') +ROLLUP_V2=$(echo "$DEPLOY_V2_JSON" | jq -r '.rollupAddress') +V2_VERSION=$(echo "$DEPLOY_V2_JSON" | jq -r '.rollupVersion') + +echo " Rollup v2: $ROLLUP_V2 (version: $V2_VERSION)" + +# ====================================== +# Phase 3: Register rollup v2 in Registry via anvil impersonation +# ====================================== +echo "" +echo "=== Phase 3: Registering Rollup v2 in Registry ===" + +# Governance owns the Registry after handover. Impersonate it on anvil. +cast rpc anvil_impersonateAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null +cast rpc anvil_setBalance "$GOVERNANCE" "0xDE0B6B3A7640000" --rpc-url "$L1_RPC_URL" > /dev/null + +cast send "$REGISTRY" "addRollup(address)" "$ROLLUP_V2" \ + --from "$GOVERNANCE" --rpc-url "$L1_RPC_URL" --unlocked > /dev/null + +cast rpc anvil_stopImpersonatingAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null + +# Also register v2 in GSE +cast rpc anvil_impersonateAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null +cast send "$GSE_ADDR" "addRollup(address)" "$ROLLUP_V2" \ + --from "$GOVERNANCE" --rpc-url "$L1_RPC_URL" --unlocked > /dev/null 2>&1 || true +cast rpc anvil_stopImpersonatingAccount "$GOVERNANCE" --rpc-url "$L1_RPC_URL" > /dev/null + +# Verify +NUM_VERSIONS=$(cast call "$REGISTRY" "numberOfVersions()(uint256)" --rpc-url "$L1_RPC_URL") +echo " Registered rollup versions: $NUM_VERSIONS" + +CANONICAL=$(cast call "$REGISTRY" "getCanonicalRollup()(address)" --rpc-url "$L1_RPC_URL") +echo " Canonical rollup: $CANONICAL" + +# ====================================== +# Phase 4: Deploy MockStakingRegistry +# ====================================== +echo "" +echo "=== Phase 4: Deploying MockStakingRegistry ===" +cd "$STAKING_ROOT" +forge build 2>/dev/null + +MOCK_BYTECODE=$(jq -r '.bytecode.object' contracts/out/MockStakingRegistry.sol/MockStakingRegistry.json) +ENCODED_ARGS=$(cast abi-encode "constructor(address,address,address)" "$REGISTRY" "$STAKING_ASSET" "0x0000000000000000000000000000000000000000") +DEPLOY_DATA="${MOCK_BYTECODE}${ENCODED_ARGS:2}" + +MOCK_TX=$(cast send \ + --private-key "$DEPLOYER_PK" \ + --rpc-url "$L1_RPC_URL" \ + --create "$DEPLOY_DATA" \ + --json 2>/dev/null) +MOCK_SR_ADDR=$(echo "$MOCK_TX" | jq -r '.contractAddress') +echo " MockStakingRegistry: $MOCK_SR_ADDR" + +# ====================================== +# Phase 5: Write output files +# ====================================== +echo "" +echo "=== Writing output files ===" + +cat > "$STAKING_ROOT/deploy-output.json" << EOJSON +{ + "registryAddress": "$REGISTRY", + "rollupV1Address": "$ROLLUP_V1", + "rollupV1Version": "$V1_VERSION", + "rollupV2Address": "$ROLLUP_V2", + "rollupV2Version": "$V2_VERSION", + "stakingAssetAddress": "$STAKING_ASSET", + "feeAssetAddress": "$FEE_ASSET", + "mockStakingRegistryAddress": "$MOCK_SR_ADDR", + "governanceAddress": "$GOVERNANCE", + "gseAddress": "$GSE_ADDR", + "rewardDistributorAddress": "$REWARD_DIST", + "rpcUrl": "$L1_RPC_URL" +} +EOJSON + +echo " deploy-output.json written" + +cat > "$STAKING_ROOT/contract_addresses.json" << EOJSON +{ + "atpFactory": "0x0000000000000000000000000000000000000001", + "atpFactoryAuction": "0x0000000000000000000000000000000000000002", + "atpRegistry": "0x0000000000000000000000000000000000000003", + "atpRegistryAuction": "0x0000000000000000000000000000000000000004", + "stakingRegistry": "$MOCK_SR_ADDR", + "rollupAddress": "$ROLLUP_V1", + "atpWithdrawableAndClaimableStaker": "0x0000000000000000000000000000000000000005", + "genesisSequencerSale": "0x0000000000000000000000000000000000000006", + "governanceAddress": "$GOVERNANCE", + "gseAddress": "$GSE_ADDR" +} +EOJSON + +echo " contract_addresses.json written (VITE_ROLLUP_ADDRESS = rollup v1, deliberately stale)" + +echo "" +echo "=== Deployment complete ===" +echo " Rollup v1 (old/configured): $ROLLUP_V1 (v$V1_VERSION)" +echo " Rollup v2 (canonical): $ROLLUP_V2 (v$V2_VERSION)" +echo " Registry: $REGISTRY ($NUM_VERSIONS versions)" +echo " MockStakingRegistry: $MOCK_SR_ADDR" +echo "" +echo "Next: npx tsx scripts/multi-rollup-test/seed-multi-rollup.ts" diff --git a/staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts b/staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts new file mode 100644 index 000000000..1e37ba745 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/seed-multi-rollup.ts @@ -0,0 +1,338 @@ +/** + * Seed multi-rollup test state on anvil. + * + * Reads deploy-output.json (from deploy-multi-rollup.sh), then: + * 1. Sets sequencer rewards via anvil_setStorageAt on both rollups + * 2. Sets isRewardsClaimable = true via anvil_setStorageAt + * 3. Writes contract_addresses.json (already done by deploy script, verified here) + * 4. Writes test-data.json for Chrome MCP assertions + * + * Usage: npx tsx scripts/seed-multi-rollup.ts + */ + +import { + createPublicClient, + createTestClient, + createWalletClient, + http, + keccak256, + encodeAbiParameters, + pad, + toHex, + numberToHex, + stringToHex, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { foundry } from "viem/chains"; +import { readFileSync, writeFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, "../.."); + +// Read deploy output +const deployOutput = JSON.parse( + readFileSync(resolve(ROOT, "deploy-output.json"), "utf-8") +); + +const rpcUrl = deployOutput.rpcUrl || "http://127.0.0.1:8545"; + +const publicClient = createPublicClient({ + chain: foundry, + transport: http(rpcUrl), +}); + +const testClient = createTestClient({ + chain: foundry, + mode: "anvil", + transport: http(rpcUrl), +}); + +// Anvil account 0 for minting +const DEPLOYER_PK = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const account = privateKeyToAccount(DEPLOYER_PK); +const walletClient = createWalletClient({ + account, + chain: foundry, + transport: http(rpcUrl), +}); + +const erc20Abi = parseAbi([ + "function mint(address _to, uint256 _amount) external", +]); + +// Test addresses (anvil defaults) +const COINBASE_A = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" as Address; +const COINBASE_B = "0x3C44CdDdB6a900fa2b585dd299e03d12FA4293BC" as Address; + +// ============================================================================ +// Storage layout for RewardLib (namespaced storage) +// +// Base slot: keccak256("aztec.reward.storage") +// +// RewardStorage struct layout (relative to base): +// slot 0: mapping(address => uint256) sequencerRewards +// slot 1: mapping(Epoch => EpochRewards) epochRewards +// slot 2: mapping(address => BitMap) proverClaimed +// slot 3: RewardConfig.rewardDistributor (20 bytes) + RewardConfig.sequencerBps (4 bytes) +// slot 4: RewardConfig.booster (20 bytes) + RewardConfig.checkpointReward (12 bytes) +// slot 5: CompressedTimestamp earliestRewardsClaimableTimestamp + bool isRewardsClaimable +// ============================================================================ + +// In Solidity: keccak256("aztec.reward.storage") hashes raw UTF-8 bytes, NOT abi.encode +const REWARD_STORAGE_BASE = keccak256(stringToHex("aztec.reward.storage")); + +// sequencerRewards mapping is at slot 0 relative to base +const SEQUENCER_REWARDS_SLOT = BigInt(REWARD_STORAGE_BASE); + +// isRewardsClaimable is at slot 5 relative to base +// It's packed with earliestRewardsClaimableTimestamp +const IS_CLAIMABLE_SLOT = BigInt(REWARD_STORAGE_BASE) + 5n; + +/** + * Compute the storage slot for a mapping entry: keccak256(abi.encode(key, mappingSlot)) + */ +function mappingSlot(key: Address, baseSlot: bigint): Hex { + return keccak256( + encodeAbiParameters( + [{ type: "address" }, { type: "uint256" }], + [key, baseSlot] + ) + ); +} + +async function setStorageSlot( + contract: Address, + slot: Hex, + value: Hex +): Promise { + await testClient.setStorageAt({ + address: contract, + index: slot, + value: pad(value, { size: 32 }), + }); +} + +// Rollup ABI for verification reads +const rollupAbi = [ + { + type: "function", + name: "getSequencerRewards", + inputs: [{ type: "address" }], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, + { + type: "function", + name: "isRewardsClaimable", + inputs: [], + outputs: [{ type: "bool" }], + stateMutability: "view", + }, + { + type: "function", + name: "getVersion", + inputs: [], + outputs: [{ type: "uint256" }], + stateMutability: "view", + }, +] as const; + +async function main() { + const rollupV1 = deployOutput.rollupV1Address as Address; + const rollupV2 = deployOutput.rollupV2Address as Address; + + console.log(`\nSeeding multi-rollup test state...`); + console.log(` Rollup v1: ${rollupV1}`); + console.log(` Rollup v2: ${rollupV2}`); + console.log(` RPC: ${rpcUrl}\n`); + + // ======================================== + // Set sequencer rewards on both rollups + // ======================================== + console.log("Setting sequencer rewards..."); + + // Rollup v1: coinbaseA = 5 TST, coinbaseB = 3 TST + await setStorageSlot( + rollupV1, + mappingSlot(COINBASE_A, SEQUENCER_REWARDS_SLOT), + numberToHex(5n * 10n ** 18n, { size: 32 }) + ); + await setStorageSlot( + rollupV1, + mappingSlot(COINBASE_B, SEQUENCER_REWARDS_SLOT), + numberToHex(3n * 10n ** 18n, { size: 32 }) + ); + + // Rollup v2: coinbaseA = 10 TST + await setStorageSlot( + rollupV2, + mappingSlot(COINBASE_A, SEQUENCER_REWARDS_SLOT), + numberToHex(10n * 10n ** 18n, { size: 32 }) + ); + + // ======================================== + // Set isRewardsClaimable = true on both + // ======================================== + console.log("Setting isRewardsClaimable = true..."); + + // isRewardsClaimable is a bool packed at the end of slot 5. + // The CompressedTimestamp is packed before it. We need to set the bool + // without clobbering the timestamp. Since we just want the bool to be true, + // we can set the entire slot to have the bool bit set. + // CompressedTimestamp is bytes4 (offset 0), isRewardsClaimable is bool (offset 4) + // Actually, let's read the current value and OR the bool in. + for (const rollup of [rollupV1, rollupV2]) { + const currentVal = await publicClient.getStorageAt({ + address: rollup, + slot: numberToHex(IS_CLAIMABLE_SLOT, { size: 32 }), + }); + // Set the 5th byte (offset 4) to 0x01 for true + // Storage is right-aligned for simple types, so bool at offset 4 means byte 27 from left + // Actually in Solidity packed storage, lower-offset items are at lower bytes. + // CompressedTimestamp (4 bytes, offset 0) occupies bytes 0-3 from right + // bool (1 byte, offset 4) occupies byte 4 from right + const currentBigInt = BigInt(currentVal || "0x0"); + const withClaimable = currentBigInt | (1n << 32n); // Set bit at byte offset 4 + await setStorageSlot( + rollup, + numberToHex(IS_CLAIMABLE_SLOT, { size: 32 }), + numberToHex(withClaimable, { size: 32 }) + ); + } + + // ======================================== + // Mint fee tokens to rollups (claimSequencerRewards transfers fee asset from rollup balance) + // ======================================== + const feeAsset = deployOutput.feeAssetAddress as Address | undefined; + if (feeAsset) { + console.log("Minting fee tokens to rollups (needed for claim payouts)..."); + const mintAmount = 1000n * 10n ** 18n; + for (const rollup of [rollupV1, rollupV2]) { + const hash = await walletClient.writeContract({ + address: feeAsset, + abi: erc20Abi, + functionName: "mint", + args: [rollup, mintAmount], + }); + await publicClient.waitForTransactionReceipt({ hash }); + } + console.log(" Minted 1000 FEE to each rollup"); + } else { + console.log("WARNING: feeAssetAddress not in deploy-output.json, claims may fail"); + } + + // ======================================== + // Verify + // ======================================== + console.log("\nVerifying..."); + + const v1RewardsA = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [COINBASE_A], + }); + console.log( + ` Rollup v1 rewards for coinbaseA: ${v1RewardsA} (expected: ${5n * 10n ** 18n})` + ); + if (v1RewardsA !== 5n * 10n ** 18n) { + console.error(" ERROR: Reward mismatch!"); + process.exit(1); + } + + const v1RewardsB = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [COINBASE_B], + }); + console.log( + ` Rollup v1 rewards for coinbaseB: ${v1RewardsB} (expected: ${3n * 10n ** 18n})` + ); + + const v2RewardsA = await publicClient.readContract({ + address: rollupV2, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [COINBASE_A], + }); + console.log( + ` Rollup v2 rewards for coinbaseA: ${v2RewardsA} (expected: ${10n * 10n ** 18n})` + ); + + const v1Claimable = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "isRewardsClaimable", + }); + console.log(` Rollup v1 isRewardsClaimable: ${v1Claimable} (expected: true)`); + + const v2Claimable = await publicClient.readContract({ + address: rollupV2, + abi: rollupAbi, + functionName: "isRewardsClaimable", + }); + console.log(` Rollup v2 isRewardsClaimable: ${v2Claimable} (expected: true)`); + + // Read versions for test data + const v1Version = await publicClient.readContract({ + address: rollupV1, + abi: rollupAbi, + functionName: "getVersion", + }); + const v2Version = await publicClient.readContract({ + address: rollupV2, + abi: rollupAbi, + functionName: "getVersion", + }); + + // ======================================== + // Write test-data.json + // ======================================== + const testData = { + ...deployOutput, + rollupV1Version: v1Version.toString(), + rollupV2Version: v2Version.toString(), + coinbaseA: COINBASE_A, + coinbaseB: COINBASE_B, + rollupV1Rewards: { + [COINBASE_A]: (5n * 10n ** 18n).toString(), + [COINBASE_B]: (3n * 10n ** 18n).toString(), + }, + rollupV2Rewards: { + [COINBASE_A]: (10n * 10n ** 18n).toString(), + }, + rewardsClaimable: { + [rollupV1]: v1Claimable, + [rollupV2]: v2Claimable, + }, + }; + + writeFileSync( + resolve(ROOT, "test-data.json"), + JSON.stringify(testData, null, 2) + ); + console.log("\nWrote test-data.json"); + + // Print localStorage setup for the browser + const DEPLOYER_ADDR = "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"; + const lsKey = `rewards_coinbase_addresses_${DEPLOYER_ADDR}`; + const lsValue = JSON.stringify([COINBASE_A.toLowerCase()]); + console.log("\nSeeding complete!"); + console.log("\n--- Browser setup (paste in DevTools console) ---"); + console.log( + `localStorage.setItem('${lsKey}', '${lsValue}'); location.reload();` + ); + console.log("---"); +} + +main().catch((err) => { + console.error(`\nError: ${err.message}\n`); + process.exit(1); +}); From db0bac740f653f22e3d39520d09144f4f8cc5efd Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 10:28:03 +0200 Subject: [PATCH 06/18] fix: show Positions Overview when user has saved coinbase addresses The Positions Overview section (with Claimable Rewards) was gated only on having staked positions from the indexer. Users who added coinbase addresses for self-stake reward tracking but had no ATP staking events wouldn't see their rewards. Now also checks for saved coinbase addresses. --- staking-dashboard/src/pages/ATP/MyPositionPage.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx index 22b2f7fa9..e1d911cfb 100644 --- a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx +++ b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx @@ -17,6 +17,7 @@ import { useERC20Balance } from "@/hooks/erc20" import { useActivationThresholdFormatted } from "@/hooks/rollup/useActivationThresholdFormatted" import { useATP } from "@/hooks/useATP" import { useAggregatedStakingData } from "@/hooks/atp/useAggregatedStakingData" +import { useCoinbaseAddresses } from "@/hooks/rewards" /** * My Position page for ATP (Aztec Token Positions) @@ -40,13 +41,16 @@ export default function MyPositionPage() { useActivationThresholdFormatted() const { atpData } = useATP() const { totalErc20Staked, directStakeBreakdown, delegationBreakdown, erc20DelegationBreakdown, erc20DirectStakeBreakdown, refetch } = useAggregatedStakingData() + const { coinbaseAddresses } = useCoinbaseAddresses() // Check if user has any staked positions (ATP vaults or ERC20 wallet stakes) + // or saved coinbase addresses (for self-stake rewards tracking) const hasStakedPositions = directStakeBreakdown.length > 0 || delegationBreakdown.length > 0 || erc20DelegationBreakdown.length > 0 || - erc20DirectStakeBreakdown.length > 0 + erc20DirectStakeBreakdown.length > 0 || + (coinbaseAddresses && coinbaseAddresses.length > 0) // Calculate stakeable amount (rounded down to nearest activation threshold multiple) const walletStakeableAmount = useMemo(() => { From 05ae72aeda9247d7cafb55ea88108891416a918c Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 10:35:03 +0200 Subject: [PATCH 07/18] =?UTF-8?q?fix:=20claim=20engine=20stalls=20between?= =?UTF-8?q?=20tasks=20=E2=80=94=20effect=20cleanup=20kills=20advance=20tim?= =?UTF-8?q?eout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The completion effect created a setTimeout to advance to the next task and returned a cleanup function to clear it. But setTasks() inside the same effect changes `tasks` (a dependency), triggering an effect re-run whose cleanup clears the timeout before it fires. The engine permanently stalls after the first task. Fix: use a ref-based timeout (advanceTimeoutRef) that persists across effect re-runs. The handledCompletionRef guard prevents re-processing, so the effect re-running is harmless. Also store claim hooks in refs to remove hook identity from trigger effect deps — prevents premature firing during reset cycles. --- .../src/hooks/rewards/useClaimAllRewards.ts | 56 ++++++++++++------- 1 file changed, 36 insertions(+), 20 deletions(-) diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index 42b3ef95e..d5358bb7a 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -80,6 +80,8 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { // Track if we were cancelled const cancelledRef = useRef(false) + // Ref-based timeout for advancing between tasks — survives effect re-runs + const advanceTimeoutRef = useRef | null>(null) // Get current task const currentTask = currentTaskIndex !== null ? tasks[currentTaskIndex] : null @@ -199,6 +201,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { */ const cancelClaiming = useCallback(() => { cancelledRef.current = true + if (advanceTimeoutRef.current) { clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = null } setIsProcessing(false) setCurrentTaskIndex(null) setHasTriggeredClaim(false) @@ -234,6 +237,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { */ const reset = useCallback(() => { cancelledRef.current = false + if (advanceTimeoutRef.current) { clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = null } setTasks([]) setCurrentTaskIndex(null) setIsProcessing(false) @@ -243,6 +247,13 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { coinbaseClaimHook.reset() }, [delegationClaimHook, coinbaseClaimHook]) + // Keep stable refs to claim functions so the trigger effect doesn't re-fire + // when the hook objects change identity (e.g. after reset()). + const delegationClaimRef = useRef(delegationClaimHook) + delegationClaimRef.current = delegationClaimHook + const coinbaseClaimRef = useRef(coinbaseClaimHook) + coinbaseClaimRef.current = coinbaseClaimHook + /** * Start claim for current task when ready */ @@ -261,15 +272,15 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { )) setHasTriggeredClaim(true) - // Start the appropriate claim + // Start the appropriate claim (read from refs to avoid stale closures) if (task.type === 'delegation') { - delegationClaimHook.claim() + delegationClaimRef.current.claim() } else if (task.type === 'coinbase' && task.coinbaseAddress) { // Pass the per-task rollup address so the claim lands on the correct rollup contract // (the hook itself defaults to the configured rollup when no override is given). - coinbaseClaimHook.claimRewards(task.coinbaseAddress, task.rollupAddress) + coinbaseClaimRef.current.claimRewards(task.coinbaseAddress, task.rollupAddress) } - }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances, delegationClaimHook, coinbaseClaimHook]) + }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances]) /** * Update sub-step for delegation tasks @@ -285,11 +296,16 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { } }, [delegationClaimHook.claimStep, currentTaskIndex, currentTask?.type, isProcessing]) + // Track whether we've already handled the current task's completion to avoid + // re-processing when hook state oscillates during reset. + const handledCompletionRef = useRef(null) + /** * Handle task completion and move to next */ useEffect(() => { if (!isProcessing || currentTaskIndex === null || !hasTriggeredClaim || cancelledRef.current) return + if (handledCompletionRef.current === currentTaskIndex) return const task = tasks[currentTaskIndex] if (!task || task.status !== 'processing') return @@ -304,32 +320,36 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { } if (isComplete) { + // Guard against re-entry + handledCompletionRef.current = currentTaskIndex + // Mark task as completed setTasks(prev => prev.map((t, i) => i === currentTaskIndex ? { ...t, status: 'completed' as const } : t )) - // Reset hooks for next task - delegationClaimHook.reset() - coinbaseClaimHook.reset() - - // Small delay before moving to next task - const timeoutId = setTimeout(() => { + // Reset hooks and advance to next task after a small delay. + // Use a ref-based timeout so it survives effect re-runs — the setTasks() + // above changes `tasks`, which re-triggers this effect. A cleanup return + // would kill the timeout before it fires. + if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current) + advanceTimeoutRef.current = setTimeout(() => { + advanceTimeoutRef.current = null if (cancelledRef.current) return - // Move to next task + delegationClaimRef.current.reset() + coinbaseClaimRef.current.reset() + const nextIndex = currentTaskIndex + 1 if (nextIndex < tasks.length) { setCurrentTaskIndex(nextIndex) setHasTriggeredClaim(false) + handledCompletionRef.current = null } else { - // All done setIsProcessing(false) setCurrentTaskIndex(null) } }, 500) - - return () => clearTimeout(timeoutId) } }, [ isProcessing, @@ -339,8 +359,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { delegationClaimHook.isSuccess, delegationClaimHook.claimStep, coinbaseClaimHook.isSuccess, - delegationClaimHook, - coinbaseClaimHook ]) /** @@ -372,8 +390,8 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { setHasTriggeredClaim(false) // Reset hooks - delegationClaimHook.reset() - coinbaseClaimHook.reset() + delegationClaimRef.current.reset() + coinbaseClaimRef.current.reset() } }, [ isProcessing, @@ -383,8 +401,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { delegationClaimHook.error, coinbaseClaimHook.isError, coinbaseClaimHook.error, - delegationClaimHook, - coinbaseClaimHook ]) // Calculate progress From 10c3c4c6386b4d8e4c45c2c760d43acd44bf965e Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 10:52:58 +0200 Subject: [PATCH 08/18] fix: claim modal closes immediately after success due to parent unmount onSuccess (which refetches rewards) was called as soon as isSuccess became true. The refetch zeroed out the rewards, causing hasStakedPositions to become false, unmounting the parent and the modal with it. Move onSuccess to handleDone so it fires when the user manually dismisses the success screen, not when the claims complete. --- .../ATPStakingOverview/ATPStakingOverview.tsx | 9 +++++++-- staking-dashboard/src/pages/ATP/MyPositionPage.tsx | 11 ++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx index f036b4d82..78cc3d1f0 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx @@ -125,8 +125,13 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv } }, []) - // Show skeleton while loading or if required data is missing - if (isLoadingAggregated || isLoadingStakeable || isLoadingTokenDetails || decimals === undefined || symbol === undefined || activationThreshold === undefined) { + // Only show skeleton on initial load — during refetches keep the existing tree + // mounted so modals (like ClaimAllRewardsModal) aren't destroyed mid-flow. + const hasLoadedOnce = useRef(false) + if (!isLoadingAggregated && !isLoadingStakeable && !isLoadingTokenDetails) { + hasLoadedOnce.current = true + } + if (!hasLoadedOnce.current && (isLoadingAggregated || isLoadingStakeable || isLoadingTokenDetails || decimals === undefined || symbol === undefined || activationThreshold === undefined)) { return } diff --git a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx index e1d911cfb..8452bc972 100644 --- a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx +++ b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx @@ -1,4 +1,4 @@ -import { useState, useMemo } from "react" +import { useState, useMemo, useRef } from "react" import { useAccount } from "wagmi" import { WalletConnectGuard } from "@/components/WalletConnectGuard" import { PageHeader } from "@/components/PageHeader" @@ -44,13 +44,18 @@ export default function MyPositionPage() { const { coinbaseAddresses } = useCoinbaseAddresses() // Check if user has any staked positions (ATP vaults or ERC20 wallet stakes) - // or saved coinbase addresses (for self-stake rewards tracking) - const hasStakedPositions = + // or saved coinbase addresses (for self-stake rewards tracking). + // Once true, stay true for the session — prevents unmounting the Positions + // Overview (and its claim modal) when a successful claim zeros out rewards. + const hasPositionsNow = directStakeBreakdown.length > 0 || delegationBreakdown.length > 0 || erc20DelegationBreakdown.length > 0 || erc20DirectStakeBreakdown.length > 0 || (coinbaseAddresses && coinbaseAddresses.length > 0) + const hadPositionsRef = useRef(false) + if (hasPositionsNow) hadPositionsRef.current = true + const hasStakedPositions = hadPositionsRef.current // Calculate stakeable amount (rounded down to nearest activation threshold multiple) const walletStakeableAmount = useMemo(() => { From 3b01d9de7c258173779c489ae56fdbaaaab86255 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 11:05:45 +0200 Subject: [PATCH 09/18] fix: per-row claim button shows spinner on all rows instead of just the active one The CoinbaseAddressList shared a single useClaimCoinbaseRewards hook instance across all rows. When one row was claiming, isPending/isConfirming applied to every row's button, making them all show "Confirming..." simultaneously. Track which row is actively claiming via a claimingRowKey state. Only the active row shows the spinner; other rows are disabled but show "Claim Rewards". --- .../RewardsManagement/CoinbaseAddressList.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx index 8d492683a..64ce8a943 100644 --- a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx +++ b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react" +import { useMemo, useState } from "react" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { formatTokenAmountFull } from "@/utils/atpFormatters" @@ -45,6 +45,11 @@ export const CoinbaseAddressList = ({ const { removeCoinbaseAddress, isPending: isRemoving } = useRemoveCoinbaseAddress() // Hook is instantiated once and the per-row rollup is passed as the `claimRewards` override. const claimRewards = useClaimCoinbaseRewards() + // Track which row initiated the claim. Combined with the hook's reactive state + // (isPending/isConfirming) to show the spinner only on the active row. + // Not cleared explicitly — stale value is harmless because it's AND-ed with isClaiming. + const [claimingRowKey, setClaimingRowKey] = useState(null) + const isClaiming = claimRewards.isPending || claimRewards.isConfirming // Multicall isRewardsClaimable() across every rollup represented in the breakdown so each // row's claim button reflects its own rollup's state, not just the configured rollup. @@ -59,9 +64,9 @@ export const CoinbaseAddressList = ({ onRefetch?.() } - const handleClaim = async (address: Address, rollupAddress: Address) => { - await claimRewards.claimRewards(address, rollupAddress) - onRefetch?.() + const handleClaim = (address: Address, rollupAddress: Address) => { + setClaimingRowKey(`${address}-${rollupAddress}`) + claimRewards.claimRewards(address, rollupAddress) } if (isLoading) { @@ -137,15 +142,18 @@ export const CoinbaseAddressList = ({
{/* Claim Button */} - {item.rewards > 0n && ( + {item.rewards > 0n && (() => { + const rowKey = `${item.address}-${item.rollupAddress}` + const isThisRowClaiming = claimingRowKey === rowKey && isClaiming + return (
{rowIsClaimable ? (
)} - )} + ) + })()} ) })} From f170e103196969c112d36db28ce1a5810fc9569a Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 12:58:46 +0200 Subject: [PATCH 10/18] test: add provider registration to MockStakingRegistry and seed script Extend MockStakingRegistry with registerProvider() that emits ProviderRegistered events. The Ponder indexer watches for these events and populates the /api/providers endpoint. Add seed-providers.ts that registers the first 10 providers from the provider metadata (providers/*.json) on-chain so the providers list is populated during integration testing. --- .../contracts/mocks/MockStakingRegistry.sol | 36 ++++++- .../multi-rollup-test/seed-providers.ts | 94 +++++++++++++++++++ 2 files changed, 128 insertions(+), 2 deletions(-) create mode 100644 staking-dashboard/scripts/multi-rollup-test/seed-providers.ts diff --git a/staking-dashboard/contracts/mocks/MockStakingRegistry.sol b/staking-dashboard/contracts/mocks/MockStakingRegistry.sol index ab19d9501..fe06c49f8 100644 --- a/staking-dashboard/contracts/mocks/MockStakingRegistry.sol +++ b/staking-dashboard/contracts/mocks/MockStakingRegistry.sol @@ -1,17 +1,49 @@ // SPDX-License-Identifier: UNLICENSED pragma solidity ^0.8.27; -/// @notice Minimal mock — the real StakingRegistry isn't in aztec-packages. -/// Returns ROLLUP_REGISTRY() and STAKING_ASSET() for frontend discovery. +/// @notice Minimal mock of the staking dashboard's StakingRegistry. +/// Implements the getters the frontend reads (ROLLUP_REGISTRY, STAKING_ASSET, +/// PULL_SPLIT_FACTORY) plus provider registration so the Ponder indexer +/// can index ProviderRegistered events and populate the providers list. contract MockStakingRegistry { address public immutable ROLLUP_REGISTRY; address public immutable STAKING_ASSET; address public immutable PULL_SPLIT_FACTORY; uint256 public nextProviderIdentifier; + struct ProviderConfig { + address providerAdmin; + uint16 providerTakeRate; + address providerRewardsRecipient; + } + + mapping(uint256 => ProviderConfig) public providerConfigurations; + + event ProviderRegistered( + uint256 indexed providerIdentifier, + address indexed providerAdmin, + uint16 indexed providerTakeRate + ); + constructor(address _rollupRegistry, address _stakingAsset, address _pullSplitFactory) { ROLLUP_REGISTRY = _rollupRegistry; STAKING_ASSET = _stakingAsset; PULL_SPLIT_FACTORY = _pullSplitFactory; } + + /// @notice Register a test provider. Emits ProviderRegistered for Ponder to index. + function registerProvider( + uint256 _providerIdentifier, + address _providerAdmin, + uint16 _takeRate, + address _rewardsRecipient + ) external { + providerConfigurations[_providerIdentifier] = ProviderConfig({ + providerAdmin: _providerAdmin, + providerTakeRate: _takeRate, + providerRewardsRecipient: _rewardsRecipient + }); + nextProviderIdentifier = _providerIdentifier + 1; + emit ProviderRegistered(_providerIdentifier, _providerAdmin, _takeRate); + } } diff --git a/staking-dashboard/scripts/multi-rollup-test/seed-providers.ts b/staking-dashboard/scripts/multi-rollup-test/seed-providers.ts new file mode 100644 index 000000000..b366ed547 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/seed-providers.ts @@ -0,0 +1,94 @@ +/** + * Seed test providers by calling registerProvider() on the MockStakingRegistry. + * This emits ProviderRegistered events that Ponder indexes, populating the + * /api/providers endpoint. + * + * Usage: npx tsx scripts/multi-rollup-test/seed-providers.ts + * + * Prerequisites: + * - Anvil running with deployed MockStakingRegistry + * - deploy-output.json exists (from deploy-multi-rollup.sh) + * - Indexer running (will pick up events in real-time) + */ + +import { + createPublicClient, + createWalletClient, + http, + parseAbi, + type Address, + type Hex, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { foundry } from "viem/chains"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, "../.."); +const INDEXER_ROOT = resolve(ROOT, "../atp-indexer"); + +// Read deploy output +const deployOutput = JSON.parse( + readFileSync(resolve(ROOT, "deploy-output.json"), "utf-8") +); +const rpcUrl = deployOutput.rpcUrl || "http://127.0.0.1:8545"; +const mockSR = deployOutput.mockStakingRegistryAddress as Address; + +// Read provider metadata to know which IDs to register +const providersJson = JSON.parse( + readFileSync(resolve(INDEXER_ROOT, "src/api/data/providers.json"), "utf-8") +) as Array<{ providerId: number; providerName: string }>; + +const DEPLOYER_PK = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex; +const account = privateKeyToAccount(DEPLOYER_PK); + +const publicClient = createPublicClient({ + chain: foundry, + transport: http(rpcUrl), +}); + +const walletClient = createWalletClient({ + account, + chain: foundry, + transport: http(rpcUrl), +}); + +const registerAbi = parseAbi([ + "function registerProvider(uint256 _providerIdentifier, address _providerAdmin, uint16 _takeRate, address _rewardsRecipient) external", +]); + +async function main() { + // Register first 10 providers from metadata + const toRegister = providersJson.slice(0, 10); + + console.log(`\nRegistering ${toRegister.length} test providers on MockStakingRegistry...`); + console.log(` MockStakingRegistry: ${mockSR}`); + console.log(` RPC: ${rpcUrl}\n`); + + for (const p of toRegister) { + const hash = await walletClient.writeContract({ + address: mockSR, + abi: registerAbi, + functionName: "registerProvider", + args: [ + BigInt(p.providerId), + account.address, // providerAdmin + 500, // 5% take rate (basis points / 100) + account.address, // rewardsRecipient + ], + }); + await publicClient.waitForTransactionReceipt({ hash }); + console.log(` Registered provider ${p.providerId} (${p.providerName})`); + } + + console.log(`\nDone! The indexer should pick up ProviderRegistered events automatically.`); + console.log(`Check: curl http://localhost:42068/api/providers | jq '.providers | length'`); +} + +main().catch((err) => { + console.error(`\nError: ${err.message}\n`); + process.exit(1); +}); From 88e20ab2f0717bd9e04d144a83025f18be4d0edc Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 13:23:22 +0200 Subject: [PATCH 11/18] fix: handledCompletionRef not reset + self-stake modal closes after first claim Two bugs fixed: 1. handledCompletionRef stuck after single-task claim: the ref was set during completion handling but never cleared in the "all done" branch, startClaiming, cancelClaiming, or reset. After a successful claim, re-opening the modal would stall forever because the completion effect returned early on the guard check. Now reset in all exit paths. 2. ClaimSelfStakeRewardsModal auto-closes after first per-rollup claim: the success effect called onClose(), dismissing the modal and clearing the coinbase input. Users with rewards on multiple rollups had to reopen and re-enter the address for each rollup. Now resets the claim hook and re-checks rewards instead, so the remaining rollup rows stay visible. --- .../ClaimSelfStakeRewardsModal.tsx | 13 ++++++++----- .../src/hooks/rewards/useClaimAllRewards.ts | 4 ++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 57d360d6a..877c4df9e 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -103,16 +103,19 @@ export const ClaimSelfStakeRewardsModal = ({ claimRewards(coinbaseAddress as Address, rollupAddress) } - // Handle success + // Handle success — reset the claim hook so the user can claim remaining rollups + // without closing the modal. The rewards breakdown refetches automatically. useEffect(() => { if (isSuccess) { onSuccess?.() - onClose() - setCoinbaseAddress("") - setHasCheckedRewards(false) reset() + // Re-trigger the rewards check for the same coinbase so the breakdown refreshes + // and the claimed row disappears while remaining rows stay visible. + if (coinbaseAddress) { + debouncedCheckRewards(coinbaseAddress) + } } - }, [isSuccess, onSuccess, onClose, reset]) + }, [isSuccess, onSuccess, reset]) // Handle errors useEffect(() => { diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index d5358bb7a..c1d9ae50d 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -190,6 +190,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { setIsProcessing(true) setError(null) setHasTriggeredClaim(false) + handledCompletionRef.current = null // Reset hooks delegationClaimHook.reset() @@ -202,6 +203,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { const cancelClaiming = useCallback(() => { cancelledRef.current = true if (advanceTimeoutRef.current) { clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = null } + handledCompletionRef.current = null setIsProcessing(false) setCurrentTaskIndex(null) setHasTriggeredClaim(false) @@ -238,6 +240,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { const reset = useCallback(() => { cancelledRef.current = false if (advanceTimeoutRef.current) { clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = null } + handledCompletionRef.current = null setTasks([]) setCurrentTaskIndex(null) setIsProcessing(false) @@ -348,6 +351,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { } else { setIsProcessing(false) setCurrentTaskIndex(null) + handledCompletionRef.current = null } }, 500) } From 62aec6277d2bd55917faee3a80f474d22a8bb325 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 13:50:13 +0200 Subject: [PATCH 12/18] fix: claim-all skips locked tasks instead of blocking + zero-balance addresses stay visible P1: The claim engine previously stopped on the first error (e.g. a locked rollup), never reaching later claimable tasks. Now marks the failed task as errored and advances to the next one. Progress counts both completed and failed tasks. isSuccess fires when all tasks are processed. P2: useCoinbaseRewardsAcrossRollups now returns both allCoinbaseBreakdown (including zero-balance rows) and coinbaseBreakdown (non-zero only). The management UI uses allCoinbaseBreakdown so saved addresses with no rewards remain visible and removable. Claim UIs continue using the filtered version. --- .../ManageRewardsAddressesModal.tsx | 8 ++-- .../src/hooks/rewards/useClaimAllRewards.ts | 44 +++++++++++++------ .../useCoinbaseRewardsAcrossRollups.ts | 16 +++++-- 3 files changed, 48 insertions(+), 20 deletions(-) diff --git a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx index 25ef5dcb9..74604b5c7 100644 --- a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx +++ b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx @@ -50,9 +50,11 @@ export const ManageRewardsAddressesModal = ({ const addCoinbaseAddress = useAddCoinbaseAddress() - // Get rewards for all coinbase addresses + // Get rewards for all coinbase addresses. + // Use allCoinbaseBreakdown for the management UI so zero-balance addresses + // remain visible and removable. const { - coinbaseBreakdown, + allCoinbaseBreakdown, isLoading: isLoadingCoinbaseRewards, refetch: refetchCoinbaseRewards } = useMultipleCoinbaseRewards(coinbaseAddresses as Address[]) @@ -206,7 +208,7 @@ export const ManageRewardsAddressesModal = ({ Your Coinbase Addresses { } if (taskError) { - // Mark task as failed + // Mark task as failed and continue to next task instead of stopping. + // In a multi-rollup setup, some rollups may have locked rewards while + // others are claimable — stopping on the first failure would strand + // the claimable ones. setTasks(prev => prev.map((t, i) => i === currentTaskIndex ? { ...t, status: 'error' as const, error: taskError } : t )) - - // Stop processing on error - setIsProcessing(false) setError(taskError) - setHasTriggeredClaim(false) - // Reset hooks - delegationClaimRef.current.reset() - coinbaseClaimRef.current.reset() + // Reset hooks and advance to next task + if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current) + advanceTimeoutRef.current = setTimeout(() => { + advanceTimeoutRef.current = null + if (cancelledRef.current) return + + delegationClaimRef.current.reset() + coinbaseClaimRef.current.reset() + + const nextIndex = currentTaskIndex + 1 + if (nextIndex < tasks.length) { + setCurrentTaskIndex(nextIndex) + setHasTriggeredClaim(false) + handledCompletionRef.current = null + } else { + setIsProcessing(false) + setCurrentTaskIndex(null) + handledCompletionRef.current = null + } + }, 500) } }, [ isProcessing, @@ -407,16 +423,18 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { coinbaseClaimHook.error, ]) - // Calculate progress + // Calculate progress (both completed and failed count as "done") const completedTasks = tasks.filter(t => t.status === 'completed') const failedTasks = tasks.filter(t => t.status === 'error') + const doneTasks = completedTasks.length + failedTasks.length const progressPercent = tasks.length > 0 - ? Math.round((completedTasks.length / tasks.length) * 100) + ? Math.round((doneTasks / tasks.length) * 100) : 0 - // Determine overall success/error state - const isSuccess = tasks.length > 0 && completedTasks.length === tasks.length - const isError = failedTasks.length > 0 + // Determine overall success/error state — isSuccess means all tasks processed + // (some may have failed). isError means at least one failed. + const isSuccess = tasks.length > 0 && !isProcessing && doneTasks === tasks.length && completedTasks.length > 0 + const isError = !isProcessing && failedTasks.length > 0 return { startClaiming, diff --git a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts index 4e4b6fc91..a13862440 100644 --- a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts +++ b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts @@ -61,9 +61,9 @@ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { }, }) - // Expand the results into one CoinbaseBreakdown per (coinbase, rollup) with non-zero balance. - // Zero balances are filtered so the rewards UI doesn't render empty rows for inactive rollups. - const coinbaseBreakdown = useMemo(() => { + // Expand the results into one CoinbaseBreakdown per (coinbase, rollup) pair. + // Includes zero-balance rows so saved addresses remain visible in the management UI. + const allCoinbaseBreakdown = useMemo(() => { if (!data || pairs.length === 0) return [] const out: CoinbaseBreakdown[] = [] for (let i = 0; i < pairs.length; i++) { @@ -71,7 +71,6 @@ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { const result = data[i] const rewards = result?.status === "success" ? ((result.result as bigint | undefined) ?? 0n) : 0n - if (rewards <= 0n) continue out.push({ address: pair.coinbase, rewards, @@ -83,12 +82,21 @@ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { return out }, [data, pairs]) + // Filtered to non-zero for claim UIs (Claim All, per-rollup claim buttons) + const coinbaseBreakdown = useMemo( + () => allCoinbaseBreakdown.filter((item) => item.rewards > 0n), + [allCoinbaseBreakdown], + ) + const totalCoinbaseRewards = useMemo( () => coinbaseBreakdown.reduce((total, item) => total + item.rewards, 0n), [coinbaseBreakdown], ) return { + /** All (coinbase, rollup) pairs including zero-balance — for management UI */ + allCoinbaseBreakdown, + /** Only non-zero balances — for claim UIs */ coinbaseBreakdown, totalCoinbaseRewards, isLoading: isLoading || isLoadingRegistry, From 39c41726a0f38d4a740110c4816c7b1280e30854 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 13:58:48 +0200 Subject: [PATCH 13/18] fix: type errors + mixed-result claim-all shown as full success MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0: ATPStakingOverview — the hasLoadedOnce guard no longer narrowed decimals/symbol/activationThreshold types. Add a second runtime guard for type narrowing after the initial-load shortcut. P0: ClaimSelfStakeRewardsModal — debouncedCheckRewards takes 0 args but was called with coinbaseAddress. Remove the argument. P1: ClaimAllRewardsModal — mixed-result runs (some succeeded, some failed) transitioned to the success phase, hiding the retry UI. Now only transitions to success when isSuccess && !isError. All three pass tsc --noEmit. --- .../ATPStakingOverview/ATPStakingOverview.tsx | 45 ++++++++++--------- .../ClaimAllRewardsModal.tsx | 8 ++-- .../ClaimSelfStakeRewardsModal.tsx | 4 +- 3 files changed, 30 insertions(+), 27 deletions(-) diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx index 78cc3d1f0..e494439ca 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx @@ -27,7 +27,7 @@ interface ATPStakingOverviewProps { * Shows staked positions, stakeable amounts, and claimable rewards */ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOverviewProps) => { - const { symbol, decimals, isLoading: isLoadingTokenDetails } = useStakingAssetTokenDetails() + const { symbol, decimals } = useStakingAssetTokenDetails() const { totalStaked, @@ -42,7 +42,6 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv delegationBreakdown, erc20DelegationBreakdown, erc20DirectStakeBreakdown, - isLoading: isLoadingAggregated, refetch: refetchAggregatedData, } = useAggregatedStakingData() @@ -55,7 +54,6 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv totalValidatorCount, totalStakeableAmount, activationThreshold, - isLoading: isLoadingStakeable, } = useMultipleStakeableAmounts(atpData) // Check if rewards are claimable @@ -125,15 +123,18 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv } }, []) - // Only show skeleton on initial load — during refetches keep the existing tree - // mounted so modals (like ClaimAllRewardsModal) aren't destroyed mid-flow. - const hasLoadedOnce = useRef(false) - if (!isLoadingAggregated && !isLoadingStakeable && !isLoadingTokenDetails) { - hasLoadedOnce.current = true + // Cache required values once loaded. During refetches tanstack-query preserves + // previous data, but TypeScript can't prove these are never undefined after first + // load. A ref captures the last known values, provides type narrowing, AND prevents + // the skeleton from flashing during refetches (which would unmount modals mid-flow). + const resolvedRef = useRef<{ decimals: number; symbol: string; activationThreshold: bigint } | null>(null) + if (decimals !== undefined && symbol !== undefined && activationThreshold !== undefined) { + resolvedRef.current = { decimals, symbol, activationThreshold } } - if (!hasLoadedOnce.current && (isLoadingAggregated || isLoadingStakeable || isLoadingTokenDetails || decimals === undefined || symbol === undefined || activationThreshold === undefined)) { + if (!resolvedRef.current) { return } + const { decimals: resolvedDecimals, symbol: resolvedSymbol, activationThreshold: resolvedActivationThreshold } = resolvedRef.current return ( @@ -149,8 +150,8 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv totalClaimable={totalClaimable} isExpanded={isTotalAllocationExpanded} onToggle={() => setIsTotalAllocationExpanded(!isTotalAllocationExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} /> {/* Total Staked Section */} @@ -161,21 +162,21 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv totalDelegated={combinedTotalDelegated} isExpanded={isTotalStakedExpanded} onToggle={() => setIsTotalStakedExpanded(!isTotalStakedExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} /> {/* Stakeable Amount Section - includes ATP stakeable + ERC20 wallet balance (rounded) */} setIsStakeableExpanded(!isStakeableExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} /> {/* Total Rewards Section */} @@ -187,8 +188,8 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv isRewardsClaimable={isRewardsClaimable} isExpanded={isTotalRewardsExpanded} onToggle={() => setIsTotalRewardsExpanded(!isTotalRewardsExpanded)} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} delegationBreakdown={delegationBreakdown} coinbaseBreakdown={coinbaseBreakdown} onClaimSuccess={() => { @@ -207,8 +208,8 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv erc20DelegationBreakdown={erc20DelegationBreakdown} erc20DirectStakeBreakdown={erc20DirectStakeBreakdown} atpData={atpData} - decimals={decimals} - symbol={symbol} + decimals={resolvedDecimals} + symbol={resolvedSymbol} onATPClick={(atp) => setSelectedATP(atp)} /> )} diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index ae3555689..4901d8af5 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -59,13 +59,15 @@ export const ClaimAllRewardsModal = ({ } }, [claimAllRewards.isProcessing, phase]) - // Transition to success when all done + // Transition to success when all done — only if no tasks failed. + // Mixed results (some succeeded, some failed) stay on the progress phase + // where the error/retry UI is visible. useEffect(() => { - if (claimAllRewards.isSuccess && phase === 'progress') { + if (claimAllRewards.isSuccess && !claimAllRewards.isError && phase === 'progress') { setPhase('success') onSuccess?.() } - }, [claimAllRewards.isSuccess, phase, onSuccess]) + }, [claimAllRewards.isSuccess, claimAllRewards.isError, phase, onSuccess]) const handleClose = () => { if (claimAllRewards.isProcessing) { diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 877c4df9e..47138439a 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -109,10 +109,10 @@ export const ClaimSelfStakeRewardsModal = ({ if (isSuccess) { onSuccess?.() reset() - // Re-trigger the rewards check for the same coinbase so the breakdown refreshes + // Re-trigger the rewards check so the breakdown refreshes // and the claimed row disappears while remaining rows stay visible. if (coinbaseAddress) { - debouncedCheckRewards(coinbaseAddress) + debouncedCheckRewards() } } }, [isSuccess, onSuccess, reset]) From f114a6e1d5db2e11f18c15ed16cfa73130390e8f Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 14:11:55 +0200 Subject: [PATCH 14/18] chore: fix eslint react-hooks/exhaustive-deps warnings in claim engine - ClaimAllRewardsModal: destructure reset to stabilize the dependency - ClaimSelfStakeRewardsModal: add debouncedCheckRewards to deps with suppress - useClaimAllRewards: suppress derived-dep warning for currentTask --- .../components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx | 5 +++-- .../ClaimSelfStakeRewardsModal.tsx | 3 ++- staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts | 1 + 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index 4901d8af5..d3b9a9450 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -45,12 +45,13 @@ export const ClaimAllRewardsModal = ({ const claimAllRewards = useClaimAllRewards() // Reset phase when modal opens + const { reset: resetClaims } = claimAllRewards useEffect(() => { if (isOpen) { setPhase('summary') - claimAllRewards.reset() + resetClaims() } - }, [isOpen]) + }, [isOpen, resetClaims]) // Transition to progress when claiming starts useEffect(() => { diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 47138439a..b75eedeec 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -115,7 +115,8 @@ export const ClaimSelfStakeRewardsModal = ({ debouncedCheckRewards() } } - }, [isSuccess, onSuccess, reset]) + // eslint-disable-next-line react-hooks/exhaustive-deps -- coinbaseAddress is read but not a reactive trigger + }, [isSuccess, onSuccess, reset, debouncedCheckRewards]) // Handle errors useEffect(() => { diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index 6d09985d6..f2cc90f67 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -297,6 +297,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { i === currentTaskIndex ? { ...t, currentSubStep: subStep as 'claiming' | 'distributing' | 'withdrawing' } : t )) } + // eslint-disable-next-line react-hooks/exhaustive-deps -- currentTask derived from currentTaskIndex }, [delegationClaimHook.claimStep, currentTaskIndex, currentTask?.type, isProcessing]) // Track whether we've already handled the current task's completion to avoid From 6929a11c3eb83b3a96f6650b908d36989e0623e8 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 15:12:15 +0200 Subject: [PATCH 15/18] refactor: use validateAddress from utils instead of hand-rolled check Replace `coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x')` with `validateAddress(coinbaseAddress)` which uses viem's `isAddress` for proper hex and checksum validation. --- .../ClaimSelfStakeRewardsModal.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index b75eedeec..5ef518c6b 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -3,6 +3,7 @@ import { createPortal } from "react-dom" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { formatTokenAmount } from "@/utils/atpFormatters" +import { validateAddress } from "@/utils/validateAddress" import { debounce } from "@/utils/debounce" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" @@ -43,7 +44,7 @@ export const ClaimSelfStakeRewardsModal = ({ const [hasCheckedRewards, setHasCheckedRewards] = useState(false) const [isDebouncing, setIsDebouncing] = useState(false) - const isValidAddress = coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x') + const isValidAddress = validateAddress(coinbaseAddress) // Empty array while typing prevents firing reads against an invalid coinbase. const coinbasesForQuery = useMemo( () => (isValidAddress ? [coinbaseAddress as Address] : []), @@ -88,7 +89,7 @@ export const ClaimSelfStakeRewardsModal = ({ // Auto-check rewards when valid address is entered (debounced) useEffect(() => { - if (coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x')) { + if (validateAddress(coinbaseAddress)) { setIsDebouncing(true) debouncedCheckRewards() } else { From 0b48cba228a0e7a4639c3f5aff2582a374fbdc0d Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 15:14:00 +0200 Subject: [PATCH 16/18] refactor: extract RollupRewardRow from ClaimSelfStakeRewardsModal The inline reward row rendering (rollup badge, reward amount, claim button, loading/locked states) was deeply nested and hard to follow. Extract it into a focused RollupRewardRow component with clear props. --- .../ClaimSelfStakeRewardsModal.tsx | 75 ++++++------------- .../RollupRewardRow.tsx | 62 +++++++++++++++ 2 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 5ef518c6b..023b5b7fb 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -4,6 +4,7 @@ import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { formatTokenAmount } from "@/utils/atpFormatters" import { validateAddress } from "@/utils/validateAddress" +import { RollupRewardRow } from "./RollupRewardRow" import { debounce } from "@/utils/debounce" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" @@ -225,68 +226,38 @@ export const ClaimSelfStakeRewardsModal = ({ )} - {/* Rewards Display — one row per rollup with a non-zero balance for this coinbase. */} + {/* Rewards Display */} {hasCheckedRewards && !isLoadingRewards && !isDebouncing && ( - <> +
{coinbaseBreakdown.length > 0 ? ( -
+
Available Rewards
- Total: {decimals && symbol ? formatTokenAmount(totalCoinbaseRewards, decimals, symbol) : '-'} + Total: + {decimals && symbol ? formatTokenAmount(totalCoinbaseRewards, decimals, symbol) : '-'} +
- {coinbaseBreakdown.map((row) => { - const perRollupClaimable = isClaimableForRollup(row.rollupAddress) - // Default to allowing the claim while loading; the contract will revert if it's - // genuinely locked. Disable explicitly only when we've confirmed false. - const rowIsClaimable = perRollupClaimable !== false - return ( -
-
- {row.rollupVersion !== undefined ? ( - - Rollup v{row.rollupVersion.toString()} - - ) : ( - - Configured rollup - - )} -
- {decimals && symbol ? formatTokenAmount(row.rewards, decimals, symbol) : '-'} -
-
- -
- ) - })} + {coinbaseBreakdown.map((row) => ( + + ))}
) : ( -
+
Available Rewards
@@ -295,7 +266,7 @@ export const ClaimSelfStakeRewardsModal = ({

)} - +
)} {/* Error Display */} diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx new file mode 100644 index 000000000..919db9866 --- /dev/null +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx @@ -0,0 +1,62 @@ +import type { Address } from "viem" +import { formatTokenAmount } from "@/utils/atpFormatters" + +interface RollupRewardRowProps { + rollupAddress: Address + rollupVersion: bigint | undefined + rewards: bigint + decimals: number + symbol: string + isClaimable: boolean + isBusy: boolean + isPending: boolean + onClaim: (rollupAddress: Address) => void +} + +export const RollupRewardRow = ({ + rollupAddress, + rollupVersion, + rewards, + decimals, + symbol, + isClaimable, + isBusy, + isPending, + onClaim, +}: RollupRewardRowProps) => ( +
+
+ {rollupVersion !== undefined ? ( + + Rollup v{rollupVersion.toString()} + + ) : ( + + Configured rollup + + )} +
+ {formatTokenAmount(rewards, decimals, symbol)} +
+
+ +
+) From c86f6970eaaf6bae5e2101aeb8355902125f2dd5 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 15:23:33 +0200 Subject: [PATCH 17/18] chore: trim verbose comments across multi-rollup diff --- .../ATPStakingOverview/ATPStakingOverview.tsx | 5 +-- .../ClaimSelfStakeRewardsModal.tsx | 14 ++------ .../IndexerRollupDisclaimer.tsx | 9 +----- .../Registration/RegistrationStake.tsx | 4 +-- .../Registration/WalletDirectStakingFlow.tsx | 8 +---- .../RewardsManagement/CoinbaseAddressList.tsx | 14 -------- .../ManageRewardsAddressesModal.tsx | 3 -- .../WalletDirectStakeItem.tsx | 4 +-- .../src/contracts/abis/RollupRegistry.ts | 3 -- staking-dashboard/src/contracts/index.ts | 2 -- .../src/hooks/rewards/useClaimAllRewards.ts | 32 ++----------------- .../useCoinbaseRewardsAcrossRollups.ts | 25 ++------------- .../hooks/rollup/useAttesterStakeLocation.ts | 15 ++------- .../src/hooks/rollup/useEjectionThreshold.ts | 1 - .../src/hooks/rollup/useFinalizeWithdraw.ts | 3 +- .../src/hooks/rollup/useIsRewardsClaimable.ts | 2 -- .../useIsRewardsClaimableAcrossRollups.ts | 12 ++----- .../src/hooks/rollup/useRollupData.ts | 2 -- .../src/hooks/rollup/useRollupRegistry.ts | 22 ++----------- .../src/hooks/rollup/useSequencerRewards.ts | 4 +-- .../src/hooks/rollup/useSequencerStatus.ts | 2 -- .../src/hooks/rollup/useWalletDirectStake.ts | 4 +-- .../hooks/rollup/useWalletInitiateWithdraw.ts | 5 +-- .../src/pages/ATP/MyPositionPage.tsx | 4 --- 24 files changed, 24 insertions(+), 175 deletions(-) diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx index e494439ca..de74d59ba 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx @@ -123,10 +123,7 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv } }, []) - // Cache required values once loaded. During refetches tanstack-query preserves - // previous data, but TypeScript can't prove these are never undefined after first - // load. A ref captures the last known values, provides type narrowing, AND prevents - // the skeleton from flashing during refetches (which would unmount modals mid-flow). + // Ref-cached to survive refetches without unmounting modals or losing type narrowing. const resolvedRef = useRef<{ decimals: number; symbol: string; activationThreshold: bigint } | null>(null) if (decimals !== undefined && symbol !== undefined && activationThreshold !== undefined) { resolvedRef.current = { decimals, symbol, activationThreshold } diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 023b5b7fb..bc7f51538 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -46,14 +46,12 @@ export const ClaimSelfStakeRewardsModal = ({ const [isDebouncing, setIsDebouncing] = useState(false) const isValidAddress = validateAddress(coinbaseAddress) - // Empty array while typing prevents firing reads against an invalid coinbase. const coinbasesForQuery = useMemo( () => (isValidAddress ? [coinbaseAddress as Address] : []), [coinbaseAddress, isValidAddress], ) - // Fan the read out across every rollup discovered via the Aztec governance Registry, so a - // sequencer with stranded balances on older rollups sees them all listed (one row per rollup). + // Per-rollup reward reads const { coinbaseBreakdown, totalCoinbaseRewards, @@ -61,8 +59,7 @@ export const ClaimSelfStakeRewardsModal = ({ refetch: checkRewards, } = useCoinbaseRewardsAcrossRollups(coinbasesForQuery) - // Multicall isRewardsClaimable() across the same rollups so the per-row claim button reflects - // the right rollup's gating, not the configured rollup's. + // Per-rollup claimability check const rollupAddressesInBreakdown = useMemo( () => coinbaseBreakdown.map((row) => row.rollupAddress), [coinbaseBreakdown], @@ -99,20 +96,15 @@ export const ClaimSelfStakeRewardsModal = ({ } }, [coinbaseAddress, debouncedCheckRewards]) - // Per-rollup claim helper — passes the rollup the row's balance lives on so the - // `claimSequencerRewards` tx is sent to the correct contract. const handleClaim = (rollupAddress: Address) => { claimRewards(coinbaseAddress as Address, rollupAddress) } - // Handle success — reset the claim hook so the user can claim remaining rollups - // without closing the modal. The rewards breakdown refetches automatically. + // On success: reset and refresh breakdown useEffect(() => { if (isSuccess) { onSuccess?.() reset() - // Re-trigger the rewards check so the breakdown refreshes - // and the claimed row disappears while remaining rows stay visible. if (coinbaseAddress) { debouncedCheckRewards() } diff --git a/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx b/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx index 18b03a0e8..20913e152 100644 --- a/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx +++ b/staking-dashboard/src/components/IndexerRollupDisclaimer/IndexerRollupDisclaimer.tsx @@ -1,17 +1,10 @@ import { Icon } from "@/components/Icon" import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" -/** - * Small inline disclaimer shown on pages that surface historical aggregates from the indexer - * (providers list, provider details, StakePortal summaries). It only renders when the - * dashboard detects more than one rollup in the governance Registry so operators aren't - * confused by a missing disclaimer on single-rollup deployments. - */ +/** Disclaimer shown on provider pages when multiple rollups exist. */ export const IndexerRollupDisclaimer = () => { const { rollups, isLoading } = useRollupRegistry() - // Only show when there's more than one known rollup; single-rollup deployments have - // complete data so the disclaimer would be misleading. if (isLoading || rollups.length <= 1) return null return ( diff --git a/staking-dashboard/src/components/Registration/RegistrationStake.tsx b/staking-dashboard/src/components/Registration/RegistrationStake.tsx index 5a25dd802..d91470991 100644 --- a/staking-dashboard/src/components/Registration/RegistrationStake.tsx +++ b/staking-dashboard/src/components/Registration/RegistrationStake.tsx @@ -30,9 +30,7 @@ interface RegistrationStakeProps { export const RegistrationStake = ({ onComplete }: RegistrationStakeProps) => { const { formData, handlePrevStep } = useATPStakingStepsContext() const { selectedAtp, uploadedKeystores, transactionType } = formData - // Resolve the canonical rollup so registrations target the latest one even if the dashboard - // was deployed against an older `VITE_ROLLUP_ADDRESS`. The activation threshold and version - // both come from the canonical rollup so the staked amount and `_rollupVersion` arg agree. + // Use canonical rollup for registration const { canonical: canonicalRollup, isLoading: isLoadingRegistry } = useRollupRegistry() const { activationThreshold, version: rollupVersion, isLoading: isLoadingRollup } = useRollupData(canonicalRollup?.address) const { symbol, decimals, isLoading: isLoadingToken } = useERC20TokenDetails(selectedAtp?.token!) diff --git a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx index 801af0744..953191f24 100644 --- a/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx +++ b/staking-dashboard/src/components/Registration/WalletDirectStakingFlow.tsx @@ -42,9 +42,7 @@ export const WalletDirectStakingFlow = ({ onComplete, }: WalletDirectStakingFlowProps) => { const { address } = useAccount() - // Discover the canonical rollup so registrations target whichever rollup is currently - // canonical, even if the dashboard was deployed against an older `VITE_ROLLUP_ADDRESS`. - // Falls back to the configured rollup while the registry is loading or if discovery fails. + // Target canonical rollup for deposits const { canonical: canonicalRollup, isLoading: isLoadingRegistry } = useRollupRegistry() const targetRollupAddress = canonicalRollup?.address ?? contracts.rollup.address const { activationThreshold, isLoading: isLoadingRollup } = useRollupData(targetRollupAddress) @@ -76,8 +74,6 @@ export const WalletDirectStakingFlow = ({ return activationThreshold * BigInt(stakeCount) }, [activationThreshold, stakeCount]) - // Check current allowance against the *canonical* rollup, since that's where the deposit - // will be sent. The approval and the deposit must agree on the spender address. const { allowance, isLoading: isLoadingAllowance, refetch: refetchAllowance } = useAllowance({ tokenAddress: stakingAssetAddress, owner: address, @@ -86,8 +82,6 @@ export const WalletDirectStakingFlow = ({ const hasEnoughAllowance = allowance !== undefined && allowance >= totalAmount - // Hooks for building transactions — both bound to the canonical rollup so the approval and - // deposit land on the same contract. const approveHook = useApproveRollup(stakingAssetAddress, targetRollupAddress) const depositHook = useWalletDirectStake(targetRollupAddress) diff --git a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx index 64ce8a943..364838590 100644 --- a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx +++ b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx @@ -27,13 +27,6 @@ interface CoinbaseAddressListProps { onRefetch?: () => void } -/** - * Display list of coinbase addresses with their rewards. - * - * Renders one row per (coinbase, rollup) pair with rewards > 0, so operators can see - * stranded balances on older rollups and claim them individually. Each claim button issues - * a `claimSequencerRewards` tx against the specific rollup the row's balance lives on. - */ export const CoinbaseAddressList = ({ coinbaseBreakdown, decimals, @@ -43,16 +36,10 @@ export const CoinbaseAddressList = ({ onRefetch }: CoinbaseAddressListProps) => { const { removeCoinbaseAddress, isPending: isRemoving } = useRemoveCoinbaseAddress() - // Hook is instantiated once and the per-row rollup is passed as the `claimRewards` override. const claimRewards = useClaimCoinbaseRewards() - // Track which row initiated the claim. Combined with the hook's reactive state - // (isPending/isConfirming) to show the spinner only on the active row. - // Not cleared explicitly — stale value is harmless because it's AND-ed with isClaiming. const [claimingRowKey, setClaimingRowKey] = useState(null) const isClaiming = claimRewards.isPending || claimRewards.isConfirming - // Multicall isRewardsClaimable() across every rollup represented in the breakdown so each - // row's claim button reflects its own rollup's state, not just the configured rollup. const rollupAddressesInBreakdown = useMemo( () => coinbaseBreakdown.map((item) => item.rollupAddress), [coinbaseBreakdown], @@ -92,7 +79,6 @@ export const CoinbaseAddressList = ({ return (
{coinbaseBreakdown.map((item) => { - // Per-rollup claimability flag; fall back to the prop while the multicall is still loading. const perRollupClaimable = isClaimableForRollup(item.rollupAddress) const rowIsClaimable = perRollupClaimable ?? isRewardsClaimable diff --git a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx index 74604b5c7..9f0851f32 100644 --- a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx +++ b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx @@ -50,9 +50,6 @@ export const ManageRewardsAddressesModal = ({ const addCoinbaseAddress = useAddCoinbaseAddress() - // Get rewards for all coinbase addresses. - // Use allCoinbaseBreakdown for the management UI so zero-balance addresses - // remain visible and removable. const { allCoinbaseBreakdown, isLoading: isLoadingCoinbaseRewards, diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx index 6c01d4906..add606052 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDirectStakeItem.tsx @@ -34,9 +34,7 @@ export const WalletDirectStakeItem = ({ const { symbol, decimals } = useStakingAssetTokenDetails() const { date, time } = formatBlockTimestamp(stake.timestamp) - // Discover which rollup the attester's stake actually lives on. For non-stranded stakes this - // returns the configured/canonical rollup; for stranded stakes it returns the specific old - // rollup so that withdraw/finalize calls target the correct contract. + // Find which rollup this stake lives on const { location: stakeLocation } = useAttesterStakeLocation(stake.attesterAddress as Address) const resolvedRollup = stakeLocation?.rollupAddress diff --git a/staking-dashboard/src/contracts/abis/RollupRegistry.ts b/staking-dashboard/src/contracts/abis/RollupRegistry.ts index 9cb7e82f9..81fd54f93 100644 --- a/staking-dashboard/src/contracts/abis/RollupRegistry.ts +++ b/staking-dashboard/src/contracts/abis/RollupRegistry.ts @@ -1,6 +1,3 @@ -// Aztec governance Registry (IRegistry). -// Source: aztec-packages next branch @ commit 9f7257e619 — l1-contracts/src/governance/interfaces/IRegistry.sol -// Discovered at runtime via stakingRegistry.ROLLUP_REGISTRY(); no env var required. export const RollupRegistryAbi = [ { "type": "function", diff --git a/staking-dashboard/src/contracts/index.ts b/staking-dashboard/src/contracts/index.ts index 812a1c7e4..fc4fe2089 100644 --- a/staking-dashboard/src/contracts/index.ts +++ b/staking-dashboard/src/contracts/index.ts @@ -76,8 +76,6 @@ const contracts = { address: env.VITE_GSE_ADDRESS, abi: GSEAbi, }, - // The rollup registry's address is not configured statically — it is discovered at runtime via - // stakingRegistry.ROLLUP_REGISTRY(). Only the ABI is exported here so callers can pass it to wagmi. rollupRegistry: { abi: RollupRegistryAbi, }, diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index f2cc90f67..f2d6354d0 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -28,13 +28,7 @@ export interface ClaimTask { providerTakeRate?: number // Coinbase-specific data coinbaseAddress?: Address - /** - * The rollup contract this task targets. Coinbase tasks pulled from a multi-rollup - * breakdown carry the rollup the balance lives on so the engine routes the - * `claimSequencerRewards` call to the correct contract. Delegation tasks default to - * the configured rollup (delegation rewards are split-contract scoped, but the - * underlying balance still flows from a specific rollup). - */ + /** Rollup contract this task targets for claiming. */ rollupAddress?: Address rollupVersion?: bigint // Sub-step tracking for delegations @@ -89,9 +83,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { // Get current task's addresses const currentSplitContract = currentTask?.type === 'delegation' ? currentTask.splitContract : undefined const currentCoinbase = currentTask?.type === 'coinbase' ? currentTask.coinbaseAddress : undefined - // Each task carries the rollup the balance lives on. For delegation tasks (which have not - // been multi-rollup-expanded yet) and legacy callers that omit the field, fall back to the - // configured rollup so existing behavior is preserved. const currentTaskRollup = currentTask?.rollupAddress // Fetch balances for current task (for delegations) - extract refetch functions @@ -162,8 +153,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { providerTakeRate: delegation.providerTakeRate })), ...coinbases.map((coinbase): ClaimTask => ({ - // Include the rollup in the task id so the same coinbase address claimed across - // multiple rollups produces distinct tasks instead of collapsing into one. id: `coinbase-${coinbase.address}-${coinbase.rollupAddress}`, type: 'coinbase', displayName: coinbase.rollupVersion !== undefined @@ -250,8 +239,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { coinbaseClaimHook.reset() }, [delegationClaimHook, coinbaseClaimHook]) - // Keep stable refs to claim functions so the trigger effect doesn't re-fire - // when the hook objects change identity (e.g. after reset()). const delegationClaimRef = useRef(delegationClaimHook) delegationClaimRef.current = delegationClaimHook const coinbaseClaimRef = useRef(coinbaseClaimHook) @@ -275,12 +262,9 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { )) setHasTriggeredClaim(true) - // Start the appropriate claim (read from refs to avoid stale closures) if (task.type === 'delegation') { delegationClaimRef.current.claim() } else if (task.type === 'coinbase' && task.coinbaseAddress) { - // Pass the per-task rollup address so the claim lands on the correct rollup contract - // (the hook itself defaults to the configured rollup when no override is given). coinbaseClaimRef.current.claimRewards(task.coinbaseAddress, task.rollupAddress) } }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances]) @@ -332,10 +316,7 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { i === currentTaskIndex ? { ...t, status: 'completed' as const } : t )) - // Reset hooks and advance to next task after a small delay. - // Use a ref-based timeout so it survives effect re-runs — the setTasks() - // above changes `tasks`, which re-triggers this effect. A cleanup return - // would kill the timeout before it fires. + // Delay so setTasks() doesn't re-trigger this effect before the timeout fires. if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current) advanceTimeoutRef.current = setTimeout(() => { advanceTimeoutRef.current = null @@ -384,16 +365,12 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { } if (taskError) { - // Mark task as failed and continue to next task instead of stopping. - // In a multi-rollup setup, some rollups may have locked rewards while - // others are claimable — stopping on the first failure would strand - // the claimable ones. + // Skip failed task and continue setTasks(prev => prev.map((t, i) => i === currentTaskIndex ? { ...t, status: 'error' as const, error: taskError } : t )) setError(taskError) - // Reset hooks and advance to next task if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current) advanceTimeoutRef.current = setTimeout(() => { advanceTimeoutRef.current = null @@ -424,7 +401,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { coinbaseClaimHook.error, ]) - // Calculate progress (both completed and failed count as "done") const completedTasks = tasks.filter(t => t.status === 'completed') const failedTasks = tasks.filter(t => t.status === 'error') const doneTasks = completedTasks.length + failedTasks.length @@ -432,8 +408,6 @@ export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { ? Math.round((doneTasks / tasks.length) * 100) : 0 - // Determine overall success/error state — isSuccess means all tasks processed - // (some may have failed). isError means at least one failed. const isSuccess = tasks.length > 0 && !isProcessing && doneTasks === tasks.length && completedTasks.length > 0 const isError = !isProcessing && failedTasks.length > 0 diff --git a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts index a13862440..d983964db 100644 --- a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts +++ b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts @@ -6,32 +6,17 @@ import { useRollupRegistry } from "@/hooks/rollup/useRollupRegistry" import type { CoinbaseBreakdown } from "./rewardsTypes" /** - * Fans `getSequencerRewards(coinbase)` out across every rollup discovered via the Aztec - * governance Registry, in a single multicall roundtrip. - * - * This is the multi-rollup-aware reader behind the rewards UI. The Registry enumeration - * happens via {@link useRollupRegistry} (cached forever); only when that returns rollups - * does this hook issue the actual `getSequencerRewards` reads. - * - * Returns one `CoinbaseBreakdown` entry per `(coinbase × rollup)` pair that has a non-zero - * balance, plus the running total. Callers display these as separate rows so operators can - * see exactly which rollup each balance lives on, and the claim engine routes per-row claims - * to the correct rollup contract. - * - * Falls back to the configured rollup only when registry discovery fails (so the dashboard - * keeps working in single-rollup deployments and during the registry-loading window). + * Multicalls `getSequencerRewards(coinbase)` across every registry-discovered rollup. + * Returns one `CoinbaseBreakdown` per `(coinbase, rollup)` pair with a non-zero balance. */ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { const { rollups, isLoading: isLoadingRegistry, error: registryError } = useRollupRegistry() - // Fall back to the configured rollup if registry discovery hasn't produced anything yet. - // This keeps single-rollup deployments and the initial-load window working. const effectiveRollups = useMemo(() => { if (rollups.length > 0) return rollups return [{ version: undefined as bigint | undefined, address: contracts.rollup.address }] }, [rollups]) - // Build a flat list of (rollup, coinbase) pairs and the matching multicall contracts. const pairs = useMemo(() => { const out: Array<{ rollupAddress: Address; rollupVersion: bigint | undefined; coinbase: Address }> = [] for (const rollup of effectiveRollups) { @@ -56,13 +41,10 @@ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { : undefined, query: { enabled: pairs.length > 0, - // Auto-refresh every 30 seconds to match the legacy single-rollup hook's cadence. refetchInterval: 30 * 1000, }, }) - // Expand the results into one CoinbaseBreakdown per (coinbase, rollup) pair. - // Includes zero-balance rows so saved addresses remain visible in the management UI. const allCoinbaseBreakdown = useMemo(() => { if (!data || pairs.length === 0) return [] const out: CoinbaseBreakdown[] = [] @@ -82,7 +64,6 @@ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { return out }, [data, pairs]) - // Filtered to non-zero for claim UIs (Claim All, per-rollup claim buttons) const coinbaseBreakdown = useMemo( () => allCoinbaseBreakdown.filter((item) => item.rewards > 0n), [allCoinbaseBreakdown], @@ -94,9 +75,7 @@ export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { ) return { - /** All (coinbase, rollup) pairs including zero-balance — for management UI */ allCoinbaseBreakdown, - /** Only non-zero balances — for claim UIs */ coinbaseBreakdown, totalCoinbaseRewards, isLoading: isLoading || isLoadingRegistry, diff --git a/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts b/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts index 8cdc44eef..3d6aae06c 100644 --- a/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts +++ b/staking-dashboard/src/hooks/rollup/useAttesterStakeLocation.ts @@ -5,19 +5,8 @@ import { contracts } from "@/contracts" import { useRollupRegistry } from "./useRollupRegistry" /** - * Locates the rollup where a given attester's stake actually lives. - * - * In the multi-rollup world, an attester can be stranded on an older rollup if they originally - * deposited with `_moveWithLatestRollup=false`. In that case, calling `getAttesterView` on the - * canonical rollup returns `Status.NONE`, even though the stake is real and recoverable. - * - * This hook fans `getAttesterView(attester)` across every rollup discovered via the Aztec - * governance Registry, picks the one returning a non-NONE status, and returns that rollup's - * address + version. The withdrawal flow uses this to target the correct rollup contract for - * `initiateWithdraw` / `finalizeWithdraw`. - * - * Tie-break order if the attester appears on multiple rollups (rare): prefer the canonical - * rollup, then the latest non-canonical. + * Locates the rollup where a given attester's stake actually lives by multicalling + * `getAttesterView` across all registry-discovered rollups. Prefers the canonical rollup. */ const STATUS_NONE = 0 diff --git a/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts b/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts index f21710644..47e1f1190 100644 --- a/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts +++ b/staking-dashboard/src/hooks/rollup/useEjectionThreshold.ts @@ -8,7 +8,6 @@ import { contracts } from "../../contracts" * below this threshold, validators are ejected from the active set. * * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. - * Each rollup version may have its own ejection threshold. */ export function useEjectionThreshold(rollupAddress?: Address) { const targetRollup = rollupAddress ?? contracts.rollup.address diff --git a/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts b/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts index 80143b9c7..616d4ec5c 100644 --- a/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts +++ b/staking-dashboard/src/hooks/rollup/useFinalizeWithdraw.ts @@ -9,8 +9,7 @@ import type { Address } from "viem" * The staker contract has a bug where it calls `finaliseWithdraw` (British spelling) but * the actual Rollup contract uses `finalizeWithdraw` (American spelling). * - * @param rollupAddress - Optional rollup contract to finalize on. Defaults to the configured - * rollup. For stranded stakes, pass the specific rollup the exit lives on. + * @param rollupAddress - Optional rollup contract to finalize on. Defaults to the configured rollup. * * @returns Hook with finalizeWithdraw function and transaction status */ diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts index d79e91472..2bb1d0cca 100644 --- a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts @@ -6,8 +6,6 @@ import type { Address } from "viem" * Hook to check if rewards are claimable from a specific rollup contract. * * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. - * Each rollup has its own `isRewardsClaimable` flag, so callers iterating - * across multiple rollups must check each one independently. */ export function useIsRewardsClaimable(rollupAddress?: Address) { const targetRollup = rollupAddress ?? contracts.rollup.address diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts index cfbb6e79b..733d0ee21 100644 --- a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts @@ -4,15 +4,8 @@ import type { Address } from "viem" import { contracts } from "@/contracts" /** - * Reads `isRewardsClaimable()` from a list of rollup contracts in a single multicall. - * - * Each rollup has its own `isRewardsClaimable` storage flag (toggled by the rollup owner), - * so a row showing rewards on rollup v2 may be claimable while a row on v3 is locked, or - * vice versa. This hook returns a Map keyed by the lowercased rollup address so callers can - * cheaply look up the right flag per row. - * - * Returns `undefined` for rollups that haven't loaded yet; consumers should treat - * `undefined` as "still loading" and not as "unclaimable". + * Multicalls `isRewardsClaimable()` across a list of rollup contracts. + * Returns a Map keyed by lowercased rollup address; `undefined` means still loading. */ export function useIsRewardsClaimableAcrossRollups(rollupAddresses: Address[]) { const uniqueAddresses = useMemo(() => { @@ -55,7 +48,6 @@ export function useIsRewardsClaimableAcrossRollups(rollupAddresses: Address[]) { return map }, [data, uniqueAddresses]) - /** Convenience lookup that returns `undefined` while loading, then `true`/`false`. */ const isClaimable = (rollupAddress: Address): boolean | undefined => { return claimableByRollup.get(rollupAddress.toLowerCase()) } diff --git a/staking-dashboard/src/hooks/rollup/useRollupData.ts b/staking-dashboard/src/hooks/rollup/useRollupData.ts index 0679ae6a3..7c8d524e3 100644 --- a/staking-dashboard/src/hooks/rollup/useRollupData.ts +++ b/staking-dashboard/src/hooks/rollup/useRollupData.ts @@ -6,8 +6,6 @@ import { contracts } from "../../contracts"; * Hook to get rollup data including version and activation threshold. * * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. - * Pass an explicit address to read version/threshold from a non-canonical - * rollup (e.g. for stranded-stake withdrawal flows). */ export function useRollupData(rollupAddress?: Address) { const targetRollup = rollupAddress ?? contracts.rollup.address; diff --git a/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts b/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts index 2e76fdf03..51a0e3847 100644 --- a/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts +++ b/staking-dashboard/src/hooks/rollup/useRollupRegistry.ts @@ -12,21 +12,11 @@ export interface RollupInstance { } /** - * Discovers all rollup instances from the Aztec governance Registry contract. - * - * The Registry address is read lazily from `stakingRegistry.ROLLUP_REGISTRY()` so no env var - * is required. The hook then enumerates `numberOfVersions()` and multicalls - * `getVersion(i)` + `getRollup(version)` to build the full list. The canonical rollup is read - * via `getCanonicalRollup()`. - * - * `isStale` is true when the configured `VITE_ROLLUP_ADDRESS` no longer matches the canonical - * rollup — this is what surfaces the "your dashboard is pinned to an old rollup" banner. - * - * Cached forever (`staleTime: Infinity`); the list only changes when governance rotates a rollup, - * which is extremely rare. + * Discovers all rollup instances from the Aztec governance Registry. + * Enumerates versions via `numberOfVersions()` + `getVersion(i)` + `getRollup(version)`. + * `isStale` is true when the configured rollup differs from the canonical one. */ export function useRollupRegistry() { - // Step 1: discover the rollup registry address via the staking registry. const registryAddressQuery = useReadContract({ abi: contracts.stakingRegistry.abi, address: contracts.stakingRegistry.address, @@ -39,7 +29,6 @@ export function useRollupRegistry() { const registryAddress = registryAddressQuery.data as Address | undefined - // Step 2: enumerate counts + canonical from the registry. const headerQuery = useReadContracts({ contracts: registryAddress ? [ @@ -65,7 +54,6 @@ export function useRollupRegistry() { const numberOfVersions = headerQuery.data?.[0].result as bigint | undefined const canonicalAddress = headerQuery.data?.[1].result as Address | undefined - // Step 3: enumerate version numbers via getVersion(i). const versionIndexes = useMemo(() => { if (!numberOfVersions) return [] const out: bigint[] = [] @@ -93,7 +81,6 @@ export function useRollupRegistry() { }, }) - // Step 4: resolve each version → rollup address via getRollup(version). const versions = useMemo(() => { if (!versionsQuery.data) return [] as bigint[] return versionsQuery.data @@ -121,7 +108,6 @@ export function useRollupRegistry() { }, }) - // Combine versions + addresses into the discovered list. const rollups = useMemo(() => { if (!rollupsQuery.data || versions.length === 0) return [] const out: RollupInstance[] = [] @@ -135,8 +121,6 @@ export function useRollupRegistry() { const canonical = useMemo(() => { if (!canonicalAddress) return undefined - // The canonical address is authoritative; pair it with the matching version from the - // enumerated list (or the last version, which mirrors the registry's storage layout). const match = rollups.find((r) => r.address.toLowerCase() === canonicalAddress.toLowerCase()) if (match) return match return rollups.length > 0 ? rollups[rollups.length - 1] : undefined diff --git a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts index f54ad66fb..01fee8e91 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts @@ -6,9 +6,7 @@ import type { Address } from "viem" * Hook to get sequencer rewards for a specific coinbase address. * * @param coinbaseAddress - The coinbase address to query rewards for - * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup - * (contracts.rollup.address) for backwards compatibility. Pass an explicit - * rollup when querying historical/non-canonical rollups. + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ export function useSequencerRewards(coinbaseAddress: string, rollupAddress?: Address) { const targetRollup = rollupAddress ?? contracts.rollup.address diff --git a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts index a5e27492f..b8bbcefa2 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts @@ -41,8 +41,6 @@ export function getStatusLabel(status: number | undefined): string { * Hook to get sequencer status information. * @param sequencerAddress - The address of the sequencer * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. - * Pass an explicit address to inspect status on a non-canonical rollup - * (e.g. for stranded-stake withdrawal flows). * @returns Sequencer status, label, and related information */ export function useSequencerStatus(sequencerAddress: Address | undefined, rollupAddress?: Address) { diff --git a/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts b/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts index 09c55f8c0..80ed21bfa 100644 --- a/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts +++ b/staking-dashboard/src/hooks/rollup/useWalletDirectStake.ts @@ -9,9 +9,7 @@ import type { G1Point, G2Point } from "@/hooks/staker/types" * This is for wallet-based direct staking (own validator registration). * User calls Rollup.deposit() directly with their BLS keys. * - * @param rollupAddress - Optional rollup contract to deposit into. Defaults to the configured - * rollup. Registration flows should pass the *canonical* rollup so the - * deposit lands on the active rollup, not on a pinned/stale one. + * @param rollupAddress - Optional rollup contract to deposit into. Defaults to the configured rollup. */ export function useWalletDirectStake(rollupAddress?: Address) { const targetRollup = rollupAddress ?? contracts.rollup.address diff --git a/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts b/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts index 694c381bb..ad9fec490 100644 --- a/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts +++ b/staking-dashboard/src/hooks/rollup/useWalletInitiateWithdraw.ts @@ -9,10 +9,7 @@ import type { Address } from "viem" * directly on the Rollup contract. This is different from ATP staking where * withdrawals are initiated through the staker contract. * - * @param rollupAddress - Optional rollup contract to withdraw from. Defaults to the configured - * rollup. For stranded stakes (originally deposited with - * `_moveWithRollup=false`) callers must pass the rollup the stake actually - * lives on, not the canonical rollup. + * @param rollupAddress - Optional rollup contract to withdraw from. Defaults to the configured rollup. * * @returns Hook with initiateWithdraw function and transaction status */ diff --git a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx index 8452bc972..bedba7d23 100644 --- a/staking-dashboard/src/pages/ATP/MyPositionPage.tsx +++ b/staking-dashboard/src/pages/ATP/MyPositionPage.tsx @@ -43,10 +43,6 @@ export default function MyPositionPage() { const { totalErc20Staked, directStakeBreakdown, delegationBreakdown, erc20DelegationBreakdown, erc20DirectStakeBreakdown, refetch } = useAggregatedStakingData() const { coinbaseAddresses } = useCoinbaseAddresses() - // Check if user has any staked positions (ATP vaults or ERC20 wallet stakes) - // or saved coinbase addresses (for self-stake rewards tracking). - // Once true, stay true for the session — prevents unmounting the Positions - // Overview (and its claim modal) when a successful claim zeros out rewards. const hasPositionsNow = directStakeBreakdown.length > 0 || delegationBreakdown.length > 0 || From 8a296d3a26bc9a2c1c8bf24c87b71ae6462c9459 Mon Sep 17 00:00:00 2001 From: Gregory Date: Thu, 16 Apr 2026 15:30:14 +0200 Subject: [PATCH 18/18] refactor: rewrite claim engine as useReducer state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace 4 interleaved useEffects, 5 refs, and 6 state variables with an explicit state machine using useReducer. Phases: idle → ready_to_trigger → waiting_for_result → advancing → next Each phase has exactly one effect: 1. trigger: call claim function 2. result: watch hook isSuccess/isError 3. advance: setTimeout → reset hooks → dispatch ADVANCED 4. substep: update delegation sub-step display All task mutations happen in the pure reducer function. Effect cleanups are safe because phases don't change mid-timeout. No guard refs, no hasTriggeredClaim, no handledCompletionRef. --- .../ClaimAllRewardsModal.tsx | 11 +- .../src/hooks/rewards/useClaimAllRewards.ts | 558 ++++++++---------- 2 files changed, 250 insertions(+), 319 deletions(-) diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index d3b9a9450..1ddfacfac 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react" +import { useState, useEffect, useRef } from "react" import { createPortal } from "react-dom" import { Icon } from "@/components/Icon" import { useClaimAllRewards } from "@/hooks/rewards" @@ -44,14 +44,15 @@ export const ClaimAllRewardsModal = ({ // Claim hook const claimAllRewards = useClaimAllRewards() - // Reset phase when modal opens - const { reset: resetClaims } = claimAllRewards + // Reset phase when modal opens — use ref to avoid re-firing when reset identity changes + const resetRef = useRef(claimAllRewards.reset) + resetRef.current = claimAllRewards.reset useEffect(() => { if (isOpen) { setPhase('summary') - resetClaims() + resetRef.current() } - }, [isOpen, resetClaims]) + }, [isOpen]) // Transition to progress when claiming starts useEffect(() => { diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts index f2d6354d0..8b0897451 100644 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react" +import { useEffect, useCallback, useRef, useMemo, useReducer } from "react" import { useAccount } from "wagmi" import { useClaimSplitRewards } from "@/hooks/splits/useClaimSplitRewards" import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" @@ -12,6 +12,8 @@ import type { SplitData } from "@/hooks/splits/types" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" import type { CoinbaseBreakdown } from "./rewardsTypes" +// ── Types ────────────────────────────────────────────────────────────── + export type ClaimTaskStatus = 'pending' | 'processing' | 'completed' | 'error' | 'skipped' export type ClaimTaskType = 'delegation' | 'coinbase' @@ -22,34 +24,128 @@ export interface ClaimTask { estimatedRewards: bigint status: ClaimTaskStatus error?: Error - // Delegation-specific data splitContract?: Address splitData?: SplitData providerTakeRate?: number - // Coinbase-specific data coinbaseAddress?: Address /** Rollup contract this task targets for claiming. */ rollupAddress?: Address rollupVersion?: bigint - // Sub-step tracking for delegations currentSubStep?: 'claiming' | 'distributing' | 'withdrawing' } +// ── State machine ────────────────────────────────────────────────────── + +type Phase = 'idle' | 'ready_to_trigger' | 'waiting_for_result' | 'advancing' + +interface EngineState { + tasks: ClaimTask[] + currentIndex: number | null + phase: Phase + error: Error | null +} + +type Action = + | { type: 'START'; tasks: ClaimTask[] } + | { type: 'TRIGGERED' } + | { type: 'TASK_COMPLETED' } + | { type: 'TASK_FAILED'; error: Error } + | { type: 'UPDATE_SUBSTEP'; subStep: string } + | { type: 'ADVANCED' } + | { type: 'CANCEL' } + | { type: 'RESET' } + | { type: 'RETRY' } + +const initialState: EngineState = { + tasks: [], + currentIndex: null, + phase: 'idle', + error: null, +} + +function reducer(state: EngineState, action: Action): EngineState { + switch (action.type) { + case 'START': + return { tasks: action.tasks, currentIndex: 0, phase: 'ready_to_trigger', error: null } + + case 'TRIGGERED': + return { + ...state, + phase: 'waiting_for_result', + tasks: state.tasks.map((t, i) => + i === state.currentIndex ? { ...t, status: 'processing' as const } : t + ), + } + + case 'TASK_COMPLETED': + return { + ...state, + phase: 'advancing', + tasks: state.tasks.map((t, i) => + i === state.currentIndex ? { ...t, status: 'completed' as const } : t + ), + } + + case 'TASK_FAILED': + return { + ...state, + phase: 'advancing', + error: action.error, + tasks: state.tasks.map((t, i) => + i === state.currentIndex ? { ...t, status: 'error' as const, error: action.error } : t + ), + } + + case 'UPDATE_SUBSTEP': + return { + ...state, + tasks: state.tasks.map((t, i) => + i === state.currentIndex + ? { ...t, currentSubStep: action.subStep as ClaimTask['currentSubStep'] } + : t + ), + } + + case 'ADVANCED': { + const nextIndex = state.currentIndex! + 1 + if (nextIndex < state.tasks.length) { + return { ...state, currentIndex: nextIndex, phase: 'ready_to_trigger' } + } + return { ...state, currentIndex: null, phase: 'idle' } + } + + case 'CANCEL': + return { ...state, currentIndex: null, phase: 'idle' } + + case 'RESET': + return initialState + + case 'RETRY': { + const retried = state.tasks.map(t => + t.status === 'error' ? { ...t, status: 'pending' as const, error: undefined } : t + ) + const firstPending = retried.findIndex(t => t.status === 'pending') + if (firstPending === -1) return state + return { tasks: retried, currentIndex: firstPending, phase: 'ready_to_trigger', error: null } + } + + default: + return state + } +} + +// ── Return type ──────────────────────────────────────────────────────── + interface UseClaimAllRewardsReturn { - // Actions startClaiming: (delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => void cancelClaiming: () => void retryFailed: () => void reset: () => void - - // State tasks: ClaimTask[] currentTask: ClaimTask | null currentTaskIndex: number | null isProcessing: boolean progressPercent: number - - // Results isSuccess: boolean isError: boolean error: Error | null @@ -57,374 +153,208 @@ interface UseClaimAllRewardsReturn { failedTasks: ClaimTask[] } -/** - * Hook to orchestrate claiming rewards from multiple delegation splits and coinbase addresses - * Processes tasks sequentially: delegations first (3 steps each), then coinbases (1 step each) - */ +// ── Hook ─────────────────────────────────────────────────────────────── + export const useClaimAllRewards = (): UseClaimAllRewardsReturn => { const { address: userAddress } = useAccount() const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() - // Task queue state - const [tasks, setTasks] = useState([]) - const [currentTaskIndex, setCurrentTaskIndex] = useState(null) - const [isProcessing, setIsProcessing] = useState(false) - const [error, setError] = useState(null) - const [hasTriggeredClaim, setHasTriggeredClaim] = useState(false) + const [state, dispatch] = useReducer(reducer, initialState) + const currentTask = state.currentIndex !== null ? state.tasks[state.currentIndex] : null - // Track if we were cancelled - const cancelledRef = useRef(false) - // Ref-based timeout for advancing between tasks — survives effect re-runs - const advanceTimeoutRef = useRef | null>(null) - - // Get current task - const currentTask = currentTaskIndex !== null ? tasks[currentTaskIndex] : null - - // Get current task's addresses + // ── Delegation balance hooks (driven by currentTask) ───────────── const currentSplitContract = currentTask?.type === 'delegation' ? currentTask.splitContract : undefined const currentCoinbase = currentTask?.type === 'coinbase' ? currentTask.coinbaseAddress : undefined const currentTaskRollup = currentTask?.rollupAddress - // Fetch balances for current task (for delegations) - extract refetch functions const { warehouseAddress, isLoading: isLoadingWarehouse } = useSplitsWarehouse(currentSplitContract) - const { rewards: rollupBalance, isLoading: isLoadingRollup, refetch: refetchRollup } = useSequencerRewards(currentSplitContract || currentCoinbase || '', currentTaskRollup) - const { balance: splitContractBalance, isLoading: isLoadingSplitBalance, refetch: refetchSplitContract } = useERC20Balance(tokenAddress, currentSplitContract) - const { balance: warehouseBalance, isLoading: isLoadingWarehouseBalance, refetch: refetchWarehouse } = useWarehouseBalance(warehouseAddress, userAddress, tokenAddress) + const { rewards: rollupBalance, isLoading: isLoadingRollup, refetch: refetchRollup } = + useSequencerRewards(currentSplitContract || currentCoinbase || '', currentTaskRollup) + const { balance: splitContractBalance, isLoading: isLoadingSplitBalance, refetch: refetchSplitContract } = + useERC20Balance(tokenAddress, currentSplitContract) + const { balance: warehouseBalance, isLoading: isLoadingWarehouseBalance, refetch: refetchWarehouse } = + useWarehouseBalance(warehouseAddress, userAddress, tokenAddress) const isLoadingBalances = currentTask?.type === 'delegation' ? (isLoadingWarehouse || isLoadingRollup || isLoadingSplitBalance || isLoadingWarehouseBalance) : isLoadingRollup - // Memoize balances object to prevent effect re-runs on every render const balances = useMemo(() => ({ - rollupBalance, - splitContractBalance, - warehouseBalance, - refetchRollup, - refetchSplitContract, - refetchWarehouse + rollupBalance, splitContractBalance, warehouseBalance, + refetchRollup, refetchSplitContract, refetchWarehouse }), [rollupBalance, splitContractBalance, warehouseBalance, refetchRollup, refetchSplitContract, refetchWarehouse]) - // Use existing hooks for claiming + // ── Claim hooks ────────────────────────────────────────────────── const delegationClaimHook = useClaimSplitRewards( currentSplitContract, currentTask?.splitData || { recipients: [], allocations: [], totalAllocation: 0n, distributionIncentive: 0 }, - tokenAddress, - userAddress, - balances + tokenAddress, userAddress, balances ) - const coinbaseClaimHook = useClaimSequencerRewards() - /** - * Build SplitData from delegation info - */ - const buildSplitData = useCallback((delegation: DelegationBreakdown, user: Address): SplitData => { - const totalAllocation = 10000n - const providerAllocation = BigInt(delegation.providerTakeRate) - const userAllocation = totalAllocation - providerAllocation + // Stable refs for calling inside effects without dep issues + const delegationRef = useRef(delegationClaimHook) + delegationRef.current = delegationClaimHook + const coinbaseRef = useRef(coinbaseClaimHook) + coinbaseRef.current = coinbaseClaimHook - return { - recipients: [delegation.providerRewardsRecipient as Address, user], - allocations: [providerAllocation, userAllocation], - totalAllocation, - distributionIncentive: 0 - } - }, []) + // ── Effect 1: TRIGGER — start the claim for the current task ───── + useEffect(() => { + if (state.phase !== 'ready_to_trigger' || state.currentIndex === null) return + const task = state.tasks[state.currentIndex] + if (!task) return + if (task.type === 'delegation' && isLoadingBalances) return - /** - * Start claiming all rewards - */ - const startClaiming = useCallback((delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => { - if (!userAddress || (!delegations.length && !coinbases.length)) return + dispatch({ type: 'TRIGGERED' }) - cancelledRef.current = false + if (task.type === 'delegation') { + delegationRef.current.claim() + } else if (task.type === 'coinbase' && task.coinbaseAddress) { + coinbaseRef.current.claimRewards(task.coinbaseAddress, task.rollupAddress) + } + }, [state.phase, state.currentIndex, state.tasks, isLoadingBalances]) - // Build task list: delegations first, then coinbases - const newTasks: ClaimTask[] = [ - ...delegations.map((delegation): ClaimTask => ({ - id: `delegation-${delegation.splitContract}`, - type: 'delegation', - displayName: delegation.providerName || `Provider ${delegation.providerId}`, - estimatedRewards: delegation.rewards, - status: 'pending', - splitContract: delegation.splitContract as Address, - splitData: buildSplitData(delegation, userAddress), - providerTakeRate: delegation.providerTakeRate - })), - ...coinbases.map((coinbase): ClaimTask => ({ - id: `coinbase-${coinbase.address}-${coinbase.rollupAddress}`, - type: 'coinbase', - displayName: coinbase.rollupVersion !== undefined - ? `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)} (rollup v${coinbase.rollupVersion})` - : `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)}`, - estimatedRewards: coinbase.rewards, - status: 'pending', - coinbaseAddress: coinbase.address, - rollupAddress: coinbase.rollupAddress, - rollupVersion: coinbase.rollupVersion, - })) - ] + // ── Effect 2: RESULT — watch hooks for success or error ────────── + useEffect(() => { + if (state.phase !== 'waiting_for_result' || !currentTask) return - // Filter out tasks with no rewards - const tasksWithRewards = newTasks.filter(task => task.estimatedRewards > 0n) + const isSuccess = currentTask.type === 'delegation' + ? delegationClaimHook.isSuccess && delegationClaimHook.claimStep === 'idle' + : coinbaseClaimHook.isSuccess - if (tasksWithRewards.length === 0) { - setError(new Error('No rewards to claim')) - return - } + const isError = currentTask.type === 'delegation' + ? delegationClaimHook.isError + : coinbaseClaimHook.isError - setTasks(tasksWithRewards) - setCurrentTaskIndex(0) - setIsProcessing(true) - setError(null) - setHasTriggeredClaim(false) - handledCompletionRef.current = null - - // Reset hooks - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [userAddress, buildSplitData, delegationClaimHook, coinbaseClaimHook]) - - /** - * Cancel claiming - stops processing but keeps completed - */ - const cancelClaiming = useCallback(() => { - cancelledRef.current = true - if (advanceTimeoutRef.current) { clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = null } - handledCompletionRef.current = null - setIsProcessing(false) - setCurrentTaskIndex(null) - setHasTriggeredClaim(false) - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [delegationClaimHook, coinbaseClaimHook]) - - /** - * Retry failed tasks - */ - const retryFailed = useCallback(() => { - const failedTasks = tasks.filter(t => t.status === 'error') - if (failedTasks.length === 0) return - - // Reset failed tasks to pending - setTasks(prev => prev.map(t => - t.status === 'error' ? { ...t, status: 'pending' as const, error: undefined } : t - )) - - // Find first pending task - const firstPendingIndex = tasks.findIndex(t => t.status === 'pending' || t.status === 'error') - if (firstPendingIndex !== -1) { - cancelledRef.current = false - setCurrentTaskIndex(firstPendingIndex) - setIsProcessing(true) - setError(null) - setHasTriggeredClaim(false) + const hookError = currentTask.type === 'delegation' + ? delegationClaimHook.error + : coinbaseClaimHook.error + + if (isSuccess) { + dispatch({ type: 'TASK_COMPLETED' }) + } else if (isError && hookError) { + dispatch({ type: 'TASK_FAILED', error: hookError as Error }) } - }, [tasks]) + }, [ + state.phase, currentTask, + delegationClaimHook.isSuccess, delegationClaimHook.isError, + delegationClaimHook.error, delegationClaimHook.claimStep, + coinbaseClaimHook.isSuccess, coinbaseClaimHook.isError, coinbaseClaimHook.error, + ]) - /** - * Reset all state - */ - const reset = useCallback(() => { - cancelledRef.current = false - if (advanceTimeoutRef.current) { clearTimeout(advanceTimeoutRef.current); advanceTimeoutRef.current = null } - handledCompletionRef.current = null - setTasks([]) - setCurrentTaskIndex(null) - setIsProcessing(false) - setError(null) - setHasTriggeredClaim(false) - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [delegationClaimHook, coinbaseClaimHook]) - - const delegationClaimRef = useRef(delegationClaimHook) - delegationClaimRef.current = delegationClaimHook - const coinbaseClaimRef = useRef(coinbaseClaimHook) - coinbaseClaimRef.current = coinbaseClaimHook - - /** - * Start claim for current task when ready - */ + // ── Effect 3: ADVANCE — delay, reset hooks, move to next task ──── useEffect(() => { - if (!isProcessing || currentTaskIndex === null || hasTriggeredClaim || cancelledRef.current) return + if (state.phase !== 'advancing') return - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'pending') return + const timeout = setTimeout(() => { + delegationRef.current.reset() + coinbaseRef.current.reset() + dispatch({ type: 'ADVANCED' }) + }, 500) - // Wait for balances to load for delegations - if (task.type === 'delegation' && isLoadingBalances) return - - // Mark task as processing - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, status: 'processing' as const } : t - )) - setHasTriggeredClaim(true) - - if (task.type === 'delegation') { - delegationClaimRef.current.claim() - } else if (task.type === 'coinbase' && task.coinbaseAddress) { - coinbaseClaimRef.current.claimRewards(task.coinbaseAddress, task.rollupAddress) - } - }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances]) + return () => clearTimeout(timeout) + }, [state.phase]) - /** - * Update sub-step for delegation tasks - */ + // ── Effect 4: SUBSTEP — update delegation sub-step display ─────── useEffect(() => { - if (!currentTask || currentTask.type !== 'delegation' || !isProcessing) return + if (state.phase !== 'waiting_for_result') return + if (!currentTask || currentTask.type !== 'delegation') return const subStep = delegationClaimHook.claimStep if (subStep !== 'idle') { - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, currentSubStep: subStep as 'claiming' | 'distributing' | 'withdrawing' } : t - )) + dispatch({ type: 'UPDATE_SUBSTEP', subStep }) } - // eslint-disable-next-line react-hooks/exhaustive-deps -- currentTask derived from currentTaskIndex - }, [delegationClaimHook.claimStep, currentTaskIndex, currentTask?.type, isProcessing]) + }, [state.phase, currentTask, delegationClaimHook.claimStep]) - // Track whether we've already handled the current task's completion to avoid - // re-processing when hook state oscillates during reset. - const handledCompletionRef = useRef(null) + // ── Actions ────────────────────────────────────────────────────── - /** - * Handle task completion and move to next - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null || !hasTriggeredClaim || cancelledRef.current) return - if (handledCompletionRef.current === currentTaskIndex) return + const buildSplitData = useCallback((delegation: DelegationBreakdown, user: Address): SplitData => { + const totalAllocation = 10000n + const providerAllocation = BigInt(delegation.providerTakeRate) + return { + recipients: [delegation.providerRewardsRecipient as Address, user], + allocations: [providerAllocation, totalAllocation - providerAllocation], + totalAllocation, + distributionIncentive: 0 + } + }, []) - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'processing') return + const resetHooks = useCallback(() => { + delegationRef.current.reset() + coinbaseRef.current.reset() + }, []) - let isComplete = false + const startClaiming = useCallback((delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => { + if (!userAddress || (!delegations.length && !coinbases.length)) return - // Check completion based on task type - if (task.type === 'delegation') { - isComplete = delegationClaimHook.isSuccess && delegationClaimHook.claimStep === 'idle' - } else if (task.type === 'coinbase') { - isComplete = coinbaseClaimHook.isSuccess - } + const newTasks: ClaimTask[] = [ + ...delegations.map((d): ClaimTask => ({ + id: `delegation-${d.splitContract}`, + type: 'delegation', + displayName: d.providerName || `Provider ${d.providerId}`, + estimatedRewards: d.rewards, + status: 'pending', + splitContract: d.splitContract as Address, + splitData: buildSplitData(d, userAddress), + providerTakeRate: d.providerTakeRate + })), + ...coinbases.map((c): ClaimTask => ({ + id: `coinbase-${c.address}-${c.rollupAddress}`, + type: 'coinbase', + displayName: c.rollupVersion !== undefined + ? `${c.address.slice(0, 6)}...${c.address.slice(-4)} (rollup v${c.rollupVersion})` + : `${c.address.slice(0, 6)}...${c.address.slice(-4)}`, + estimatedRewards: c.rewards, + status: 'pending', + coinbaseAddress: c.address, + rollupAddress: c.rollupAddress, + rollupVersion: c.rollupVersion, + })) + ].filter(t => t.estimatedRewards > 0n) - if (isComplete) { - // Guard against re-entry - handledCompletionRef.current = currentTaskIndex - - // Mark task as completed - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, status: 'completed' as const } : t - )) - - // Delay so setTasks() doesn't re-trigger this effect before the timeout fires. - if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current) - advanceTimeoutRef.current = setTimeout(() => { - advanceTimeoutRef.current = null - if (cancelledRef.current) return - - delegationClaimRef.current.reset() - coinbaseClaimRef.current.reset() - - const nextIndex = currentTaskIndex + 1 - if (nextIndex < tasks.length) { - setCurrentTaskIndex(nextIndex) - setHasTriggeredClaim(false) - handledCompletionRef.current = null - } else { - setIsProcessing(false) - setCurrentTaskIndex(null) - handledCompletionRef.current = null - } - }, 500) - } - }, [ - isProcessing, - currentTaskIndex, - tasks, - hasTriggeredClaim, - delegationClaimHook.isSuccess, - delegationClaimHook.claimStep, - coinbaseClaimHook.isSuccess, - ]) + if (newTasks.length === 0) return - /** - * Handle errors - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null) return + resetHooks() + dispatch({ type: 'START', tasks: newTasks }) + }, [userAddress, buildSplitData, resetHooks]) - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'processing') return + const cancelClaiming = useCallback(() => { + resetHooks() + dispatch({ type: 'CANCEL' }) + }, [resetHooks]) - let taskError: Error | null = null + const retryFailed = useCallback(() => { + resetHooks() + dispatch({ type: 'RETRY' }) + }, [resetHooks]) - if (task.type === 'delegation' && delegationClaimHook.isError) { - taskError = delegationClaimHook.error as Error - } else if (task.type === 'coinbase' && coinbaseClaimHook.isError) { - taskError = coinbaseClaimHook.error as Error - } + const reset = useCallback(() => { + resetHooks() + dispatch({ type: 'RESET' }) + }, [resetHooks]) - if (taskError) { - // Skip failed task and continue - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, status: 'error' as const, error: taskError } : t - )) - setError(taskError) - - if (advanceTimeoutRef.current) clearTimeout(advanceTimeoutRef.current) - advanceTimeoutRef.current = setTimeout(() => { - advanceTimeoutRef.current = null - if (cancelledRef.current) return - - delegationClaimRef.current.reset() - coinbaseClaimRef.current.reset() - - const nextIndex = currentTaskIndex + 1 - if (nextIndex < tasks.length) { - setCurrentTaskIndex(nextIndex) - setHasTriggeredClaim(false) - handledCompletionRef.current = null - } else { - setIsProcessing(false) - setCurrentTaskIndex(null) - handledCompletionRef.current = null - } - }, 500) - } - }, [ - isProcessing, - currentTaskIndex, - tasks, - delegationClaimHook.isError, - delegationClaimHook.error, - coinbaseClaimHook.isError, - coinbaseClaimHook.error, - ]) + // ── Derived state ──────────────────────────────────────────────── - const completedTasks = tasks.filter(t => t.status === 'completed') - const failedTasks = tasks.filter(t => t.status === 'error') + const completedTasks = state.tasks.filter(t => t.status === 'completed') + const failedTasks = state.tasks.filter(t => t.status === 'error') const doneTasks = completedTasks.length + failedTasks.length - const progressPercent = tasks.length > 0 - ? Math.round((doneTasks / tasks.length) * 100) - : 0 - - const isSuccess = tasks.length > 0 && !isProcessing && doneTasks === tasks.length && completedTasks.length > 0 - const isError = !isProcessing && failedTasks.length > 0 + const isProcessing = state.phase !== 'idle' + const isAllDone = state.tasks.length > 0 && !isProcessing && doneTasks === state.tasks.length return { startClaiming, cancelClaiming, retryFailed, reset, - tasks, + tasks: state.tasks, currentTask, - currentTaskIndex, + currentTaskIndex: state.currentIndex, isProcessing, - progressPercent, - isSuccess, - isError, - error, + progressPercent: state.tasks.length > 0 ? Math.round((doneTasks / state.tasks.length) * 100) : 0, + isSuccess: isAllDone && completedTasks.length > 0, + isError: isAllDone && failedTasks.length > 0, + error: state.error, completedTasks, - failedTasks + failedTasks, } }