From 49b48a764fdd1b80bca4850e886ffb3b041057d4 Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 12 May 2026 10:48:20 +0400 Subject: [PATCH 1/5] fix: multi rollup claims --- .../scripts/multi-rollup-test/seed-fork.ts | 196 +++++++++ .../scripts/multi-rollup-test/setup-fork.sh | 141 +++++++ .../ATPDetailsDelegationItem.tsx | 42 +- .../ATPDetailsDirectStakeItem.tsx | 5 +- .../ATPDetailsModal/ATPDetailsModal.tsx | 13 +- .../ATPStakingOverview/ATPStakingOverview.tsx | 7 +- .../ATPStakingOverviewBreakdownSection.tsx | 6 + .../ATPStakingOverviewDelegationItem.tsx | 37 +- .../ClaimAllDelegationRewardsButton.tsx | 203 +++++---- .../ClaimAllRewardsModal.tsx | 229 +++++----- .../ClaimAllRewardsProgress.tsx | 226 ---------- .../ClaimAllRewardsSuccess.tsx | 129 ------ .../ClaimAllRewardsSummary.tsx | 49 ++- .../components/ClaimAllRewardsModal/index.ts | 2 - .../ClaimDelegationRewardsButton.tsx | 244 ++++++----- .../ClaimDelegationRewardsModal.tsx | 93 ++-- .../ClaimSelfStakeRewardsModal.tsx | 228 +++++----- .../RollupRewardRow.tsx | 72 ++++ .../components/Footer/StakingTermsModal.tsx | 2 +- .../RewardsManagement/CoinbaseAddressList.tsx | 194 ++++++--- .../ManageRewardsAddressesModal.tsx | 8 +- .../RewardsManagement/SplitContractList.tsx | 12 +- .../src/components/StakeHealthBar.tsx | 41 +- .../src/components/StatusBadge.tsx | 16 +- .../TermsAcceptanceModal.tsx | 2 +- .../TransactionCartDetailsExpanded.tsx | 56 +++ .../WalletDelegationItem.tsx | 38 +- .../WalletDirectStakeItem.tsx | 8 +- .../WalletStakesDetailsModal.tsx | 10 +- .../src/contexts/ClaimAllContext.tsx | 68 --- .../src/contexts/TransactionCartContext.tsx | 50 +++ .../contexts/TransactionCartContextType.ts | 53 ++- staking-dashboard/src/contracts/index.ts | 28 +- .../src/hooks/atp/useAggregatedStakingData.ts | 113 +++-- .../hooks/atpFactory/useATPCreatedEvents.ts | 2 +- .../hooks/governance/usePendingWithdrawals.ts | 2 +- staking-dashboard/src/hooks/rewards/index.ts | 5 - .../src/hooks/rewards/rewardsTypes.ts | 11 +- .../src/hooks/rewards/useClaimAllRewards.ts | 396 ------------------ .../hooks/rewards/useClaimCoinbaseRewards.ts | 24 -- .../useCoinbaseRewardsAcrossRollups.ts | 97 +++++ .../rewards/useMultipleCoinbaseRewards.ts | 53 +-- staking-dashboard/src/hooks/rollup/index.ts | 2 +- .../src/hooks/rollup/sequencerStatus.ts | 36 ++ .../hooks/rollup/useAttesterViewBestEffort.ts | 51 +++ .../hooks/rollup/useClaimSequencerRewards.ts | 32 -- .../src/hooks/rollup/useIsRewardsClaimable.ts | 10 +- .../useIsRewardsClaimableAcrossRollups.ts | 63 +++ .../src/hooks/rollup/useSequencerRewards.ts | 12 +- .../src/hooks/rollup/useSequencerStatus.ts | 50 +-- .../src/hooks/rollup/useStakeHealth.ts | 60 ++- staking-dashboard/src/hooks/splits/index.ts | 1 - staking-dashboard/src/hooks/splits/types.ts | 2 - .../hooks/splits/useClaimAllSplitRewards.ts | 170 -------- .../src/hooks/splits/useClaimSplitRewards.ts | 258 ------------ .../src/hooks/splits/useDistributeRewards.ts | 29 +- .../src/hooks/splits/useWithdrawRewards.ts | 22 +- staking-dashboard/src/hooks/staker/types.ts | 8 + .../useMultipleStakeWithProviderRewards.ts | 90 ++-- .../useProviderRegisteredEvents.ts | 2 +- staking-dashboard/src/utils/claimCart.ts | 220 ++++++++++ 61 files changed, 2134 insertions(+), 2195 deletions(-) create mode 100644 staking-dashboard/scripts/multi-rollup-test/seed-fork.ts create mode 100755 staking-dashboard/scripts/multi-rollup-test/setup-fork.sh delete mode 100644 staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsProgress.tsx delete mode 100644 staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSuccess.tsx create mode 100644 staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx delete mode 100644 staking-dashboard/src/contexts/ClaimAllContext.tsx delete mode 100644 staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts delete mode 100644 staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts create mode 100644 staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts create mode 100644 staking-dashboard/src/hooks/rollup/sequencerStatus.ts create mode 100644 staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts delete mode 100644 staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts create mode 100644 staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts delete mode 100644 staking-dashboard/src/hooks/splits/useClaimAllSplitRewards.ts delete mode 100644 staking-dashboard/src/hooks/splits/useClaimSplitRewards.ts create mode 100644 staking-dashboard/src/utils/claimCart.ts diff --git a/staking-dashboard/scripts/multi-rollup-test/seed-fork.ts b/staking-dashboard/scripts/multi-rollup-test/seed-fork.ts new file mode 100644 index 000000000..c2ae608e0 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/seed-fork.ts @@ -0,0 +1,196 @@ +/** + * Seed multi-rollup reward state on a forked-mainnet anvil. + * + * Auto-discovers rollups by reading `Registry.CanonicalRollupUpdated` events, + * then writes `sequencerRewards[TARGET_ADDRESS]` on each rollup via + * `anvil_setStorageAt`. The fork already has real fee-asset balances and + * `isRewardsClaimable=true` from mainnet state, so nothing else needs touching. + * + * Run from repo root: + * npx tsx staking-dashboard/scripts/multi-rollup-test/seed-fork.ts + * + * Env overrides: + * TARGET_ADDRESS - address to seed rewards for (default: anvil account 0) + * RPC_URL - anvil RPC URL (default: http://127.0.0.1:8545) + * REWARD_AMOUNTS - comma-separated reward amounts in whole tokens, oldest + * rollup first (default: "5,10" — 5 on v1, 10 on v2) + */ + +import { + createPublicClient, + createTestClient, + http, + keccak256, + encodeAbiParameters, + numberToHex, + stringToHex, + parseAbi, + type Address, + type Hex, + type Log, +} from "viem"; +import { mainnet } from "viem/chains"; +import { readFileSync } from "fs"; +import { resolve, dirname } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(__dirname, "../../.."); +const ADDRS_FILE = resolve(REPO_ROOT, "atp-indexer/contract_addresses.json"); + +const TARGET_ADDRESS = ( + process.env.TARGET_ADDRESS ?? "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" +) as Address; +const RPC_URL = process.env.RPC_URL ?? "http://127.0.0.1:8545"; +const REWARD_AMOUNTS = (process.env.REWARD_AMOUNTS ?? "5,10") + .split(",") + .map((s) => BigInt(s.trim())); + +const addrs = JSON.parse(readFileSync(ADDRS_FILE, "utf-8")); +const REGISTRY_ADDRESS = addrs.registryAddress as Address; +const REGISTRY_DEPLOY_BLOCK = BigInt(addrs.registryDeploymentBlock); + +// ERC-7201-style namespaced storage. Base = keccak256 of the raw UTF-8 string +// (NOT abi.encode("aztec.reward.storage"), which produces a different hash). +// slot 0: mapping(address => uint256) sequencerRewards +const REWARD_STORAGE_BASE = keccak256(stringToHex("aztec.reward.storage")); +const SEQUENCER_REWARDS_SLOT = BigInt(REWARD_STORAGE_BASE); + +const CANONICAL_UPDATED_EVENT = { + type: "event", + name: "CanonicalRollupUpdated", + inputs: [ + { name: "instance", type: "address", indexed: true }, + { name: "version", type: "uint256", indexed: true }, + ], +} as const; + +const rollupAbi = parseAbi([ + "function getSequencerRewards(address) view returns (uint256)", + "function isRewardsClaimable() view returns (bool)", + "function getVersion() view returns (uint256)", + "function getFeeAsset() view returns (address)", +]); + +const erc20Abi = parseAbi(["function balanceOf(address) view returns (uint256)"]); + +const publicClient = createPublicClient({ chain: mainnet, transport: http(RPC_URL) }); +const testClient = createTestClient({ chain: mainnet, mode: "anvil", transport: http(RPC_URL) }); + +function mappingSlot(key: Address, baseSlot: bigint): Hex { + return keccak256( + encodeAbiParameters([{ type: "address" }, { type: "uint256" }], [key, baseSlot]), + ); +} + +async function discoverRollups(): Promise<{ address: Address; version: bigint; blockNumber: bigint }[]> { + const logs = await publicClient.getLogs({ + address: REGISTRY_ADDRESS, + event: CANONICAL_UPDATED_EVENT, + fromBlock: REGISTRY_DEPLOY_BLOCK, + toBlock: "latest", + }); + + // Dedupe by rollup address. Keep oldest event per rollup (first canonical). + const seen = new Map(); + for (const log of logs as Log[]) { + const args = (log as unknown as { args: { instance: Address; version: bigint } }).args; + const addr = args.instance.toLowerCase() as Address; + if (!seen.has(addr)) { + seen.set(addr, { + address: args.instance, + version: args.version, + blockNumber: log.blockNumber!, + }); + } + } + return [...seen.values()].sort((a, b) => Number(a.blockNumber - b.blockNumber)); +} + +async function main() { + console.log(`\nSeeding multi-rollup rewards on fork`); + console.log(` RPC: ${RPC_URL}`); + console.log(` target: ${TARGET_ADDRESS}\n`); + + const chainId = await publicClient.getChainId(); + if (chainId !== 1) { + throw new Error(`expected chainId 1 (mainnet fork), got ${chainId}`); + } + + const rollups = await discoverRollups(); + if (rollups.length === 0) { + throw new Error(`no CanonicalRollupUpdated events found on Registry ${REGISTRY_ADDRESS}`); + } + console.log(`Discovered ${rollups.length} rollup(s) from Registry events:`); + for (const r of rollups) { + console.log(` - ${r.address} (version=${r.version}, block=${r.blockNumber})`); + } + console.log(); + + if (REWARD_AMOUNTS.length < rollups.length) { + console.warn( + `Note: ${rollups.length} rollups discovered but only ${REWARD_AMOUNTS.length} reward amount(s) given; ` + + `remaining rollups will be seeded with the last value (${REWARD_AMOUNTS[REWARD_AMOUNTS.length - 1]}).`, + ); + } + + const slot = mappingSlot(TARGET_ADDRESS, SEQUENCER_REWARDS_SLOT); + for (let i = 0; i < rollups.length; i++) { + const rollup = rollups[i]; + const amount = REWARD_AMOUNTS[Math.min(i, REWARD_AMOUNTS.length - 1)] * 10n ** 18n; + + await testClient.setStorageAt({ + address: rollup.address, + index: slot, + value: numberToHex(amount, { size: 32 }), + }); + + // Verify via the contract getter (covers both slot calc and the fork actually accepting the write) + const got = await publicClient.readContract({ + address: rollup.address, + abi: rollupAbi, + functionName: "getSequencerRewards", + args: [TARGET_ADDRESS], + }); + if (got !== amount) { + throw new Error( + `verification failed for ${rollup.address}: wrote ${amount} but getSequencerRewards returned ${got}`, + ); + } + + // Sanity: claimSequencerRewards transfers fee asset from the rollup, so the + // rollup needs to hold at least `amount` of it. On a fresh mainnet fork this + // is true (rollups already hold the AZTEC token). Warn if it's not. + const feeAsset = await publicClient.readContract({ + address: rollup.address, + abi: rollupAbi, + functionName: "getFeeAsset", + }); + const feeBal = await publicClient.readContract({ + address: feeAsset, + abi: erc20Abi, + functionName: "balanceOf", + args: [rollup.address], + }); + const claimable = await publicClient.readContract({ + address: rollup.address, + abi: rollupAbi, + functionName: "isRewardsClaimable", + }); + + console.log( + ` ✓ ${rollup.address} rewards=${amount} claimable=${claimable} ` + + `feeBal=${feeBal} ${feeBal < amount ? "(LOW — claim will revert)" : ""}`, + ); + } + + const lsKey = `rewards_coinbase_addresses_${TARGET_ADDRESS.toLowerCase()}`; + const lsValue = JSON.stringify([TARGET_ADDRESS.toLowerCase()]); + console.log(`\nDone. In the dashboard's DevTools console, paste:`); + console.log(` localStorage.setItem('${lsKey}', '${lsValue}'); location.reload();`); +} + +main().catch((err) => { + console.error(`\nError: ${err.message ?? err}\n`); + process.exit(1); +}); diff --git a/staking-dashboard/scripts/multi-rollup-test/setup-fork.sh b/staking-dashboard/scripts/multi-rollup-test/setup-fork.sh new file mode 100755 index 000000000..6e67a3e15 --- /dev/null +++ b/staking-dashboard/scripts/multi-rollup-test/setup-fork.sh @@ -0,0 +1,141 @@ +#!/bin/bash +# Set up a forked-mainnet anvil environment for end-to-end multi-rollup testing. +# +# Assumes anvil is already running at http://127.0.0.1:8545 forking mainnet. +# Generates atp-indexer/.env and staking-dashboard/.env pointing at the local +# anvil + local indexer, then seeds sequencer rewards for the target address +# on every rollup the Registry has ever made canonical. +# +# Usage: +# bash staking-dashboard/scripts/multi-rollup-test/setup-fork.sh +# +# Env overrides: +# TARGET_ADDRESS address to seed rewards for (default: anvil account 0) +# RPC_URL anvil RPC URL (default: http://127.0.0.1:8545) +# INDEXER_PORT port for the indexer API (default: 42068) + +set -eu + +ROOT=$(git rev-parse --show-toplevel) +INDEXER=$ROOT/atp-indexer +DASHBOARD=$ROOT/staking-dashboard +ADDRS_FILE=$INDEXER/contract_addresses.json + +TARGET_ADDRESS="${TARGET_ADDRESS:-0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266}" +RPC_URL="${RPC_URL:-http://127.0.0.1:8545}" +INDEXER_PORT="${INDEXER_PORT:-42068}" + +source "$ROOT/scripts/logging.sh" + +# ---------- 1. Verify anvil ---------- +log_step "Checking anvil at $RPC_URL..." +ANVIL_CHAIN_ID=$(cast chain-id --rpc-url "$RPC_URL" 2>/dev/null || echo "") +if [ -z "$ANVIL_CHAIN_ID" ]; then + echo "Error: could not reach anvil at $RPC_URL" + exit 1 +fi +if [ "$ANVIL_CHAIN_ID" != "1" ]; then + echo "Error: anvil chain id is $ANVIL_CHAIN_ID, expected 1 (mainnet fork)." + echo "Restart anvil with: anvil --fork-url \$MAINNET_RPC --fork-block-number " + exit 1 +fi +ANVIL_BLOCK=$(cast block-number --rpc-url "$RPC_URL") +log_success " anvil OK: chain $ANVIL_CHAIN_ID @ block $ANVIL_BLOCK" + +# ---------- 2. Validate addresses file ---------- +if [ ! -f "$ADDRS_FILE" ]; then + echo "Error: $ADDRS_FILE not found" + exit 1 +fi + +ATP_FACTORY=$(jq -r '.atpFactory' "$ADDRS_FILE") +ATP_FACTORY_AUCTION=$(jq -r '.atpFactoryAuction' "$ADDRS_FILE") +ATP_FACTORY_MATP=$(jq -r '.atpFactoryMatp' "$ADDRS_FILE") +ATP_FACTORY_LATP=$(jq -r '.atpFactoryLatp' "$ADDRS_FILE") +ATP_REGISTRY=$(jq -r '.atpRegistry' "$ADDRS_FILE") +ATP_REGISTRY_AUCTION=$(jq -r '.atpRegistryAuction' "$ADDRS_FILE") +STAKING_REGISTRY=$(jq -r '.stakingRegistry' "$ADDRS_FILE") +REGISTRY=$(jq -r '.registryAddress' "$ADDRS_FILE") +ATP_FACTORY_BLOCK=$(jq -r '.atpFactoryDeploymentBlock' "$ADDRS_FILE") +REGISTRY_BLOCK=$(jq -r '.registryDeploymentBlock' "$ADDRS_FILE") +ATP_STAKER=$(jq -r '.atpWithdrawableAndClaimableStaker' "$ADDRS_FILE") +GENESIS_SALE=$(jq -r '.genesisSequencerSale' "$ADDRS_FILE") +GOVERNANCE=$(jq -r '.governanceAddress' "$ADDRS_FILE") +GSE=$(jq -r '.gseAddress' "$ADDRS_FILE") + +# ---------- 3. Write atp-indexer/.env ---------- +log_step "Writing $INDEXER/.env (start blocks pinned to mainnet deployment)..." +[ -f "$INDEXER/.env" ] && cp "$INDEXER/.env" "$INDEXER/.env.pre-fork" +cat > "$INDEXER/.env" < "$DASHBOARD/.env" < { - if (!isProcessingInBatch) return 'Claim Rewards' - - // Show completed message if available - if (claimAllHook.completedMessage) return claimAllHook.completedMessage - // Show skip message if available - if (claimAllHook.skipMessage) return claimAllHook.skipMessage - - if (claimAllHook.currentStep === 'claiming') return 'Claiming' - if (claimAllHook.currentStep === 'distributing') return 'Distributing' - return 'Withdrawing' - } - return (
{/* Collapsible Header */} @@ -137,7 +119,7 @@ export const ATPDetailsDelegationItem = ({ isLoading={isLoadingStatus} isUnstaked={isUnstaked} isInQueue={isInQueue} - slashCount={slashCount} + lossPercentage={lossPercentage} isAtRisk={isAtRisk} /> )} @@ -419,7 +401,8 @@ export const ATPDetailsDelegationItem = ({ activationThreshold={activationThreshold} ejectionThreshold={ejectionThreshold} healthPercentage={healthPercentage} - slashCount={slashCount} + lossAmount={lossAmount} + lossPercentage={lossPercentage} isAtRisk={isAtRisk} isCritical={isCritical} isLoading={isLoadingHealth} @@ -446,26 +429,17 @@ export const ATPDetailsDelegationItem = ({ providerTakeRate: delegation.providerTakeRate, providerRewardsRecipient: delegation.providerRewardsRecipient })} - disabled={delegationRewards.userRewards === 0n || isInBatch || isRewardsClaimable === false} + disabled={delegationRewards.userRewards === 0n || isRewardsClaimable === false} className="px-3 py-1.5 border font-oracle-standard text-xs font-bold uppercase tracking-wide whitespace-nowrap transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-parchment/10 disabled:border-parchment/30 disabled:text-parchment/60 border-chartreuse bg-chartreuse text-ink hover:bg-chartreuse/90" title={ isRewardsClaimable === false ? "Rewards are currently locked by the network protocol" : delegationRewards.userRewards === 0n ? "No rewards to claim" - : isInBatch - ? "Processing in batch" - : "Claim delegation rewards" + : "Claim delegation rewards" } > - {isProcessingInBatch ? ( -
-
- {getButtonText()} -
- ) : ( - 'Claim Rewards' - )} + Claim Rewards {(delegationRewards.userRewards === 0n || isRewardsClaimable === false) && ( )} diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx index 4f8d81426..cd5239336 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx @@ -19,7 +19,6 @@ import { ATPDetailsDelegationItem } from "./ATPDetailsDelegationItem" import { ATPDetailsLoadingState } from "./ATPDetailsLoadingState" import { ATPDetailsErrorState } from "./ATPDetailsErrorState" import { VestingGraph } from "@/components/VestingSchedule" -import { ClaimAllProvider } from "@/contexts/ClaimAllContext" import { ClaimAllDelegationRewardsButton } from "@/components/ClaimAllDelegationRewardsButton" import { ClaimDelegationRewardsModal, type DelegationModalData } from "@/components/ClaimDelegationRewardsModal" import type { ATPData } from "@/hooks/atp" @@ -272,7 +271,7 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef const milestoneId = isMATPData(atp) ? atp.milestoneId : undefined; return createPortal( - + <>
)} - , + , document.body ) } \ No newline at end of file diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx index f036b4d82..d540eb7f8 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverview.tsx @@ -11,7 +11,6 @@ import { ATPStakingOverviewTotalStaked } from "./ATPStakingOverviewTotalStaked" import { ATPStakingOverviewStakeableAmount } from "./ATPStakingOverviewStakeableAmount" import { ATPStakingOverviewClaimableRewards } from "./ATPStakingOverviewClaimableRewards" import { ATPStakingOverviewBreakdownSection } from "./ATPStakingOverviewBreakdownSection" -import { ClaimAllProvider } from "@/contexts/ClaimAllContext" import type { ATPData } from "@/hooks/atp" import type { Address } from "viem" import { calculateStakeableAmount } from "@/hooks/atp/useStakeableAmount" @@ -131,8 +130,7 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv } return ( - -
+
{/* Total Allocation, Staked, Stakeable, and Rewards Row */}
@@ -216,7 +214,6 @@ export const ATPStakingOverview = ({ atpData, walletBalance = 0n }: ATPStakingOv onClose={() => setSelectedATP(null)} /> )} -
- +
) } diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx index 36afae9af..3d3d17007 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx @@ -229,12 +229,18 @@ export const ATPStakingOverviewBreakdownSection = ({ providerTakeRate: d.providerTakeRate, providerRewardsRecipient: d.providerRewardsRecipient as `0x${string}`, rewards: d.rewards, + rollupRewardsByRollup: d.rollupRewardsByRollup, + providerName: d.providerName, + providerId: d.providerId, })), ...filteredErc20Delegations.map(d => ({ splitContract: d.splitContract as `0x${string}`, providerTakeRate: d.providerTakeRate, providerRewardsRecipient: d.providerRewardsRecipient as `0x${string}`, rewards: d.rewards, + rollupRewardsByRollup: d.rollupRewardsByRollup, + providerName: d.providerName, + providerId: d.providerId, })) ]} onSuccess={handleRefetchAll} diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewDelegationItem.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewDelegationItem.tsx index 343b18bcd..f94bf814b 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewDelegationItem.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewDelegationItem.tsx @@ -5,10 +5,8 @@ import { formatTokenAmount } from "@/utils/atpFormatters" import { getValidatorDashboardValidatorUrl } from "@/utils/validatorDashboardUtils" import { getExplorerTxUrl } from "@/utils/explorerUtils" import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" -import { useClaimAllContext } from "@/contexts/ClaimAllContext" import type { ATPData } from "@/hooks/atp" import type { DelegationBreakdown, Erc20DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" -import type { Address } from "viem" interface ATPStakingOverviewDelegationItemProps { delegation: DelegationBreakdown | Erc20DelegationBreakdown @@ -34,27 +32,9 @@ export const ATPStakingOverviewDelegationItem = ({ onWalletClick, onClaimClick }: ATPStakingOverviewDelegationItemProps) => { - const { getSplitStatus, claimAllHook } = useClaimAllContext() const { isRewardsClaimable } = useIsRewardsClaimable() const isWallet = variant === 'wallet' - const splitStatus = getSplitStatus(delegation.splitContract as Address) - const isInBatch = splitStatus !== 'idle' - const isProcessingInBatch = splitStatus === 'processing' - - const getButtonText = () => { - if (!isProcessingInBatch) return 'Claim' - - // Show completed message if available - if (claimAllHook.completedMessage) return claimAllHook.completedMessage - // Show skip message if available - if (claimAllHook.skipMessage) return claimAllHook.skipMessage - - if (claimAllHook.currentStep === 'claiming') return 'Claiming' - if (claimAllHook.currentStep === 'distributing') return 'Distributing' - return 'Withdrawing' - } - return (
@@ -152,26 +132,17 @@ export const ATPStakingOverviewDelegationItem = ({
{isRewardsClaimable === false && ( + providerName?: string | null + providerId?: number } interface ClaimAllDelegationRewardsButtonProps { @@ -19,105 +33,138 @@ interface ClaimAllDelegationRewardsButtonProps { } /** - * Button component for claiming all delegation rewards at once - * Processes claims sequentially to avoid race conditions + * Adds every delegation's claim flow (claim per rollup with balance → + * distribute) plus a single warehouse withdraw at the end to the transaction + * cart. Routes through the same `buildDelegationClaimEntries` helper as the + * per-delegation button and the "Claim All Rewards" modal, so all three + * produce identical cart entries given identical inputs. */ export const ClaimAllDelegationRewardsButton = ({ delegations, - onSuccess + onSuccess, }: ClaimAllDelegationRewardsButtonProps) => { - const { address: beneficiary } = useAccount() // TODO : should get the address from atp.beneficiary to handle the condition where the connected address is operator - const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { address: beneficiary } = useAccount() + const { stakingAssetAddress: tokenAddress, decimals, symbol } = useStakingAssetTokenDetails() const { isRewardsClaimable } = useIsRewardsClaimable() - const { claimAllHook } = useClaimAllContext() - const { showAlert } = useAlert() + const { addTransaction, checkTransactionInQueue, openCart, replaceTransactionByTx } = useTransactionCart() - // Get delegations with rewards - const delegationsWithRewards = useMemo( - () => delegations.filter(d => d.rewards > 0n), - [delegations] - ) - - // Call onSuccess when all claims complete - useEffect(() => { - if (!claimAllHook.isProcessing && claimAllHook.completedCount > 0) { - onSuccess?.() - } - }, [claimAllHook.isProcessing, claimAllHook.completedCount, onSuccess]) + // Warehouse is the same per-token across all delegations; resolve from the first one. + const firstSplit = delegations[0]?.splitContract + const { warehouseAddress } = useSplitsWarehouse(firstSplit) - // Handle errors - useEffect(() => { - if (claimAllHook.error) { - const errorMessage = claimAllHook.error.message - if (errorMessage.includes('User rejected') || errorMessage.includes('rejected')) { - showAlert('warning', 'Transaction was cancelled') - } - } - }, [claimAllHook.error, showAlert]) - - const handleClaimAll = () => { - if (!tokenAddress || !beneficiary || delegationsWithRewards.length === 0) return + // Filter to delegations that have *anything* claimable — canonical or stranded. + const claimableDelegations = useMemo(() => { + const canonicalRollup = contracts.rollup.address.toLowerCase() + return delegations.filter((d) => { + const perRollup = d.rollupRewardsByRollup ?? [] + return perRollup.some((r) => r.rewards > 0n) + || (d.rewards > 0n && !perRollup.some((r) => r.rollupAddress.toLowerCase() === canonicalRollup)) + }) + }, [delegations]) - // Build claim tasks - const tasks = delegationsWithRewards.map(delegation => { - const totalAllocation = 10000n - const providerAllocation = BigInt(delegation.providerTakeRate) - const userAllocation = totalAllocation - providerAllocation + const isReady = !!tokenAddress && !!beneficiary && !!warehouseAddress - return { - splitContract: delegation.splitContract as Address, - splitData: { - recipients: [delegation.providerRewardsRecipient as Address, beneficiary], - allocations: [providerAllocation, userAllocation], - totalAllocation, - distributionIncentive: 0 - }, + // Build the candidate entries up-front so we can detect "in batch" and dedupe. + // + // `entries` are per-delegation (claims + distribute, unique calldata per + // entry). `withdraw` is shared across all delegations for the same + // user/token, so its raw signature collides with any prior withdraw the + // user queued from another entry point. We split them so: + // - `isInBatch` only considers `entries` (a sibling delegation's + // withdraw shouldn't lock this button). + // - On add we route `withdraw` through `replaceTransactionByTx` so a + // newly-built withdraw (wired to the LATEST distribute group) cleanly + // supersedes any prior one. + const built = useMemo(() => { + if (!isReady || !tokenAddress || !beneficiary || !warehouseAddress) { + return { entries: [] as ClaimCartEntry[], withdraw: null as ClaimCartEntry | null } + } + const entries: ClaimCartEntry[] = [] + let lastDistributeGroup: string | null = null + for (const d of claimableDelegations) { + const providerLabel = d.providerName ?? (d.providerId !== undefined ? `Provider ${d.providerId}` : "delegation") + const { entries: delegationEntries, distributeGroup } = buildDelegationClaimEntries({ + splitContract: d.splitContract, + providerTakeRate: d.providerTakeRate, + providerRewardsRecipient: d.providerRewardsRecipient, + providerLabel, + rollupRewardsByRollup: d.rollupRewardsByRollup ?? [], + beneficiary, tokenAddress, - userAddress: beneficiary, - onSuccess - } - }) + decimals: decimals ?? 18, + symbol: symbol ?? "", + }) + entries.push(...delegationEntries) + if (distributeGroup) lastDistributeGroup = distributeGroup + } + const withdraw = lastDistributeGroup + ? buildWarehouseWithdrawEntry({ + warehouseAddress, + beneficiary, + tokenAddress, + dependsOnDistributeGroup: lastDistributeGroup, + }) + : null + return { entries, withdraw } + }, [claimableDelegations, isReady, tokenAddress, beneficiary, warehouseAddress, decimals, symbol]) + + // "In batch" requires every entry this button would queue to already be in + // the cart — per-delegation claims/distributes AND the warehouse withdraw + // (treated as a shared singleton; any queued withdraw counts because there + // is only one). Using `.every()` keeps the button actionable when state + // changes add a new candidate (a fresh rollup balance, a new delegation): + // those new entries get queued on the next click instead of being locked + // out by a single already-queued entry. + const candidates = built.withdraw ? [...built.entries, built.withdraw] : built.entries + const isInBatch = candidates.length > 0 + && candidates.every((e) => checkTransactionInQueue(e.transaction)) - claimAllHook.claimAll(tasks) + const handleAddAllToBatch = () => { + for (const entry of built.entries) { + addTransaction(entry, { preventDuplicate: true }) + } + if (built.withdraw) { + replaceTransactionByTx(built.withdraw.transaction, built.withdraw) + } + onSuccess?.() + openCart() } - if (delegationsWithRewards.length === 0 || isRewardsClaimable === false) { + if (claimableDelegations.length === 0 || isRewardsClaimable === false) { return null } + const handleClick = () => { + if (isInBatch) { + openCart() + return + } + handleAddAllToBatch() + } + return ( - <> - {claimAllHook.error && !(claimAllHook.error.message.includes('User rejected') || claimAllHook.error.message.includes('rejected')) && ( -
-
Transaction Error
-
- {claimAllHook.error.message || 'An error occurred during batch claim'} -
-
- )} - - ) } diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index ae3555689..5601bf7a5 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -1,17 +1,21 @@ -import { useState, useEffect } from "react" import { createPortal } from "react-dom" +import { useAccount } from "wagmi" import { Icon } from "@/components/Icon" -import { useClaimAllRewards } from "@/hooks/rewards" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" +import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" import { ClaimAllRewardsSummary } from "./ClaimAllRewardsSummary" -import { ClaimAllRewardsProgress } from "./ClaimAllRewardsProgress" -import { ClaimAllRewardsSuccess } from "./ClaimAllRewardsSuccess" +import { useTransactionCart } from "@/contexts/TransactionCartContext" +import { useAlert } from "@/contexts/AlertContext" +import { + buildDelegationClaimEntries, + buildCoinbaseClaimEntry, + buildWarehouseWithdrawEntry, + type ClaimCartEntry, +} from "@/utils/claimCart" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" import type { CoinbaseBreakdown } from "@/hooks/rewards/rewardsTypes" -type ModalPhase = 'summary' | 'progress' | 'success' - interface ClaimAllRewardsModalProps { isOpen: boolean onClose: () => void @@ -22,8 +26,11 @@ interface ClaimAllRewardsModalProps { } /** - * Modal for claiming all rewards in a unified flow - * Handles both delegation rewards (3-step) and self-stake rewards (1-step) + * Fans every claim leg (delegation per-rollup claims/distribute, coinbase + * claims, and a single warehouse withdraw at the end) into the transaction + * cart with `dependsOn` wiring, then opens the cart. Routes through the + * shared `claimCart` helpers so this matches the per-delegation and bulk + * delegation entry points entry-for-entry. */ export const ClaimAllRewardsModal = ({ isOpen, @@ -31,73 +38,99 @@ export const ClaimAllRewardsModal = ({ delegations, coinbases, pendingWarehouseWithdrawal = 0n, - onSuccess + onSuccess, }: ClaimAllRewardsModalProps) => { - const [phase, setPhase] = useState('summary') - - // Token details - const { symbol, decimals } = useStakingAssetTokenDetails() - - // Check if rewards are claimable + const { address: beneficiary } = useAccount() + const { symbol, decimals, stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() const { isRewardsClaimable } = useIsRewardsClaimable() + const { addTransaction, openCart, replaceTransactionByTx } = useTransactionCart() + const { showAlert } = useAlert() - // Claim hook - const claimAllRewards = useClaimAllRewards() + // Resolve warehouse via the first delegation's split contract. All splits + // funnel into the same SplitsWarehouse for a given token, so any split works. + const firstSplit = delegations.find((d) => d.rewards > 0n)?.splitContract ?? delegations[0]?.splitContract + const { warehouseAddress } = useSplitsWarehouse(firstSplit) - // Reset phase when modal opens - useEffect(() => { - if (isOpen) { - setPhase('summary') - claimAllRewards.reset() + const handleAddAllToBatch = () => { + if (!tokenAddress || !beneficiary) { + showAlert("error", "Wallet or token address not ready") + return } - }, [isOpen]) - // Transition to progress when claiming starts - useEffect(() => { - if (claimAllRewards.isProcessing && phase === 'summary') { - setPhase('progress') + const entriesToAdd: ClaimCartEntry[] = [] + let lastDistributeGroup: string | null = null + + // Per-delegation entries from the shared helper. + for (const d of delegations) { + const providerLabel = d.providerName ?? `Provider ${d.providerId}` + const { entries, distributeGroup } = buildDelegationClaimEntries({ + splitContract: d.splitContract, + providerTakeRate: d.providerTakeRate, + providerRewardsRecipient: d.providerRewardsRecipient, + providerLabel, + rollupRewardsByRollup: d.rollupRewardsByRollup ?? [], + beneficiary, + tokenAddress, + decimals: decimals ?? 18, + symbol: symbol ?? "", + }) + entriesToAdd.push(...entries) + if (distributeGroup) lastDistributeGroup = distributeGroup } - }, [claimAllRewards.isProcessing, phase]) - // Transition to success when all done - useEffect(() => { - if (claimAllRewards.isSuccess && phase === 'progress') { - setPhase('success') - onSuccess?.() + // Per-coinbase entries. + for (const c of coinbases) { + if (c.rewards === 0n) continue + entriesToAdd.push( + buildCoinbaseClaimEntry({ + coinbase: c.address, + rollupAddress: c.rollupAddress, + rollupVersion: c.rollupVersion, + rewards: c.rewards, + decimals: decimals ?? 18, + symbol: symbol ?? "", + }), + ) } - }, [claimAllRewards.isSuccess, phase, onSuccess]) - const handleClose = () => { - if (claimAllRewards.isProcessing) { - // Don't allow closing while processing - user must cancel - return + // Per-delegation + per-coinbase entries have unique calldata; safe to add + // with `preventDuplicate`. Subsequent "Add All" clicks won't duplicate + // them. + for (const entry of entriesToAdd) { + addTransaction(entry, { preventDuplicate: true }) } - claimAllRewards.reset() - onClose() - } - const handleBackdropClick = (e: React.MouseEvent) => { - if (e.target === e.currentTarget && !claimAllRewards.isProcessing) { - handleClose() + // The warehouse withdraw's calldata is identical across delegations for + // the same user/token. Using `addTransaction` with `preventDuplicate` + // would silently drop it on a re-add and leave a stale dependency on a + // previous distribute group — stranding any newly-added delegation's + // share in the warehouse. Route it through `replaceTransactionByTx` so + // the fresh entry (wired to the LATEST distribute group) supersedes any + // prior withdraw. + const needsWithdraw = lastDistributeGroup !== null || pendingWarehouseWithdrawal > 0n + if (needsWithdraw && warehouseAddress) { + const withdrawEntry = buildWarehouseWithdrawEntry({ + warehouseAddress, + beneficiary, + tokenAddress, + dependsOnDistributeGroup: lastDistributeGroup, + }) + replaceTransactionByTx(withdrawEntry.transaction, withdrawEntry) } - } - - const handleStartClaiming = () => { - claimAllRewards.startClaiming(delegations, coinbases) - } - const handleCancel = () => { - claimAllRewards.cancelClaiming() - setPhase('summary') + onSuccess?.() + openCart() + onClose() } - const handleRetry = () => { - claimAllRewards.retryFailed() + const handleClose = () => { + onClose() } - const handleDone = () => { - claimAllRewards.reset() - onClose() + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + handleClose() + } } if (!isOpen) return null @@ -108,83 +141,41 @@ export const ClaimAllRewardsModal = ({ onClick={handleBackdropClick} >
- {/* Close button - only show when not processing */} - {!claimAllRewards.isProcessing && ( - - )} +
- {/* Header */}
- +

- {phase === 'success' ? 'Rewards Claimed' : - phase === 'progress' ? 'Claiming Rewards' : - 'Claim All Rewards'} + Claim All Rewards

- {phase === 'success' ? 'Your rewards have been successfully claimed.' : - phase === 'progress' ? 'Processing your claims. Please approve each transaction.' : - 'Review and claim all your available rewards.'} + Review your rewards below, then add them all to the transaction batch. The cart panel handles execution.

- {/* Content */} - {phase === 'summary' && ( - - )} - - {phase === 'progress' && ( - - )} - - {phase === 'success' && ( - - )} +
, - document.body + document.body, ) } diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsProgress.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsProgress.tsx deleted file mode 100644 index 721a9205c..000000000 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsProgress.tsx +++ /dev/null @@ -1,226 +0,0 @@ -import { Icon } from "@/components/Icon" -import { formatTokenAmountFull } from "@/utils/atpFormatters" -import type { ClaimTask } from "@/hooks/rewards/useClaimAllRewards" - -interface ClaimAllRewardsProgressProps { - tasks: ClaimTask[] - currentTask: ClaimTask | null - progressPercent: number - decimals: number - symbol: string - onCancel: () => void - isError: boolean - error: Error | null - onRetry: () => void -} - -/** - * Progress view showing claiming status for each task - */ -export const ClaimAllRewardsProgress = ({ - tasks, - currentTask, - progressPercent, - decimals, - symbol, - onCancel, - isError, - error, - onRetry -}: ClaimAllRewardsProgressProps) => { - const completedCount = tasks.filter(t => t.status === 'completed').length - - const getSubStepText = (subStep?: string) => { - switch (subStep) { - case 'claiming': return 'Claiming from rollup...' - case 'distributing': return 'Distributing to warehouse...' - case 'withdrawing': return 'Withdrawing to wallet...' - default: return 'Processing...' - } - } - - const getStatusIcon = (status: ClaimTask['status']) => { - switch (status) { - case 'completed': - return - case 'processing': - return - case 'error': - return - case 'skipped': - return - default: - return
- } - } - - return ( -
- {/* Overall Progress */} -
-
- - Progress: {completedCount} of {tasks.length} claims - - {progressPercent}% -
-
-
-
-
- - {/* Error Banner */} - {isError && error && ( -
-
- -
-

Transaction Failed

-

- {error.message.includes('rejected') - ? 'Transaction was rejected. You can retry or cancel.' - : error.message} -

-
-
-
- )} - - {/* Task List */} -
- {tasks.map((task) => ( -
-
- {/* Status Icon */} -
- {getStatusIcon(task.status)} -
- - {/* Task Info */} -
-
- - {task.type === 'delegation' ? 'Delegation' : 'Coinbase'} - - - {task.displayName} - -
- - {/* Sub-step for processing delegations */} - {task.status === 'processing' && task.type === 'delegation' && ( -
- {getSubStepText(task.currentSubStep)} -
- )} - - {/* Processing indicator for coinbase */} - {task.status === 'processing' && task.type === 'coinbase' && ( -
- Claiming rewards... -
- )} - - {/* Error message */} - {task.status === 'error' && task.error && ( -
- {task.error.message.includes('rejected') ? 'Rejected by user' : 'Failed'} -
- )} -
- - {/* Rewards Amount */} -
- - {formatTokenAmountFull(task.estimatedRewards, decimals, symbol)} - -
-
-
- ))} -
- - {/* Current Task Detail */} - {currentTask && !isError && ( -
-
- -
-

- Claiming from {currentTask.displayName} -

- {currentTask.type === 'delegation' && currentTask.currentSubStep && ( -

- Step {currentTask.currentSubStep === 'claiming' ? '1' : currentTask.currentSubStep === 'distributing' ? '2' : '3'} of 3: {getSubStepText(currentTask.currentSubStep)} -

- )} - {currentTask.type === 'coinbase' && ( -

- Waiting for transaction confirmation... -

- )} -
-
-
- )} - - {/* Action Buttons */} -
- {isError ? ( - <> - - - - ) : ( - - )} -
- - {/* Info */} - {!isError && ( -

- Please approve each transaction in your wallet. Do not close this modal. -

- )} -
- ) -} diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSuccess.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSuccess.tsx deleted file mode 100644 index 2b0fff579..000000000 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSuccess.tsx +++ /dev/null @@ -1,129 +0,0 @@ -import { Icon } from "@/components/Icon" -import { formatTokenAmountFull } from "@/utils/atpFormatters" -import type { ClaimTask } from "@/hooks/rewards/useClaimAllRewards" - -interface ClaimAllRewardsSuccessProps { - completedTasks: ClaimTask[] - decimals: number - symbol: string - onClose: () => void -} - -/** - * Success view after all rewards have been claimed - */ -export const ClaimAllRewardsSuccess = ({ - completedTasks, - decimals, - symbol, - onClose -}: ClaimAllRewardsSuccessProps) => { - const totalClaimed = completedTasks.reduce((sum, task) => sum + task.estimatedRewards, 0n) - - const delegationsClaimed = completedTasks.filter(t => t.type === 'delegation') - const coinbasesClaimed = completedTasks.filter(t => t.type === 'coinbase') - - const delegationTotal = delegationsClaimed.reduce((sum, t) => sum + t.estimatedRewards, 0n) - const coinbaseTotal = coinbasesClaimed.reduce((sum, t) => sum + t.estimatedRewards, 0n) - - return ( -
- {/* Success Icon */} -
-
- -
-

- Rewards Claimed! -

-

- All rewards have been successfully claimed to your wallet. -

-
- - {/* Total Claimed */} -
-
- Total Claimed -
-
- {formatTokenAmountFull(totalClaimed, decimals, symbol)} -
-
- - {/* Breakdown */} -
- {/* Delegation Summary */} - {delegationsClaimed.length > 0 && ( -
-
-
- - - {delegationsClaimed.length} Delegation{delegationsClaimed.length > 1 ? 's' : ''} - -
- - {formatTokenAmountFull(delegationTotal, decimals, symbol)} - -
-
- {delegationsClaimed.map(task => ( -
- {task.displayName} - - {formatTokenAmountFull(task.estimatedRewards, decimals, symbol)} - -
- ))} -
-
- )} - - {/* Coinbase Summary */} - {coinbasesClaimed.length > 0 && ( -
-
-
- - - {coinbasesClaimed.length} Coinbase{coinbasesClaimed.length > 1 ? 's' : ''} - -
- - {formatTokenAmountFull(coinbaseTotal, decimals, symbol)} - -
-
- {coinbasesClaimed.map(task => ( -
- {task.displayName} - - {formatTokenAmountFull(task.estimatedRewards, decimals, symbol)} - -
- ))} -
-
- )} -
- - {/* Info Text */} -
-

- Delegation rewards have been withdrawn to your wallet. -
- Self-stake rewards have been sent to your coinbase address. -

-
- - {/* Done Button */} - -
- ) -} diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx index 9237690da..933cdaf54 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx @@ -41,15 +41,18 @@ export const ClaimAllRewardsSummary = ({ return (
- {/* Rewards Locked Warning */} + {/* Configured-rollup locked banner — informational only. Per-task rollups handle + their own gating, so claims on other rollups may still succeed; failed tasks + surface via the engine's retry flow. */} {!isRewardsClaimable && (
-

Rewards Locked

+

Configured Rollup Locked

- Rewards are currently locked and cannot be claimed. Check back later. + Rewards on the configured rollup are currently locked. Claims targeting + other rollup versions may still succeed; failed tasks will be marked individually.

@@ -80,6 +83,16 @@ export const ClaimAllRewardsSummary = ({ ? (delegation.rewards * 10000n) / BigInt(10000 - delegation.providerTakeRate) : 0n + // One claim per rollup with balance, plus distribute + a single + // warehouse withdraw shared across the whole batch. + const claimsPerRollup = (delegation.rollupRewardsByRollup ?? []).filter( + (r) => r.rewards > 0n, + ) + const totalTxs = claimsPerRollup.length + 1 // claims + distribute + const claimsLabel = claimsPerRollup + .map((r) => `claim v${r.rollupVersion}`) + .join(", ") + return (
- 3 transactions: claim, distribute, withdraw + {totalTxs} transactions: {claimsLabel ? `${claimsLabel}, ` : ""}distribute (+ shared withdraw)
) @@ -125,11 +138,11 @@ export const ClaimAllRewardsSummary = ({
{coinbasesWithRewards.map((coinbase) => (
-
+
Coinbase @@ -137,6 +150,14 @@ export const ClaimAllRewardsSummary = ({ {coinbase.address.slice(0, 6)}...{coinbase.address.slice(-4)} + {coinbase.rollupVersion !== undefined && ( + + Rollup v{coinbase.rollupVersion} + + )}
{formatTokenAmountFull(coinbase.rewards, decimals, symbol)} @@ -184,23 +205,21 @@ export const ClaimAllRewardsSummary = ({
)} - {/* Claim Button */} + {/* Claim Button — no longer gated by the configured rollup's `isRewardsClaimable`. + Per-task rollups handle their own gating; failed tasks surface via retry. */} {/* Transaction Info */} - {hasRewards && isRewardsClaimable && ( + {hasRewards && (

- This will require multiple transactions. Each claim will prompt for approval. + Adds every claim leg to your transaction cart with the right execution order. + Open the cart panel to review and sign.

)}
diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/index.ts b/staking-dashboard/src/components/ClaimAllRewardsModal/index.ts index 215558d55..ddc395417 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/index.ts +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/index.ts @@ -1,4 +1,2 @@ export { ClaimAllRewardsModal } from "./ClaimAllRewardsModal" export { ClaimAllRewardsSummary } from "./ClaimAllRewardsSummary" -export { ClaimAllRewardsProgress } from "./ClaimAllRewardsProgress" -export { ClaimAllRewardsSuccess } from "./ClaimAllRewardsSuccess" diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx index 738e34c8e..105f263be 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx @@ -1,165 +1,181 @@ -import { useEffect, useMemo } from "react" import { useAccount } from "wagmi" -import { useClaimSplitRewards } from "@/hooks/splits" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" -import { useAlert } from "@/contexts/AlertContext" -import { useSequencerRewards } from "@/hooks/rollup/useSequencerRewards" import { useERC20Balance } from "@/hooks/erc20/useERC20Balance" import { useWarehouseBalance } from "@/hooks/splits/useWarehouseBalance" import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" +import { useTransactionCart } from "@/contexts/TransactionCartContext" +import { Icon } from "@/components/Icon" +import { + buildDelegationClaimEntries, + buildWarehouseWithdrawEntry, + type ClaimCartEntry, +} from "@/utils/claimCart" import type { Address } from "viem" interface ClaimDelegationRewardsButtonProps { splitContract: Address providerTakeRate: number providerRewardsRecipient: Address + /** Used in cart-entry descriptions so the user can tell entries apart at a glance. */ + providerName?: string | null + /** Full per-rollup `getSequencerRewards(splitContract)` breakdown. The helper + * picks out the canonical row and treats the rest as stranded balances to + * claim before the canonical claim. */ + rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> onSuccess?: () => void variant?: 'default' | 'modal' } /** - * Button component for claiming delegation rewards - * Handles the complete claim flow (claim → distribute → withdraw) - * Shows skip messages when steps have zero balance + * Adds the delegation claim flow to the transaction cart. Entries come from the + * shared `buildDelegationClaimEntries` helper so this matches the bulk-claim and + * "claim all" entry points exactly — same labels, descriptions, and dependsOn + * wiring. */ export const ClaimDelegationRewardsButton = ({ splitContract, providerTakeRate, providerRewardsRecipient, + providerName, + rollupRewardsByRollup, onSuccess, variant = 'default' }: ClaimDelegationRewardsButtonProps) => { - const { address: beneficiary } = useAccount() // TODO : should get the address from atp.beneficiary to handle the condition where the connected address is operator - const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() - const { showAlert } = useAlert() + const { address: beneficiary } = useAccount() + const { stakingAssetAddress: tokenAddress, decimals, symbol } = useStakingAssetTokenDetails() - // Fetch balances for skip logic - extract refetch functions const { warehouseAddress } = useSplitsWarehouse(splitContract) - const { rewards: rollupBalance, refetch: refetchRollup } = useSequencerRewards(splitContract) - const { balance: splitContractBalance, refetch: refetchSplitContract } = useERC20Balance(tokenAddress!, splitContract) - const { balance: warehouseBalance, refetch: refetchWarehouse } = useWarehouseBalance(warehouseAddress, beneficiary, tokenAddress) - - // Calculate split allocations based on provider take rate - const totalAllocation = 10000n - const providerAllocation = BigInt(providerTakeRate) - const userAllocation = totalAllocation - providerAllocation - - // Recipients order: [provider, user] - matches contract - const splitData = { - recipients: [providerRewardsRecipient, beneficiary as Address], - allocations: [providerAllocation, userAllocation], - totalAllocation, - distributionIncentive: 0 - } - - // 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]) + const { balance: splitContractBalance } = useERC20Balance(tokenAddress!, splitContract) + const { balance: warehouseBalance } = useWarehouseBalance(warehouseAddress, beneficiary, tokenAddress) + + const { addTransaction, checkTransactionInQueue, openCart, replaceTransactionByTx } = useTransactionCart() + + const providerLabel = providerName ?? "delegation" + + const currentSplitBalance = splitContractBalance ?? 0n + const currentWarehouseBalance = warehouseBalance ?? 0n + const totalRollupRewards = rollupRewardsByRollup.reduce((sum, r) => sum + r.rewards, 0n) + + const hasRewards = + totalRollupRewards > 0n || + currentSplitBalance > 0n || + currentWarehouseBalance > 0n + + const isReady = !!warehouseAddress && !!tokenAddress && !!beneficiary + const isDisabled = !isReady || !hasRewards + + // Build the candidate entries up-front (without adding) so we can detect + // whether any are already in the cart for the "in batch" indicator. + // + // `entries` (claims + distribute) have calldata unique to this delegation. + // `withdraw` is a singleton across delegations for the same user/token — + // its calldata is identical regardless of which delegation queued it. The + // `isInBatch` derivation below treats it as "in batch when present" rather + // than excluding it: with `addTransaction(..., preventDuplicate: true)` for + // entries and `replaceTransactionByTx` for withdraw, the cart always ends + // up with at most one withdraw, and whether *any* withdraw is queued is + // exactly the signal we want. + const built = (() => { + if (!isReady || !tokenAddress || !beneficiary || !warehouseAddress) { + return { entries: [], withdraw: null as ClaimCartEntry | null } + } + const { entries, distributeGroup } = buildDelegationClaimEntries({ + splitContract, + providerTakeRate, + providerRewardsRecipient, + providerLabel, + rollupRewardsByRollup, + beneficiary, + tokenAddress, + decimals: decimals ?? 18, + symbol: symbol ?? "", + }) + const withdraw = entries.length > 0 || currentWarehouseBalance > 0n + ? buildWarehouseWithdrawEntry({ + warehouseAddress, + beneficiary, + tokenAddress, + dependsOnDistributeGroup: distributeGroup, + }) + : null + return { entries, withdraw } + })() + // "In batch" requires every entry this button would queue to already be in + // the cart — claims, distribute, AND the warehouse withdraw (treated as a + // shared singleton; any queued withdraw counts because there is only one). + // Using `.every()` rather than `.some()` makes the button stay actionable + // when state changes add a new candidate (e.g. an additional rollup + // balance, or a new delegation under the bulk button) — those new entries + // get added on the next click instead of being locked out. + const candidates = built.withdraw ? [...built.entries, built.withdraw] : built.entries + const isInBatch = candidates.length > 0 + && candidates.every((e) => checkTransactionInQueue(e.transaction)) - const { - claim, - claimStep, - skipMessage, - completedMessage, - isClaiming, - isSuccess, - error - } = useClaimSplitRewards( - splitContract, - splitData, - tokenAddress!, - beneficiary as Address, - balances - ) + const buttonClass = variant === 'modal' + ? `px-6 py-3 bg-chartreuse text-ink font-oracle-standard font-bold text-sm uppercase tracking-wider hover:bg-chartreuse/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed` + : `px-3 py-1.5 border font-oracle-standard text-xs font-bold uppercase tracking-wide whitespace-nowrap transition-colors ${isDisabled + ? 'border-parchment/40 text-parchment/40 cursor-not-allowed' + : 'border-chartreuse bg-chartreuse text-ink hover:bg-chartreuse/90' + }` - // Call onSuccess callback when claim completes - useEffect(() => { - if (isSuccess && onSuccess) { - onSuccess() + const handleAddToBatch = () => { + for (const entry of built.entries) { + addTransaction(entry, { preventDuplicate: true }) } - }, [isSuccess, onSuccess]) - - // Handle errors - show all errors, not just rejections - useEffect(() => { - if (error) { - const errorMessage = error.message - if (errorMessage.includes('User rejected') || errorMessage.includes('rejected')) { - showAlert('warning', 'Transaction was cancelled') - } else { - // Show error for all other failures - showAlert('error', `Claim failed: ${errorMessage}`) - } + // Withdraw calldata is identical across delegations for the same user/token, + // so `addTransaction` with `preventDuplicate` would silently drop a second + // add — leaving the previously queued withdraw wired to an EARLIER + // delegation's distribute and stranding this delegation's distributed + // share in the warehouse. `replaceTransactionByTx` swaps any existing + // withdraw entry for the fresh one (which depends on this delegation's + // distribute group) atomically. + if (built.withdraw) { + replaceTransactionByTx(built.withdraw.transaction, built.withdraw) } - }, [error, showAlert]) - - const handleClaim = () => { - if (!tokenAddress || !beneficiary) return - claim() + onSuccess?.() + openCart() } - // Check if there are any rewards to claim - const hasRewards = (rollupBalance || 0n) > 0n || (splitContractBalance || 0n) > 0n || (warehouseBalance || 0n) > 0n - - // Button state logic - const isDisabled = isClaiming || !warehouseAddress || !hasRewards + const handleClick = () => { + if (isInBatch) { + // Same as the add-path: let the parent (e.g., modal) close itself, then + // surface the cart. Without `onSuccess` here, the modal stays open and + // obscures the cart panel. + onSuccess?.() + openCart() + return + } + handleAddToBatch() + } const getButtonText = () => { - if (isClaiming) { - // Show completed message if available - if (completedMessage) return completedMessage - // Show skip message if available - if (skipMessage) return skipMessage - - if (claimStep === 'claiming') return 'Claiming' - if (claimStep === 'distributing') return 'Distributing' - return 'Withdrawing' - } - return 'Claim' + if (!warehouseAddress) return 'Loading...' + if (!hasRewards) return 'No Rewards' + if (isInBatch) return variant === 'modal' ? 'In Batch — Open Cart' : 'In Batch' + return variant === 'modal' ? 'Add to Batch' : 'Add' } const getTitle = () => { if (!warehouseAddress) return 'Loading warehouse address...' if (!hasRewards) return 'No rewards available to claim' - if (isClaiming) { - // Show completed message in tooltip if available - if (completedMessage) return completedMessage - // Show skip message in tooltip if available - if (skipMessage) return skipMessage - - if (claimStep === 'claiming') return 'Claiming rewards from rollup...' - if (claimStep === 'distributing') return 'Distributing rewards...' - return 'Withdrawing rewards...' - } - return 'Claim delegation rewards' + if (isInBatch) return 'Already added to the transaction batch — open the cart to execute' + return 'Add the full delegation claim flow to the transaction batch' } - const buttonClass = variant === 'modal' - ? `px-6 py-3 bg-chartreuse text-ink font-oracle-standard font-bold text-sm uppercase tracking-wider hover:bg-chartreuse/90 transition-all disabled:opacity-50 disabled:cursor-not-allowed` - : `px-3 py-1.5 border font-oracle-standard text-xs font-bold uppercase tracking-wide whitespace-nowrap transition-colors ${isDisabled - ? 'border-parchment/40 text-parchment/40 cursor-not-allowed' - : 'border-chartreuse bg-chartreuse text-ink hover:bg-chartreuse/90' - }` - return ( ) diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx index 79cdf53b8..b8a3ccec4 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx @@ -1,3 +1,4 @@ +import { useMemo } from "react" import { useAccount } from "wagmi" import { createPortal } from "react-dom" import { Icon } from "@/components/Icon" @@ -7,7 +8,7 @@ import { formatTokenAmount } from "@/utils/atpFormatters" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { ClaimDelegationRewardsButton } from "@/components/ClaimDelegationRewardsButton" import { useWarehouseBalance } from "@/hooks/splits/useWarehouseBalance" -import { useSequencerRewards } from "@/hooks/rollup/useSequencerRewards" +import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewardsAcrossRollups" import { useERC20Balance } from "@/hooks/erc20/useERC20Balance" import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" import { calculateTotalUserShareFromSplitRewards, calculateUserShareFromTakeRate } from "@/utils/rewardCalculations" @@ -43,11 +44,16 @@ export const ClaimDelegationRewardsModal = ({ // Get warehouse address from split contract const { warehouseAddress } = useSplitsWarehouse(delegation.splitContract) - // Get rewards balance on rollup (step 1 - needs to be claimed to split contract) + // Fan out `getSequencerRewards(splitContract)` across every rollup. The sum + // drives the user-share calc; each non-zero per-rollup entry becomes its own + // `Claim — Rollup v` cart entry when the button below is clicked. + const perSplitQuery = useMemo(() => [delegation.splitContract], [delegation.splitContract]) const { - rewards: rollupBalance, - isLoading: isLoadingRollup - } = useSequencerRewards(delegation.splitContract) + allCoinbaseBreakdown: perRollupRows, + isLoading: isLoadingRollup, + } = useCoinbaseRewardsAcrossRollups(perSplitQuery) + const rollupBalance = perRollupRows.reduce((sum, row) => sum + row.rewards, 0n) + const claimableRollupCount = perRollupRows.filter(r => r.rewards > 0n).length // Get rewards balance on split contract (step 2 - needs to be distributed) const { @@ -83,12 +89,12 @@ export const ClaimDelegationRewardsModal = ({ const providerPercentage = (delegation.providerTakeRate / 100).toFixed(2) // Calculate user's share from each balance source using shared calculation - const userShareFromRollup = calculateUserShareFromTakeRate(rollupBalance || 0n, delegation.providerTakeRate) + const userShareFromRollup = calculateUserShareFromTakeRate(rollupBalance, delegation.providerTakeRate) const userShareFromSplitContract = calculateUserShareFromTakeRate(splitContractBalance || 0n, delegation.providerTakeRate) // Calculate total user's share after claim using shared calculation const userShare = calculateTotalUserShareFromSplitRewards( - rollupBalance || 0n, + rollupBalance, splitContractBalance || 0n, warehouseBalance || 0n, delegation.providerTakeRate @@ -161,38 +167,47 @@ export const ClaimDelegationRewardsModal = ({
- {/* Claim Flow Explanation */} -
-
- Three-Step Claim Process -
-
-
-
- 1 -
-
- Claim: Claim rewards from rollup to split contract + {/* Claim Flow Explanation. One `Claim — Rollup v` entry per rollup with + balance, plus a single distribute and one warehouse withdraw shared + across the whole batch. */} + {(() => { + const stepCount = claimableRollupCount + 2 + let stepNum = 1 + return ( +
+
+ {stepCount}-Step Claim Process
-
-
-
- 2 -
-
- Distribute: Split rewards between you ({userPercentage}%) and provider ({providerPercentage}%) -
-
-
-
- 3 -
-
- Withdraw: Transfer your share to your wallet +
+
+
+ {stepNum++} +
+
+ Claim ({claimableRollupCount}):{' '} + One transaction per rollup with a non-zero balance, moving tokens into the split contract +
+
+
+
+ {stepNum++} +
+
+ Distribute: Split rewards between you ({userPercentage}%) and provider ({providerPercentage}%) +
+
+
+
+ {stepNum++} +
+
+ Withdraw: Transfer your share to your wallet +
+
-
-
+ ) + })()} {/* Total You Will Receive */} {isLoadingBalances ? ( @@ -300,6 +315,12 @@ export const ClaimDelegationRewardsModal = ({ splitContract={delegation.splitContract} providerTakeRate={delegation.providerTakeRate} providerRewardsRecipient={delegation.providerRewardsRecipient} + providerName={delegation.providerName} + rollupRewardsByRollup={perRollupRows.map(r => ({ + rollupAddress: r.rollupAddress, + rollupVersion: r.rollupVersion ?? "?", + rewards: r.rewards, + }))} onSuccess={handleSuccess} variant="modal" /> diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 19d09f749..2fff17ebb 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -2,13 +2,15 @@ import { useState, useEffect, useMemo } from "react" import { createPortal } from "react-dom" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" -import { formatTokenAmount } from "@/utils/atpFormatters" +import { formatTokenAmount, formatTokenAmountFull } from "@/utils/atpFormatters" +import { validateAddress } from "@/utils/validateAddress" +import { RollupRewardRow } from "./RollupRewardRow" 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 { useAlert } from "@/contexts/AlertContext" +import { buildClaimSequencerRewardsTx } from "@/utils/claimCart" +import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup/useIsRewardsClaimableAcrossRollups" +import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewardsAcrossRollups" +import { useTransactionCart, ClaimStepType } from "@/contexts/TransactionCartContext" import type { ATPData } from "@/hooks/atp" import type { Address } from "viem" @@ -27,52 +29,61 @@ interface ClaimSelfStakeRewardsModalProps { } /** - * Modal for claiming self-stake rewards - * User inputs coinbase address to check and claim rewards + * Modal for claiming self-stake rewards. Fans out reward reads across every + * rollup the indexer has seen, so the user sees (and can claim) stranded + * balances on non-canonical rollups. Each row adds a single + * `claimSequencerRewards` tx to the transaction cart; the user reviews and + * executes from the cart panel. */ export const ClaimSelfStakeRewardsModal = ({ isOpen, onClose, stake, atp, - onSuccess + onSuccess, }: ClaimSelfStakeRewardsModalProps) => { const { symbol, decimals } = useStakingAssetTokenDetails() - const { showAlert } = useAlert() const [coinbaseAddress, setCoinbaseAddress] = useState("") const [hasCheckedRewards, setHasCheckedRewards] = useState(false) const [isDebouncing, setIsDebouncing] = useState(false) - const { - rewards, - isLoading: isLoadingRewards, - refetch: checkRewards - } = useSequencerRewards(coinbaseAddress) + const { addTransaction, checkTransactionInQueue, openCart } = useTransactionCart() + const isValidAddress = validateAddress(coinbaseAddress) + const coinbasesForQuery = useMemo( + () => (isValidAddress ? [coinbaseAddress as Address] : []), + [coinbaseAddress, isValidAddress], + ) + + // Per-rollup reward reads const { - claimRewards, - isPending, - isConfirming, - isSuccess, - error, - reset - } = useClaimSequencerRewards() + coinbaseBreakdown, + totalCoinbaseRewards, + isLoading: isLoadingRewards, + refetch: checkRewards, + } = useCoinbaseRewardsAcrossRollups(coinbasesForQuery) - const { isRewardsClaimable } = useIsRewardsClaimable() + // Per-rollup claimability check + const rollupAddressesInBreakdown = useMemo( + () => coinbaseBreakdown.map((row) => row.rollupAddress), + [coinbaseBreakdown], + ) + const { isClaimable: isClaimableForRollup } = useIsRewardsClaimableAcrossRollups(rollupAddressesInBreakdown) // Create debounced check function that manages debouncing state const debouncedCheckRewards = useMemo( - () => debounce(() => { - setIsDebouncing(false) - checkRewards() - setHasCheckedRewards(true) - }, 500), - [checkRewards] + () => + debounce(() => { + setIsDebouncing(false) + checkRewards() + setHasCheckedRewards(true) + }, 500), + [checkRewards], ) // Auto-check rewards when valid address is entered (debounced) useEffect(() => { - if (coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x')) { + if (validateAddress(coinbaseAddress)) { setIsDebouncing(true) debouncedCheckRewards() } else { @@ -81,31 +92,29 @@ export const ClaimSelfStakeRewardsModal = ({ } }, [coinbaseAddress, debouncedCheckRewards]) - const handleClaim = () => { - claimRewards(coinbaseAddress as Address) + const handleAddToBatch = (rollupAddress: Address, rollupVersion: string | undefined, rewards: bigint) => { + const tx = buildClaimSequencerRewardsTx(coinbaseAddress as Address, rollupAddress) + addTransaction( + { + type: "claim", + label: `Claim self-stake rewards — Rollup v${rollupVersion ?? "?"}`, + description: `${formatTokenAmountFull(rewards, decimals ?? 18, symbol ?? "")} from ${coinbaseAddress.slice(0, 10)}…${coinbaseAddress.slice(-8)}`, + transaction: tx, + metadata: { + stepType: ClaimStepType.CoinbaseClaim, + stepGroupIdentifier: `self-stake:${coinbaseAddress.toLowerCase()}:${rollupAddress.toLowerCase()}`, + coinbase: coinbaseAddress as Address, + rollupAddress, + rollupVersion, + amount: rewards, + }, + }, + { preventDuplicate: true }, + ) + onSuccess?.() + openCart() } - // Handle success - useEffect(() => { - if (isSuccess) { - onSuccess?.() - onClose() - setCoinbaseAddress("") - setHasCheckedRewards(false) - reset() - } - }, [isSuccess, onSuccess, onClose, reset]) - - // Handle errors - useEffect(() => { - if (error) { - const errorMessage = error.message - if (errorMessage.includes('User rejected') || errorMessage.includes('rejected')) { - showAlert('warning', 'Transaction was cancelled') - } - } - }, [error, showAlert]) - const handleClose = () => { onClose() setCoinbaseAddress("") @@ -118,8 +127,6 @@ export const ClaimSelfStakeRewardsModal = ({ } } - const isValidAddress = coinbaseAddress.length === 42 && coinbaseAddress.startsWith('0x') - if (!isOpen) return null return createPortal( @@ -147,7 +154,7 @@ export const ClaimSelfStakeRewardsModal = ({ Claim Self-Stake Rewards

- Enter your coinbase address to check and claim accumulated rewards for this self-stake position. + Enter your coinbase address to check accumulated rewards. Each rollup row adds a claim transaction to your batch — open the cart to execute.

@@ -158,7 +165,7 @@ export const ClaimSelfStakeRewardsModal = ({
Token Vault
- #{atp?.sequentialNumber || '?'} + #{atp?.sequentialNumber || "?"}
@@ -173,7 +180,7 @@ export const ClaimSelfStakeRewardsModal = ({
Staked Amount
- {decimals && symbol ? formatTokenAmount(stake.stakedAmount, decimals, symbol) : '-'} + {decimals && symbol ? formatTokenAmount(stake.stakedAmount, decimals, symbol) : "-"}
@@ -192,67 +199,56 @@ export const ClaimSelfStakeRewardsModal = ({ className="w-full bg-ink border border-parchment/20 text-parchment px-3 py-2 font-mono text-sm focus:outline-none focus:border-chartreuse/40" /> {!isValidAddress && coinbaseAddress.length > 0 && ( -

- Invalid address format -

+

Invalid address format

)} {(isDebouncing || isLoadingRewards) && (
- {isDebouncing ? 'Waiting...' : 'Checking rewards...'} + {isDebouncing ? "Waiting..." : "Checking rewards..."}
)}
- {/* Rewards Display */} + {/* Rewards Display — one row per rollup that holds a non-zero balance. */} {hasCheckedRewards && !isLoadingRewards && !isDebouncing && ( - <> - {rewards !== undefined ? ( -
-
- Available Rewards -
-
- {decimals && symbol ? formatTokenAmount(rewards, decimals, symbol) : '-'} +
+ {coinbaseBreakdown.length > 0 ? ( +
+
+
Available Rewards
+
+ Total: + {decimals && symbol ? formatTokenAmount(totalCoinbaseRewards, decimals, symbol) : "-"} + +
- {rewards === 0n && ( -

- No rewards available for this coinbase address -

- )} + {coinbaseBreakdown.map((row) => { + const tx = buildClaimSequencerRewardsTx(coinbaseAddress as Address, row.rollupAddress) + const isInBatch = checkTransactionInQueue(tx) + return ( + handleAddToBatch(row.rollupAddress, row.rollupVersion, row.rewards)} + onOpenCart={openCart} + /> + ) + })}
) : ( -
-
- Coinbase Not Found -
-
- Cannot find rewards for this coinbase address. Please verify the address is correct. -
+
+
Available Rewards
+

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

)} - - {/* 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. -
-
- )} - - )} - - {/* Error Display */} - {error && !(error.message.includes('User rejected') || error.message.includes('rejected')) && ( -
-
Transaction Error
-
- {error.message || 'An error occurred while claiming rewards'} -
)} @@ -262,32 +258,12 @@ 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 - -
, - document.body + document.body, ) } diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx new file mode 100644 index 000000000..e5ed52e7b --- /dev/null +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/RollupRewardRow.tsx @@ -0,0 +1,72 @@ +import type { Address } from "viem" +import { Icon } from "@/components/Icon" +import { formatTokenAmount } from "@/utils/atpFormatters" + +interface RollupRewardRowProps { + rollupAddress: Address + rollupVersion: string | undefined + rewards: bigint + decimals: number + symbol: string + isClaimable: boolean + isInBatch: boolean + onAddToBatch: () => void + onOpenCart: () => void +} + +export const RollupRewardRow = ({ + rollupAddress, + rollupVersion, + rewards, + decimals, + symbol, + isClaimable, + isInBatch, + onAddToBatch, + onOpenCart, +}: RollupRewardRowProps) => ( +
+
+ {rollupVersion !== undefined ? ( + + Rollup v{rollupVersion} + + ) : ( + + Configured rollup + + )} +
+ {formatTokenAmount(rewards, decimals, symbol)} +
+
+ {!isClaimable ? ( + + ) : isInBatch ? ( + + ) : ( + + )} +
+) diff --git a/staking-dashboard/src/components/Footer/StakingTermsModal.tsx b/staking-dashboard/src/components/Footer/StakingTermsModal.tsx index 023dce447..73d2adc75 100644 --- a/staking-dashboard/src/components/Footer/StakingTermsModal.tsx +++ b/staking-dashboard/src/components/Footer/StakingTermsModal.tsx @@ -18,7 +18,7 @@ export const StakingTermsModal = ({ isOpen, onClose }: StakingTermsModalProps) = if (!isOpen) return null return createPortal( -
+
{/* Header */}
diff --git a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx index 47f3862e7..41fa4b76b 100644 --- a/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx +++ b/staking-dashboard/src/components/RewardsManagement/CoinbaseAddressList.tsx @@ -1,41 +1,84 @@ +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 { useRemoveCoinbaseAddress } from "@/hooks/rewards" +import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup" +import { buildClaimSequencerRewardsTx } from "@/utils/claimCart" +import { useTransactionCart, ClaimStepType } from "@/contexts/TransactionCartContext" import type { CoinbaseBreakdown } from "@/hooks/rewards" import type { Address } from "viem" interface CoinbaseAddressListProps { + /** + * Reward breakdown produced by `useCoinbaseRewardsAcrossRollups` (or its wrapper + * `useMultipleCoinbaseRewards`). May contain multiple entries for the same coinbase — + * one row per rollup the coinbase has a balance on. Pass `allCoinbaseBreakdown` to + * keep saved-but-zero-balance rows visible and removable. + */ coinbaseBreakdown: CoinbaseBreakdown[] decimals: number symbol: string + /** + * Configured-rollup claimability flag, used as a fallback when the per-rollup value + * hasn't loaded yet. Each row otherwise gates on its own rollup's `isRewardsClaimable()`. + */ isRewardsClaimable: boolean isLoading?: boolean onRefetch?: () => void } /** - * Display list of coinbase addresses with their rewards + * Display list of coinbase rewards with one row per (coinbase, rollup) pair. + * Each claim button adds the corresponding `claimSequencerRewards` tx to the + * transaction cart; execution happens from the cart panel. */ export const CoinbaseAddressList = ({ coinbaseBreakdown, decimals, symbol, - isRewardsClaimable, isLoading, onRefetch }: CoinbaseAddressListProps) => { const { removeCoinbaseAddress, isPending: isRemoving } = useRemoveCoinbaseAddress() - const claimRewards = useClaimCoinbaseRewards() + const { addTransaction, checkTransactionInQueue, openCart } = useTransactionCart() + + 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) - onRefetch?.() + const handleAddToBatch = ( + address: Address, + rollupAddress: Address, + rollupVersion: string | undefined, + rewards: bigint, + ) => { + const tx = buildClaimSequencerRewardsTx(address, rollupAddress) + addTransaction( + { + type: "claim", + label: `Claim rewards — Rollup v${rollupVersion ?? "?"}`, + description: `${formatTokenAmountFull(rewards, decimals, symbol)} from ${address.slice(0, 10)}…${address.slice(-8)}`, + transaction: tx, + metadata: { + stepType: ClaimStepType.CoinbaseClaim, + stepGroupIdentifier: `coinbase:${address.toLowerCase()}:${rollupAddress.toLowerCase()}`, + coinbase: address, + rollupAddress, + rollupVersion, + amount: rewards, + }, + }, + { preventDuplicate: true }, + ) + openCart() } if (isLoading) { @@ -60,70 +103,93 @@ export const CoinbaseAddressList = ({ return (
- {coinbaseBreakdown.map((item) => ( -
-
-
- {/* Address */} -
- - {item.address.slice(0, 10)}...{item.address.slice(-8)} - - -
+ {coinbaseBreakdown.map((item) => { + // Only enable the claim button when the rollup's claimability has been + // explicitly confirmed `true`. `undefined` (still loading, or the + // multicall reverted) is treated as not-claimable so the user doesn't + // sign a tx that's guaranteed to revert and waste gas. + const rowIsClaimable = isClaimableForRollup(item.rollupAddress) === true + const rowKey = `${item.address}-${item.rollupAddress}` + const tx = buildClaimSequencerRewardsTx(item.address, item.rollupAddress) + const isInBatch = checkTransactionInQueue(tx) - {/* Rewards */} -
- Accumulated Rewards -
-
- {formatTokenAmountFull(item.rewards, decimals, symbol)} -
-
+ return ( +
+
+
+ {/* Address + version badge */} +
+ + {item.address.slice(0, 10)}...{item.address.slice(-8)} + + + {item.rollupVersion !== undefined && ( + + Rollup v{item.rollupVersion} + + )} +
- {/* Actions */} -
- -
-
+ {/* Rewards */} +
+ Accumulated Rewards +
+
+ {formatTokenAmountFull(item.rewards, decimals, symbol)} +
+
- {/* Claim Button */} - {item.rewards > 0n && ( -
- {isRewardsClaimable ? ( + {/* Actions */} +
- ) : ( -
- Rewards are currently locked -
- )} +
- )} -
- ))} + + {/* Claim Button */} + {item.rewards > 0n && ( +
+ {rowIsClaimable ? ( + isInBatch ? ( + + ) : ( + + ) + ) : ( +
+ Rewards are currently locked +
+ )} +
+ )} +
+ ) + })}
) } diff --git a/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx b/staking-dashboard/src/components/RewardsManagement/ManageRewardsAddressesModal.tsx index 25ef5dcb9..d2ed0aa49 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` so rows with + // zero balance across every rollup still render — users need to be able to remove + // saved addresses that haven't earned anything yet. const { - coinbaseBreakdown, + allCoinbaseBreakdown, isLoading: isLoadingCoinbaseRewards, refetch: refetchCoinbaseRewards } = useMultipleCoinbaseRewards(coinbaseAddresses as Address[]) @@ -206,7 +208,7 @@ export const ManageRewardsAddressesModal = ({ Your Coinbase Addresses
{ const { address: splitAddress, source, providerName, providerTakeRate } = splitContract - // Fetch rewards for this split contract - const { rewards: rollupBalance, isLoading: isLoadingRollup } = useSequencerRewards(splitAddress) + // Fan out `getSequencerRewards(splitAddress)` across every rollup so stranded + // balances on non-canonical rollups are counted in the displayed total. + const perSplitQuery = useMemo(() => [splitAddress], [splitAddress]) + const { allCoinbaseBreakdown, isLoading: isLoadingRollup } = useCoinbaseRewardsAcrossRollups(perSplitQuery) + const rollupBalance = allCoinbaseBreakdown.reduce((sum, row) => sum + row.rewards, 0n) const { balance: splitContractBalance, isLoading: isLoadingSplitContract } = useERC20Balance(tokenAddress, splitAddress) const isLoading = isLoadingRollup || isLoadingSplitContract - const totalRewards = (rollupBalance || 0n) + (splitContractBalance || 0n) + const totalRewards = rollupBalance + (splitContractBalance || 0n) const isDelegation = source === "delegation" // Calculate user's share if we have the take rate (delegation splits) diff --git a/staking-dashboard/src/components/StakeHealthBar.tsx b/staking-dashboard/src/components/StakeHealthBar.tsx index afa1ebca6..0be2bbf15 100644 --- a/staking-dashboard/src/components/StakeHealthBar.tsx +++ b/staking-dashboard/src/components/StakeHealthBar.tsx @@ -6,23 +6,33 @@ interface StakeHealthBarProps { effectiveBalance: bigint | undefined activationThreshold: bigint | undefined ejectionThreshold: bigint | undefined + /** % of the activation→ejection cushion still intact. Drives bar fill + color. */ healthPercentage: number - slashCount: number + /** Cumulative stake lost relative to activation threshold (raw + percentage). */ + lossAmount: bigint + lossPercentage: number isAtRisk: boolean isCritical: boolean isLoading: boolean } /** - * Visual progress bar showing stake health relative to ejection threshold - * Green = healthy (>50%), Yellow = at risk (25-50%), Red = critical (<25% or below ejection) + * Visual progress bar showing stake health relative to the ejection threshold. + * Green = healthy (>50% cushion), Yellow = at risk (25-50%), Red = critical + * (<25% or below ejection). + * + * `healthPercentage` here is the cushion (activation→ejection) remaining — + * it's what the user actually wants to know ("am I close to being ejected?"). + * Cumulative slashed amount is shown as a separate headline so a small slash + * that eats a big chunk of cushion isn't misread as catastrophic loss. */ export const StakeHealthBar = ({ effectiveBalance, activationThreshold, ejectionThreshold, healthPercentage, - slashCount, + lossAmount, + lossPercentage, isAtRisk, isCritical, isLoading, @@ -49,29 +59,30 @@ export const StakeHealthBar = ({ return 'text-chartreuse' } + const hasLoss = lossAmount > 0n + return (
Stake Health
-
- {slashCount > 0 && ( - - {slashCount} slash{slashCount !== 1 ? 'es' : ''} - - )} - - {healthPercentage.toFixed(0)}% - -
+ + {healthPercentage.toFixed(0)}% cushion +
+ {hasLoss && ( +
+ Slashed: {formatTokenAmount(lossAmount, decimals, symbol)} ({lossPercentage.toFixed(2)}% of stake) +
+ )} +
{ const getBadgeClasses = () => { @@ -49,8 +51,8 @@ export const StatusBadge = ({ return statusLabel } - // Show slash warning only for validating sequencers that have been slashed - const showSlashWarning = slashCount > 0 && status === SequencerStatus.VALIDATING + // Show slash warning only for validating sequencers that have lost stake. + const showSlashWarning = lossPercentage > 0 && status === SequencerStatus.VALIDATING return (
@@ -66,7 +68,7 @@ export const StatusBadge = ({ {showSlashWarning && ( - ({slashCount}) + (−{lossPercentage.toFixed(2)}%) )}
diff --git a/staking-dashboard/src/components/TermsAcceptanceModal/TermsAcceptanceModal.tsx b/staking-dashboard/src/components/TermsAcceptanceModal/TermsAcceptanceModal.tsx index a52bd190d..2449eaf51 100644 --- a/staking-dashboard/src/components/TermsAcceptanceModal/TermsAcceptanceModal.tsx +++ b/staking-dashboard/src/components/TermsAcceptanceModal/TermsAcceptanceModal.tsx @@ -19,7 +19,7 @@ export const TermsAcceptanceModal = ({ isOpen, onAccept, onClose }: TermsAccepta if (!isOpen) return null return createPortal( -
+
{/* Header */}
diff --git a/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx b/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx index 6859b9164..62c30062e 100644 --- a/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx +++ b/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx @@ -1,4 +1,5 @@ import type { CartTransaction } from "@/contexts/TransactionCartContext" +import { ClaimStepTypeName } from "@/contexts/TransactionCartContext" import { CopyButton } from "@/components/CopyButton/CopyButton" import { Icon } from "@/components/Icon" import { openTxInExplorer } from "@/utils/explorerUtils" @@ -136,6 +137,61 @@ export const TransactionCartDetailsExpanded = ({ transaction }: TransactionCartD )} )} + {transaction.type === "claim" && ( + <> + {transaction.metadata.stepType && ( +
+
Step
+ + {ClaimStepTypeName[transaction.metadata.stepType]} + +
+ )} + {transaction.metadata.coinbase && ( +
+
Coinbase Address
+
+ + {transaction.metadata.coinbase} + + +
+
+ )} + {transaction.metadata.splitContract && ( +
+
Split Contract
+
+ + {transaction.metadata.splitContract} + + +
+
+ )} + {transaction.metadata.rollupAddress && ( +
+
+ Rollup{transaction.metadata.rollupVersion ? ` v${transaction.metadata.rollupVersion}` : ""} +
+
+ + {transaction.metadata.rollupAddress} + + +
+
+ )} + {transaction.metadata.amount !== undefined && transaction.metadata.amount > 0n && ( +
+
Expected Amount
+ + {transaction.metadata.amount.toString()} + +
+ )} + + )} {transaction.type === "self-stake" && "atpAddress" in transaction.metadata && ( <> {transaction.metadata.atpAddress && ( diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx index 7675943ff..a22805afc 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletDelegationItem.tsx @@ -14,7 +14,6 @@ import { getValidatorDashboardValidatorUrl } from "@/utils/validatorDashboardUti import { getExplorerTxUrl, getExplorerAddressUrl } from "@/utils/explorerUtils" import { useSequencerStatus, SequencerStatus, useStakeHealth, useIsRewardsClaimable } from "@/hooks/rollup" import { useGovernanceConfig } from "@/hooks/governance" -import { useClaimAllContext } from "@/contexts/ClaimAllContext" import { WalletWithdrawalActions } from "./WalletWithdrawalActions" import type { Erc20DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" @@ -42,7 +41,6 @@ export const WalletDelegationItem = ({ const { address } = useAccount() const { symbol, decimals } = useStakingAssetTokenDetails() const { date, time } = formatBlockTimestamp(delegation.timestamp) - const { getSplitStatus, claimAllHook } = useClaimAllContext() const { isRewardsClaimable } = useIsRewardsClaimable() const delegationRollupAddress = delegation.rollupAddress as Address @@ -54,28 +52,16 @@ export const WalletDelegationItem = ({ activationThreshold, ejectionThreshold, healthPercentage, - slashCount, + lossAmount, + lossPercentage, isAtRisk, isCritical, isLoading: isLoadingHealth } = useStakeHealth(delegation.attesterAddress as Address, delegationRollupAddress) - const splitStatus = getSplitStatus(delegation.splitContract as Address) - const isInBatch = splitStatus !== 'idle' - const isProcessingInBatch = splitStatus === 'processing' - const isUnstaked = delegation.status === 'UNSTAKED' const isInQueue = status === SequencerStatus.NONE && !delegation.hasFailedDeposit && !isUnstaked - const getButtonText = () => { - if (!isProcessingInBatch) return 'Claim Rewards' - if (claimAllHook.completedMessage) return claimAllHook.completedMessage - if (claimAllHook.skipMessage) return claimAllHook.skipMessage - if (claimAllHook.currentStep === 'claiming') return 'Claiming' - if (claimAllHook.currentStep === 'distributing') return 'Distributing' - return 'Withdrawing' - } - return (
{/* Collapsible Header */} @@ -109,7 +95,7 @@ export const WalletDelegationItem = ({ isLoading={isLoadingStatus} isUnstaked={isUnstaked} isInQueue={isInQueue} - slashCount={slashCount} + lossPercentage={lossPercentage} isAtRisk={isAtRisk} /> )} @@ -318,7 +304,8 @@ export const WalletDelegationItem = ({ activationThreshold={activationThreshold} ejectionThreshold={ejectionThreshold} healthPercentage={healthPercentage} - slashCount={slashCount} + lossAmount={lossAmount} + lossPercentage={lossPercentage} isAtRisk={isAtRisk} isCritical={isCritical} isLoading={isLoadingHealth} @@ -340,26 +327,17 @@ export const WalletDelegationItem = ({ providerTakeRate: delegation.providerTakeRate, providerRewardsRecipient: delegation.providerRewardsRecipient })} - disabled={delegation.rewards === 0n || isInBatch || isRewardsClaimable === false} + disabled={delegation.rewards === 0n || isRewardsClaimable === false} className="px-3 py-1.5 border font-oracle-standard text-xs font-bold uppercase tracking-wide whitespace-nowrap transition-colors disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-parchment/10 disabled:border-parchment/30 disabled:text-parchment/60 border-chartreuse bg-chartreuse text-ink hover:bg-chartreuse/90" title={ isRewardsClaimable === false ? "Rewards are currently locked by the network protocol" : delegation.rewards === 0n ? "No rewards to claim" - : isInBatch - ? "Processing in batch" - : "Claim delegation rewards" + : "Claim delegation rewards" } > - {isProcessingInBatch ? ( -
-
- {getButtonText()} -
- ) : ( - 'Claim Rewards' - )} + Claim Rewards {(delegation.rewards === 0n || isRewardsClaimable === false) && ( )} @@ -236,7 +237,8 @@ export const WalletDirectStakeItem = ({ activationThreshold={activationThreshold} ejectionThreshold={ejectionThreshold} healthPercentage={healthPercentage} - slashCount={slashCount} + lossAmount={lossAmount} + lossPercentage={lossPercentage} isAtRisk={isAtRisk} isCritical={isCritical} isLoading={isLoadingHealth} diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx index c25a852d5..dd9598184 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx @@ -3,7 +3,6 @@ import { createPortal } from "react-dom" import { Icon } from "@/components/Icon" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" import { formatTokenAmount } from "@/utils/atpFormatters" -import { ClaimAllProvider } from "@/contexts/ClaimAllContext" import { ClaimAllDelegationRewardsButton } from "@/components/ClaimAllDelegationRewardsButton" import { ClaimDelegationRewardsModal, type DelegationModalData } from "@/components/ClaimDelegationRewardsModal" import { WalletDelegationItem } from "./WalletDelegationItem" @@ -73,7 +72,7 @@ export const WalletStakesDetailsModal = ({ } return createPortal( - + <>
@@ -219,7 +221,7 @@ export const WalletStakesDetailsModal = ({ }} /> )} - , + , document.body ) } diff --git a/staking-dashboard/src/contexts/ClaimAllContext.tsx b/staking-dashboard/src/contexts/ClaimAllContext.tsx deleted file mode 100644 index 5782e9ff6..000000000 --- a/staking-dashboard/src/contexts/ClaimAllContext.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import { createContext, useContext, useMemo, type ReactNode } from "react" -import { useClaimAllSplitRewards } from "@/hooks/splits/useClaimAllSplitRewards" -import type { Address } from "viem" -import type { ClaimStep } from "@/hooks/splits/types" - -interface ClaimAllContextValue { - isProcessing: boolean - currentSplitContract: Address | null - currentStep: ClaimStep - getSplitStatus: (splitContract: Address) => 'idle' | 'processing' | 'waiting' - claimAllHook: ReturnType -} - -const ClaimAllContext = createContext(null) - -/** - * Provider for managing delegation split contracts batch claim state - * Allows individual claim buttons to reflect batch claim progress - */ -export const ClaimAllProvider = ({ children }: { children: ReactNode }) => { - const claimAllHook = useClaimAllSplitRewards() - - const currentSplitContract = useMemo(() => { - const { currentIndex, tasks } = claimAllHook - return currentIndex !== null && tasks[currentIndex] - ? tasks[currentIndex].splitContract - : null - }, [claimAllHook.currentIndex, claimAllHook.tasks]) - - const getSplitStatus = useMemo(() => { - return (splitContract: Address): 'idle' | 'processing' | 'waiting' => { - if (!claimAllHook.isProcessing) return 'idle' - - const taskIndex = claimAllHook.tasks.findIndex( - task => task.splitContract.toLowerCase() === splitContract.toLowerCase() - ) - - if (taskIndex === -1) return 'idle' - if (taskIndex === claimAllHook.currentIndex) return 'processing' - return 'waiting' - } - }, [claimAllHook.isProcessing, claimAllHook.tasks, claimAllHook.currentIndex]) - - const value = useMemo(() => ({ - isProcessing: claimAllHook.isProcessing, - currentSplitContract, - currentStep: claimAllHook.currentStep, - getSplitStatus, - claimAllHook - }), [claimAllHook.isProcessing, currentSplitContract, claimAllHook.currentStep, getSplitStatus, claimAllHook]) - - return ( - - {children} - - ) -} - -/** - * Hook to access claim all context - */ -export const useClaimAllContext = () => { - const context = useContext(ClaimAllContext) - if (!context) { - throw new Error('useClaimAllContext must be used within ClaimAllProvider') - } - return context -} diff --git a/staking-dashboard/src/contexts/TransactionCartContext.tsx b/staking-dashboard/src/contexts/TransactionCartContext.tsx index a874143d0..5483dd087 100644 --- a/staking-dashboard/src/contexts/TransactionCartContext.tsx +++ b/staking-dashboard/src/contexts/TransactionCartContext.tsx @@ -12,12 +12,14 @@ import type { DelegationMetadata, SelfStakeMetadata, WalletDirectStakeMetadata, + ClaimMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions, TransactionCartContextType } from "./TransactionCartContextType" +import { ClaimStepType, ClaimStepTypeName } from "./TransactionCartContextType" // Re-export types for backwards compatibility export type { @@ -25,12 +27,15 @@ export type { DelegationMetadata, SelfStakeMetadata, WalletDirectStakeMetadata, + ClaimMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions } +export { ClaimStepType, ClaimStepTypeName } + const TransactionCartContext = createContext(undefined) interface TransactionCartProviderProps { @@ -174,6 +179,50 @@ export function TransactionCartProvider({ children }: TransactionCartProviderPro } }, [hasDependents, showAlert]) + const replaceTransactionByTx = useCallback(( + rawTx: RawTransaction, + replacement: Omit, + ) => { + let missingDepsMessage: string | null = null + + setTransactions(prev => { + const signature = getTransactionSignature(rawTx) + const filtered = prev.filter(tx => getTransactionSignature(tx.transaction) !== signature) + const tempId = `${replacement.type}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}` + const tempTransaction = { ...replacement, id: tempId, status: 'pending' as const } as CartTransaction + + // Mirror addTransaction's dependency-presence check. If the replacement + // declares deps that don't exist post-filter, fail closed (revert to + // prev) so we never end up with a dangling entry. Capture a warning so + // the caller / developer isn't left with a silent no-op. + const metadata = replacement.metadata + if (metadata && 'dependsOn' in metadata && metadata.dependsOn && metadata.dependsOn.length > 0) { + const dependencies = resolveDependencies(tempTransaction, filtered) + if (dependencies.length !== metadata.dependsOn.length) { + const foundStepTypes = new Set( + dependencies.map(dep => dep.metadata && 'stepType' in dep.metadata ? dep.metadata.stepType : null), + ) + const missing = metadata.dependsOn + .filter(dep => !foundStepTypes.has(dep.stepType)) + .map(dep => dep.stepName || String(dep.stepType)) + .join(", ") + missingDepsMessage = + `replaceTransactionByTx: skipped "${replacement.label}" — missing upstream dependencies (${missing})` + return prev + } + } + + return [...filtered, tempTransaction] + }) + + if (missingDepsMessage) { + // Surfacing as a console.warn rather than a user-facing alert: this is + // a programming error (cart-wiring drift), not a recoverable runtime + // condition the user can act on. + console.warn(missingDepsMessage) + } + }, [resolveDependencies]) + const clearCart = useCallback(() => { setTransactions([]) }, []) @@ -265,6 +314,7 @@ export function TransactionCartProvider({ children }: TransactionCartProviderPro transactions, addTransaction, removeTransaction, + replaceTransactionByTx, clearCart, clearByType, clearCompleted, diff --git a/staking-dashboard/src/contexts/TransactionCartContextType.ts b/staking-dashboard/src/contexts/TransactionCartContextType.ts index df083b4a4..23210f79b 100644 --- a/staking-dashboard/src/contexts/TransactionCartContextType.ts +++ b/staking-dashboard/src/contexts/TransactionCartContextType.ts @@ -1,7 +1,32 @@ import type { Address } from "viem" import { ATPStakingStepsWithTransaction } from "./ATPStakingStepsContext" -export type TransactionType = "delegation" | "self-stake" | "setup" | "wallet-delegation" | "wallet-direct-stake" +export type TransactionType = "delegation" | "self-stake" | "setup" | "wallet-delegation" | "wallet-direct-stake" | "claim" + +/** + * Step type for claim flows. String values so they can't collide with + * `ATPStakingStepsWithTransaction` (numeric enum) in the cart's cross-type + * dependency resolver. + */ +export enum ClaimStepType { + /** claimSequencerRewards(coinbase, rollup) for a saved coinbase address. */ + CoinbaseClaim = "claim:coinbase", + /** claimSequencerRewards(splitContract, rollup) for any rollup version — + * canonical or otherwise. No semantic difference; the dependency wiring + * treats every per-rollup claim the same. */ + SplitClaim = "claim:split-claim", + /** Split.distribute(splitData, token, distributor). */ + SplitDistribute = "claim:split-distribute", + /** SplitsWarehouse.withdraw(user, token). */ + SplitWithdraw = "claim:split-withdraw", +} + +export const ClaimStepTypeName: Record = { + [ClaimStepType.CoinbaseClaim]: "Claim Coinbase Rewards", + [ClaimStepType.SplitClaim]: "Claim to Split Contract", + [ClaimStepType.SplitDistribute]: "Distribute Rewards", + [ClaimStepType.SplitWithdraw]: "Withdraw Rewards", +} export interface TransactionDependency { stepType: T @@ -53,6 +78,23 @@ export interface WalletDirectStakeMetadata extends BaseMetadata { + /** Coinbase whose sequencer rewards are being claimed (CoinbaseClaim). */ + coinbase?: Address + /** Rollup contract the claim targets. */ + rollupAddress?: Address + /** Ordinal rollup version (1-based), used for cart display only. */ + rollupVersion?: string + /** Split contract for delegation flows. */ + splitContract?: Address + /** Splits warehouse for the withdraw step. */ + warehouseAddress?: Address + /** Reward token (fee asset) being claimed; needed by distribute/withdraw. */ + tokenAddress?: Address + /** Expected reward amount at add-time (display only — chain state is authoritative at exec). */ + amount?: bigint +} + export interface RawTransaction { to: Address data: `0x${string}` @@ -80,6 +122,7 @@ export type CartTransaction = | BaseCartItem<"setup", SetupMetadata> | BaseCartItem<"wallet-delegation", WalletDelegationMetadata> | BaseCartItem<"wallet-direct-stake", WalletDirectStakeMetadata> + | BaseCartItem<"claim", ClaimMetadata> export interface AddTransactionOptions { preventDuplicate?: boolean @@ -89,6 +132,14 @@ export interface TransactionCartContextType { transactions: CartTransaction[] addTransaction: (transaction: Omit, options?: AddTransactionOptions) => void removeTransaction: (id: string) => void + /** + * Atomic replace for "singleton" entries (e.g. a warehouse withdraw whose + * calldata is identical across delegations). Removes any existing cart entry + * with the same raw-tx signature and appends `replacement` at the end — all + * in one `setTransactions` call so callers don't have to coordinate the + * remove + add themselves, and no toasts fire for the silent swap. + */ + replaceTransactionByTx: (transaction: RawTransaction, replacement: Omit) => void clearCart: () => void clearByType: (type: TransactionType) => void clearCompleted: () => void diff --git a/staking-dashboard/src/contracts/index.ts b/staking-dashboard/src/contracts/index.ts index dd363e800..4617597ff 100644 --- a/staking-dashboard/src/contracts/index.ts +++ b/staking-dashboard/src/contracts/index.ts @@ -54,6 +54,22 @@ export interface RollupVersion { let _canonicalRollupAddress: Address | null = null; let _rollupVersions: RollupVersion[] = []; +// Runtime schema for the `/api/rollups` response. These addresses feed into +// `writeContract` targets when users claim rewards, so validate them at the +// trust boundary — a malformed / poisoned indexer response should fail fast +// rather than silently route a tx to an unchecked string. +const rollupVersionSchema = z.object({ + version: z.string(), + address: addressSchema, + blockNumber: z.number(), + timestamp: z.number(), +}); + +const rollupsApiResponseSchema = z.object({ + canonical: addressSchema.nullable(), + versions: z.array(rollupVersionSchema), +}); + export async function initRollupVersions(): Promise
{ const apiHost = import.meta.env.VITE_API_HOST; if (!apiHost) { @@ -64,10 +80,12 @@ export async function initRollupVersions(): Promise
{ if (!res.ok) { throw new Error(`/api/rollups returned ${res.status}`); } - const body = (await res.json()) as { - canonical: string | null; - versions: RollupVersion[]; - }; + + const parsed = rollupsApiResponseSchema.safeParse(await res.json()); + if (!parsed.success) { + throw new Error(`/api/rollups returned an invalid response: ${parsed.error.message}`); + } + const body = parsed.data; if (!body.canonical || body.versions.length === 0) { throw new Error( @@ -76,7 +94,7 @@ export async function initRollupVersions(): Promise
{ } _rollupVersions = body.versions; - _canonicalRollupAddress = body.canonical as Address; + _canonicalRollupAddress = body.canonical; return _canonicalRollupAddress; } diff --git a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts index 2d2837948..a29015efd 100644 --- a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts +++ b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts @@ -7,7 +7,7 @@ import { SplitAbi } from '@/contracts/abis/Split' import { SplitWarehouseAbi } from '@/contracts/abis/SplitWarehouse' import { calculateTotalUserShareFromSplitRewards } from '@/utils/rewardCalculations' import { useStakingAssetTokenDetails } from '@/hooks/stakingRegistry' -import { contracts } from '@/contracts' +import { contracts, getRollupVersions, type RollupVersion } from '@/contracts' import type { Address } from 'viem' import { stringToBigInt } from '@/utils/atpFormatters' import type { StakeStatus } from './atpTypes' @@ -55,6 +55,9 @@ export interface DelegationBreakdown { txHash: string timestamp: number blockNumber: number + /** Per-rollup unclaimed `getSequencerRewards(splitContract)` balances. Used by the + * claim engine to pre-sweep stranded balances from non-canonical rollups. */ + rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> } export interface Erc20DelegationBreakdown { @@ -75,6 +78,9 @@ export interface Erc20DelegationBreakdown { txHash: string timestamp: number blockNumber: number + /** Per-rollup unclaimed `getSequencerRewards(splitContract)` balances. Used by the + * claim engine to pre-sweep stranded balances from non-canonical rollups. */ + rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> } export interface Erc20DirectStakeBreakdown { @@ -233,18 +239,37 @@ function parseDirectStake(stake: ApiDirectStake): DirectStakeBreakdown { } /** - * Create contract calls for a delegation (rollup rewards + split balance) + * Ordered list of rollups (oldest first, 1-based ordinal version). Falls back to + * the configured rollup when `/api/rollups` hasn't populated the module cache yet. */ -function createDelegationContracts(delegation: ApiDelegation, tokenAddress: Address) { +function resolveRollupList(): Array<{ address: Address; version: string }> { + const versions = getRollupVersions() + if (versions.length > 0) { + return versions.map((v: RollupVersion, i) => ({ address: v.address, version: String(i + 1) })) + } + return [{ address: contracts.rollup.address, version: '1' }] +} + +/** + * Create contract calls for a delegation. Emits N `getSequencerRewards` calls + * (one per rollup) plus one ERC20 `balanceOf` for the split contract — total + * N+1 calls per delegation. Callers index into the result array with the same + * N+1 stride; see the parse loops below. + */ +function createDelegationContracts( + delegation: ApiDelegation, + tokenAddress: Address, + rollups: Array<{ address: Address; version: string }>, +) { if (!delegation.splitContract) return [] return [ - { - address: contracts.rollup.address, + ...rollups.map((rollup) => ({ + address: rollup.address, abi: contracts.rollup.abi, functionName: 'getSequencerRewards', args: [delegation.splitContract as Address], - }, + })), { address: tokenAddress, abi: ERC20Abi, @@ -259,12 +284,18 @@ function createDelegationContracts(delegation: ApiDelegation, tokenAddress: Addr */ function parseDelegation( delegation: ApiDelegation, - rollupBalance: bigint, + rollupBalancesByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }>, splitContractBalance: bigint ): DelegationBreakdown { + // Sum unclaimed rollup balance across every rollup version. + const rollupBalanceTotal = rollupBalancesByRollup.reduce( + (sum, r) => sum + r.rewards, + 0n, + ) + // Calculate total user share from rollup and split contract only (omit warehouse) const userRewards = calculateTotalUserShareFromSplitRewards( - rollupBalance, + rollupBalanceTotal, splitContractBalance, 0n, // Omit warehouse balance delegation.providerTakeRate @@ -289,22 +320,28 @@ function parseDelegation( txHash: delegation.txHash, timestamp: delegation.timestamp, blockNumber: delegation.blockNumber, + rollupRewardsByRollup: rollupBalancesByRollup, } } /** - * Create contract calls for an ERC20 delegation (rollup rewards + split balance) + * Create contract calls for an ERC20 delegation. Same N+1 stride as + * {@link createDelegationContracts}. */ -function createErc20DelegationContracts(delegation: ApiErc20Delegation, tokenAddress: Address) { +function createErc20DelegationContracts( + delegation: ApiErc20Delegation, + tokenAddress: Address, + rollups: Array<{ address: Address; version: string }>, +) { if (!delegation.splitContract) return [] return [ - { - address: contracts.rollup.address, + ...rollups.map((rollup) => ({ + address: rollup.address, abi: contracts.rollup.abi, functionName: 'getSequencerRewards', args: [delegation.splitContract as Address], - }, + })), { address: tokenAddress, abi: ERC20Abi, @@ -319,11 +356,16 @@ function createErc20DelegationContracts(delegation: ApiErc20Delegation, tokenAdd */ function parseErc20Delegation( delegation: ApiErc20Delegation, - rollupBalance: bigint, + rollupBalancesByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }>, splitContractBalance: bigint ): Erc20DelegationBreakdown { + const rollupBalanceTotal = rollupBalancesByRollup.reduce( + (sum, r) => sum + r.rewards, + 0n, + ) + const userRewards = calculateTotalUserShareFromSplitRewards( - rollupBalance, + rollupBalanceTotal, splitContractBalance, 0n, delegation.providerTakeRate @@ -347,6 +389,7 @@ function parseErc20Delegation( txHash: delegation.txHash, timestamp: delegation.timestamp, blockNumber: delegation.blockNumber, + rollupRewardsByRollup: rollupBalancesByRollup, } } @@ -396,12 +439,14 @@ export const useAggregatedStakingData = (): AggregatedStakingData => { const erc20Delegations = (stakingData?.erc20DelegationBreakdown ?? []).filter(delegation => delegation.splitContract) const erc20DirectStakes = stakingData?.erc20DirectStakeBreakdown ?? [] - // Build contract calls for rollup balance + split contract balance (2 calls per delegation) - // Combine ATP and ERC20 delegation contracts + // Build contract calls: N getSequencerRewards calls (one per rollup) + 1 balanceOf per delegation. + // The resulting array has stride N+1 per delegation; parse loops below mirror this layout. + const rollups = useMemo(() => resolveRollupList(), []) + const callsPerDelegation = rollups.length + 1 const delegationContracts = tokenAddress ? [ - ...delegations.flatMap(delegation => createDelegationContracts(delegation, tokenAddress)), - ...erc20Delegations.flatMap(delegation => createErc20DelegationContracts(delegation, tokenAddress)) + ...delegations.flatMap(delegation => createDelegationContracts(delegation, tokenAddress, rollups)), + ...erc20Delegations.flatMap(delegation => createErc20DelegationContracts(delegation, tokenAddress, rollups)) ] : [] @@ -440,22 +485,32 @@ export const useAggregatedStakingData = (): AggregatedStakingData => { const isLoading = isLoadingApi || ((delegations.length > 0 || erc20Delegations.length > 0) && isLoadingDelegations) || isLoadingWarehouse + // Extract per-rollup balances + split-contract balance from a delegation's + // call-stride starting at `baseIndex`. + const extractBalances = (baseIndex: number) => { + const rollupBalancesByRollup = rollups.map((rollup, rIdx) => { + const result = delegationData?.[baseIndex + rIdx] + const rewards = (result?.result as bigint | undefined) ?? 0n + return { rollupAddress: rollup.address, rollupVersion: rollup.version, rewards } + }) + const splitContractBalance = + (delegationData?.[baseIndex + rollups.length]?.result as bigint | undefined) ?? 0n + return { rollupBalancesByRollup, splitContractBalance } + } + // Parse ATP delegations with rewards from rollup and split contract const delegationBreakdown: DelegationBreakdown[] = delegations.map((delegation, index) => { - const rollupBalance = (delegationData?.[index * 2]?.result as bigint) ?? 0n - const splitContractBalance = (delegationData?.[index * 2 + 1]?.result as bigint) ?? 0n - - return parseDelegation(delegation, rollupBalance, splitContractBalance) + const { rollupBalancesByRollup, splitContractBalance } = extractBalances(index * callsPerDelegation) + return parseDelegation(delegation, rollupBalancesByRollup, splitContractBalance) }) // Parse ERC20 delegations with rewards (offset by ATP delegation count) - const atpDelegationContractCount = delegations.length * 2 + const atpDelegationContractCount = delegations.length * callsPerDelegation const erc20DelegationBreakdown: Erc20DelegationBreakdown[] = erc20Delegations.map((delegation, index) => { - const dataIndex = atpDelegationContractCount + (index * 2) - const rollupBalance = (delegationData?.[dataIndex]?.result as bigint) ?? 0n - const splitContractBalance = (delegationData?.[dataIndex + 1]?.result as bigint) ?? 0n - - return parseErc20Delegation(delegation, rollupBalance, splitContractBalance) + const { rollupBalancesByRollup, splitContractBalance } = extractBalances( + atpDelegationContractCount + index * callsPerDelegation, + ) + return parseErc20Delegation(delegation, rollupBalancesByRollup, splitContractBalance) }) // Parse direct stakes diff --git a/staking-dashboard/src/hooks/atpFactory/useATPCreatedEvents.ts b/staking-dashboard/src/hooks/atpFactory/useATPCreatedEvents.ts index c8f5cba32..6e32bc2a6 100644 --- a/staking-dashboard/src/hooks/atpFactory/useATPCreatedEvents.ts +++ b/staking-dashboard/src/hooks/atpFactory/useATPCreatedEvents.ts @@ -20,7 +20,7 @@ export function useATPCreatedEvents(beneficiaryAddress?: Address) { // We are scanning for ATPCreated events up until 200000 blocks in history. // TODO: This is just for debugging. We will have use some event indexing service in production - const CHUNK_SIZE = 50000n; + const CHUNK_SIZE = 9_000n; const MAX_BLOCKS_BACK = 200000n; const startBlock = blockNumber; const endBlock = diff --git a/staking-dashboard/src/hooks/governance/usePendingWithdrawals.ts b/staking-dashboard/src/hooks/governance/usePendingWithdrawals.ts index 6d49a77e0..d13a022e6 100644 --- a/staking-dashboard/src/hooks/governance/usePendingWithdrawals.ts +++ b/staking-dashboard/src/hooks/governance/usePendingWithdrawals.ts @@ -54,7 +54,7 @@ export function usePendingWithdrawals({ userAddress, atpAddresses = [] }: UsePen const recipients = [userAddress, ...addresses]; // Chunked block scanning to avoid RPC limits (~28 days on mainnet) - const CHUNK_SIZE = 50000n; + const CHUNK_SIZE = 9_000n; const MAX_BLOCKS_BACK = 200000n; const blockNumber = await publicClient.getBlockNumber(); const startBlock = blockNumber; diff --git a/staking-dashboard/src/hooks/rewards/index.ts b/staking-dashboard/src/hooks/rewards/index.ts index 295e92846..7a523c757 100644 --- a/staking-dashboard/src/hooks/rewards/index.ts +++ b/staking-dashboard/src/hooks/rewards/index.ts @@ -6,13 +6,8 @@ export { useCoinbaseAddresses } from "./useCoinbaseAddresses" export { useAddCoinbaseAddress } from "./useAddCoinbaseAddress" export { useRemoveCoinbaseAddress } from "./useRemoveCoinbaseAddress" export { useMultipleCoinbaseRewards } from "./useMultipleCoinbaseRewards" -export { useClaimCoinbaseRewards } from "./useClaimCoinbaseRewards" // Manual split address hooks export { useManualSplitAddresses } from "./useManualSplitAddresses" export { useAddManualSplit } from "./useAddManualSplit" export { useRemoveManualSplit } from "./useRemoveManualSplit" - -// Claim all rewards -export { useClaimAllRewards } from "./useClaimAllRewards" -export type { ClaimTask, ClaimTaskStatus, ClaimTaskType } from "./useClaimAllRewards" diff --git a/staking-dashboard/src/hooks/rewards/rewardsTypes.ts b/staking-dashboard/src/hooks/rewards/rewardsTypes.ts index 23b0d8588..33553f49f 100644 --- a/staking-dashboard/src/hooks/rewards/rewardsTypes.ts +++ b/staking-dashboard/src/hooks/rewards/rewardsTypes.ts @@ -1,12 +1,21 @@ import type { Address } from 'viem' /** - * Represents a coinbase address saved by the user for tracking self-stake rewards + * Rewards a coinbase address has accumulated on a single rollup. The dashboard + * fans out reward reads across every rollup the indexer has seen + * (`/api/rollups`), so a coinbase that earned on two rollups produces two + * `CoinbaseBreakdown` rows — one per rollup. UI disambiguates via + * `rollupAddress`/`rollupVersion` and the claim engine routes each + * `claimSequencerRewards` call to the matching rollup contract. */ export interface CoinbaseBreakdown { address: Address rewards: bigint source: 'manual' + /** Rollup contract these rewards live on. */ + rollupAddress: Address + /** Stringified uint256 registry version (e.g. "2"). Displayed as "Rollup v{version}". */ + rollupVersion?: string } /** diff --git a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts deleted file mode 100644 index 4b9c90aa1..000000000 --- a/staking-dashboard/src/hooks/rewards/useClaimAllRewards.ts +++ /dev/null @@ -1,396 +0,0 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from "react" -import { useAccount } from "wagmi" -import { useClaimSplitRewards } from "@/hooks/splits/useClaimSplitRewards" -import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" -import { useSequencerRewards } from "@/hooks/rollup/useSequencerRewards" -import { useERC20Balance } from "@/hooks/erc20/useERC20Balance" -import { useWarehouseBalance } from "@/hooks/splits/useWarehouseBalance" -import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" -import type { Address } from "viem" -import type { SplitData } from "@/hooks/splits/types" -import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" -import type { CoinbaseBreakdown } from "./rewardsTypes" - -export type ClaimTaskStatus = 'pending' | 'processing' | 'completed' | 'error' | 'skipped' -export type ClaimTaskType = 'delegation' | 'coinbase' - -export interface ClaimTask { - id: string - type: ClaimTaskType - displayName: string - estimatedRewards: bigint - status: ClaimTaskStatus - error?: Error - // Delegation-specific data - splitContract?: Address - splitData?: SplitData - providerTakeRate?: number - // Coinbase-specific data - coinbaseAddress?: Address - // Sub-step tracking for delegations - currentSubStep?: 'claiming' | 'distributing' | 'withdrawing' -} - -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 - completedTasks: ClaimTask[] - 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) - */ -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) - - // Track if we were cancelled - const cancelledRef = useRef(false) - - // Get current task - const currentTask = currentTaskIndex !== null ? tasks[currentTaskIndex] : null - - // Get current task's addresses - const currentSplitContract = currentTask?.type === 'delegation' ? currentTask.splitContract : undefined - const currentCoinbase = currentTask?.type === 'coinbase' ? currentTask.coinbaseAddress : undefined - - // 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 { 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]) - - // Use existing hooks for claiming - const delegationClaimHook = useClaimSplitRewards( - currentSplitContract, - currentTask?.splitData || { recipients: [], allocations: [], totalAllocation: 0n, distributionIncentive: 0 }, - 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 - - return { - recipients: [delegation.providerRewardsRecipient as Address, user], - allocations: [providerAllocation, userAllocation], - totalAllocation, - distributionIncentive: 0 - } - }, []) - - /** - * Start claiming all rewards - */ - const startClaiming = useCallback((delegations: DelegationBreakdown[], coinbases: CoinbaseBreakdown[]) => { - if (!userAddress || (!delegations.length && !coinbases.length)) return - - cancelledRef.current = false - - // 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}`, - type: 'coinbase', - displayName: `${coinbase.address.slice(0, 6)}...${coinbase.address.slice(-4)}`, - estimatedRewards: coinbase.rewards, - status: 'pending', - coinbaseAddress: coinbase.address - })) - ] - - // Filter out tasks with no rewards - const tasksWithRewards = newTasks.filter(task => task.estimatedRewards > 0n) - - if (tasksWithRewards.length === 0) { - setError(new Error('No rewards to claim')) - return - } - - setTasks(tasksWithRewards) - setCurrentTaskIndex(0) - setIsProcessing(true) - setError(null) - setHasTriggeredClaim(false) - - // Reset hooks - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [userAddress, buildSplitData, delegationClaimHook, coinbaseClaimHook]) - - /** - * Cancel claiming - stops processing but keeps completed - */ - const cancelClaiming = useCallback(() => { - cancelledRef.current = true - 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) - } - }, [tasks]) - - /** - * Reset all state - */ - const reset = useCallback(() => { - cancelledRef.current = false - setTasks([]) - setCurrentTaskIndex(null) - setIsProcessing(false) - setError(null) - setHasTriggeredClaim(false) - delegationClaimHook.reset() - coinbaseClaimHook.reset() - }, [delegationClaimHook, coinbaseClaimHook]) - - /** - * Start claim for current task when ready - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null || hasTriggeredClaim || cancelledRef.current) return - - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'pending') return - - // 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) - - // Start the appropriate claim - if (task.type === 'delegation') { - delegationClaimHook.claim() - } else if (task.type === 'coinbase' && task.coinbaseAddress) { - coinbaseClaimHook.claimRewards(task.coinbaseAddress) - } - }, [isProcessing, currentTaskIndex, tasks, hasTriggeredClaim, isLoadingBalances, delegationClaimHook, coinbaseClaimHook]) - - /** - * Update sub-step for delegation tasks - */ - useEffect(() => { - if (!currentTask || currentTask.type !== 'delegation' || !isProcessing) return - - const subStep = delegationClaimHook.claimStep - if (subStep !== 'idle') { - setTasks(prev => prev.map((t, i) => - i === currentTaskIndex ? { ...t, currentSubStep: subStep as 'claiming' | 'distributing' | 'withdrawing' } : t - )) - } - }, [delegationClaimHook.claimStep, currentTaskIndex, currentTask?.type, isProcessing]) - - /** - * Handle task completion and move to next - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null || !hasTriggeredClaim || cancelledRef.current) return - - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'processing') return - - let isComplete = false - - // Check completion based on task type - if (task.type === 'delegation') { - isComplete = delegationClaimHook.isSuccess && delegationClaimHook.claimStep === 'idle' - } else if (task.type === 'coinbase') { - isComplete = coinbaseClaimHook.isSuccess - } - - if (isComplete) { - // 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(() => { - if (cancelledRef.current) return - - // Move to next task - const nextIndex = currentTaskIndex + 1 - if (nextIndex < tasks.length) { - setCurrentTaskIndex(nextIndex) - setHasTriggeredClaim(false) - } else { - // All done - setIsProcessing(false) - setCurrentTaskIndex(null) - } - }, 500) - - return () => clearTimeout(timeoutId) - } - }, [ - isProcessing, - currentTaskIndex, - tasks, - hasTriggeredClaim, - delegationClaimHook.isSuccess, - delegationClaimHook.claimStep, - coinbaseClaimHook.isSuccess, - delegationClaimHook, - coinbaseClaimHook - ]) - - /** - * Handle errors - */ - useEffect(() => { - if (!isProcessing || currentTaskIndex === null) return - - const task = tasks[currentTaskIndex] - if (!task || task.status !== 'processing') return - - let taskError: Error | null = null - - if (task.type === 'delegation' && delegationClaimHook.isError) { - taskError = delegationClaimHook.error as Error - } else if (task.type === 'coinbase' && coinbaseClaimHook.isError) { - taskError = coinbaseClaimHook.error as Error - } - - if (taskError) { - // Mark task as failed - 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 - delegationClaimHook.reset() - coinbaseClaimHook.reset() - } - }, [ - isProcessing, - currentTaskIndex, - tasks, - delegationClaimHook.isError, - delegationClaimHook.error, - coinbaseClaimHook.isError, - coinbaseClaimHook.error, - delegationClaimHook, - coinbaseClaimHook - ]) - - // Calculate progress - const completedTasks = tasks.filter(t => t.status === 'completed') - const failedTasks = tasks.filter(t => t.status === 'error') - const progressPercent = tasks.length > 0 - ? Math.round((completedTasks.length / tasks.length) * 100) - : 0 - - // Determine overall success/error state - const isSuccess = tasks.length > 0 && completedTasks.length === tasks.length - const isError = failedTasks.length > 0 - - return { - startClaiming, - cancelClaiming, - retryFailed, - reset, - tasks, - currentTask, - currentTaskIndex, - isProcessing, - progressPercent, - isSuccess, - isError, - error, - completedTasks, - failedTasks - } -} diff --git a/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts b/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts deleted file mode 100644 index 72c9fd6ef..000000000 --- a/staking-dashboard/src/hooks/rewards/useClaimCoinbaseRewards.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" -import type { Address } from "viem" - -/** - * 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 - */ -export function useClaimCoinbaseRewards() { - const claimSequencerRewards = useClaimSequencerRewards() - - return { - claimRewards: (coinbaseAddress: Address) => claimSequencerRewards.claimRewards(coinbaseAddress), - reset: claimSequencerRewards.reset, - txHash: claimSequencerRewards.txHash, - error: claimSequencerRewards.error, - isPending: claimSequencerRewards.isPending, - isConfirming: claimSequencerRewards.isConfirming, - isSuccess: claimSequencerRewards.isSuccess, - isError: claimSequencerRewards.isError - } -} diff --git a/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts new file mode 100644 index 000000000..7dd3f179e --- /dev/null +++ b/staking-dashboard/src/hooks/rewards/useCoinbaseRewardsAcrossRollups.ts @@ -0,0 +1,97 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts, getRollupVersions, type RollupVersion } from "@/contracts" +import type { CoinbaseBreakdown } from "./rewardsTypes" + +/** + * Multicalls `getSequencerRewards(coinbase)` across every rollup version + * returned by `/api/rollups`. Emits one `CoinbaseBreakdown` per + * `(coinbase, rollup)` pair so stranded balances on non-canonical rollups + * surface as their own rows in the UI (and the claim engine can route each + * claim call to the right rollup contract). + */ +export function useCoinbaseRewardsAcrossRollups(coinbaseAddresses: Address[]) { + // `getRollupVersions()` returns oldest-first. The raw `version` is a uint256 + // id from the Registry (e.g. "2934756905") which is awful for UI. We replace + // it with a 1-based ordinal — "1" = genesis rollup, "2" = first upgrade, + // etc. — and display that everywhere as "Rollup v{ordinal}". + const rollups = useMemo>(() => { + const versions = getRollupVersions() + if (versions.length > 0) { + return versions.map((v: RollupVersion, i) => ({ + address: v.address, + version: String(i + 1), + })) + } + return [{ address: contracts.rollup.address, version: undefined }] + }, []) + + const pairs = useMemo(() => { + const out: Array<{ rollupAddress: Address; rollupVersion?: string; coinbase: Address }> = [] + for (const rollup of rollups) { + for (const coinbase of coinbaseAddresses) { + out.push({ rollupAddress: rollup.address, rollupVersion: rollup.version, coinbase }) + } + } + return out + }, [rollups, 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, + refetchInterval: 30 * 1000, + }, + }) + + const allCoinbaseBreakdown = useMemo(() => { + if (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 + out.push({ + address: pair.coinbase, + rewards, + source: "manual", + rollupAddress: pair.rollupAddress, + rollupVersion: pair.rollupVersion, + }) + } + return out + }, [data, pairs]) + + const coinbaseBreakdown = useMemo( + () => allCoinbaseBreakdown.filter((item) => item.rewards > 0n), + [allCoinbaseBreakdown], + ) + + const totalCoinbaseRewards = useMemo( + () => coinbaseBreakdown.reduce((total, item) => total + item.rewards, 0n), + [coinbaseBreakdown], + ) + + return { + allCoinbaseBreakdown, + coinbaseBreakdown, + totalCoinbaseRewards, + isLoading, + isError, + error, + refetch, + } +} diff --git a/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts b/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts index dce6c4cf8..652a3648d 100644 --- a/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts +++ b/staking-dashboard/src/hooks/rewards/useMultipleCoinbaseRewards.ts @@ -1,53 +1,12 @@ -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 + * Fetch rewards for multiple coinbase addresses. Thin wrapper over + * {@link useCoinbaseRewardsAcrossRollups} — each address's `rewards` is + * summed across all rollup versions so stranded balances on non-canonical + * rollups appear in the claimable-rewards total. */ 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/index.ts b/staking-dashboard/src/hooks/rollup/index.ts index d35471714..0dc52180e 100644 --- a/staking-dashboard/src/hooks/rollup/index.ts +++ b/staking-dashboard/src/hooks/rollup/index.ts @@ -1,8 +1,8 @@ export { useRollupData } from "./useRollupData"; export { useActivationThresholdFormatted } from "./useActivationThresholdFormatted"; 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"; diff --git a/staking-dashboard/src/hooks/rollup/sequencerStatus.ts b/staking-dashboard/src/hooks/rollup/sequencerStatus.ts new file mode 100644 index 000000000..7f3925fb2 --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/sequencerStatus.ts @@ -0,0 +1,36 @@ +/** + * Sequencer status enum + label helper. + * + * Lives in its own module (rather than `useSequencerStatus.ts`) because + * `useAttesterViewBestEffort` needs the enum, but `useSequencerStatus` + * already depends on `useAttesterViewBestEffort` — putting them together + * creates a circular import. + * + * Status values come straight from the rollup contract's `getAttesterView`: + * 0 = NONE — not registered in this rollup + * 1 = VALIDATING — active validator + * 2 = ZOMBIE — registered but not validating (e.g. slashed below threshold) + * 3 = EXITING — withdrawal initiated + */ +export enum SequencerStatus { + NONE = 0, + VALIDATING = 1, + ZOMBIE = 2, + EXITING = 3, +} + +export function getStatusLabel(status: number | undefined): string { + if (status === undefined) return "Unknown" + switch (status) { + case SequencerStatus.NONE: + return "None" + case SequencerStatus.VALIDATING: + return "Validating" + case SequencerStatus.ZOMBIE: + return "Inactive" + case SequencerStatus.EXITING: + return "Exiting/Unstaking" + default: + return "Unknown" + } +} diff --git a/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts b/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts new file mode 100644 index 000000000..de7682d39 --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useAttesterViewBestEffort.ts @@ -0,0 +1,51 @@ +import type { Address } from "viem" +import { useAttesterView } from "./useAttesterView" +import { SequencerStatus } from "./sequencerStatus" +import { contracts } from "@/contracts" + +/** + * Looks up an attester via `getAttesterView` against both the canonical rollup + * and the delegation's legacy rollup (when different), and returns whichever + * view recognises the attester (non-NONE status). Covers: + * + * - Active sequencer on canonical rollup with old delegation record — the + * legacy view returns NONE; we fall through to canonical. + * - Legacy stake mid-withdrawal — canonical returns NONE; we fall through + * to legacy so the exit data is still visible. + * - Genuinely unregistered — both NONE; we return the canonical view so + * callers still get a well-defined result. + * + * Used by `useSequencerStatus` and `useStakeHealth` to avoid duplicating the + * preference logic. + */ +export function useAttesterViewBestEffort( + attesterAddress: Address | undefined, + rollupAddress: Address | undefined, +) { + const canonicalRollup = contracts.rollup.address + const isLegacyDifferent = + !!rollupAddress && rollupAddress.toLowerCase() !== canonicalRollup.toLowerCase() + + const canonicalView = useAttesterView(attesterAddress, canonicalRollup) + const legacyView = useAttesterView( + attesterAddress, + isLegacyDifferent ? rollupAddress : undefined, + ) + + const preferred = + canonicalView.status !== undefined && canonicalView.status !== SequencerStatus.NONE + ? canonicalView + : isLegacyDifferent && legacyView.status !== undefined && legacyView.status !== SequencerStatus.NONE + ? legacyView + : canonicalView + + return { + ...preferred, + isLoading: canonicalView.isLoading || (isLegacyDifferent && legacyView.isLoading), + error: preferred.error || (isLegacyDifferent ? legacyView.error : undefined), + refetch: () => { + canonicalView.refetch() + if (isLegacyDifferent) legacyView.refetch() + }, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts deleted file mode 100644 index 3698a4fe9..000000000 --- a/staking-dashboard/src/hooks/rollup/useClaimSequencerRewards.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useWriteContract, useWaitForTransactionReceipt } from "@/hooks/useWagmiStrategy" -import { contracts } from "@/contracts" -import type { Address } from "viem" - -/** - * Hook to claim sequencer rewards to a specified coinbase address - */ -export function useClaimSequencerRewards() { - const write = useWriteContract() - - const receipt = useWaitForTransactionReceipt({ - hash: write.data - }) - - return { - claimRewards: (coinbaseAddress: Address) => { - return write.writeContract({ - abi: contracts.rollup.abi, - address: contracts.rollup.address, - functionName: "claimSequencerRewards", - args: [coinbaseAddress] - }) - }, - reset: write.reset, - txHash: write.data, - error: write.error || receipt.error, - isPending: write.isPending, - isConfirming: receipt.isLoading, - isSuccess: receipt.isSuccess, - isError: write.isError || receipt.isError - } -} diff --git a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts index b166f90a2..5bb7f7113 100644 --- a/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimable.ts @@ -1,13 +1,17 @@ import { useReadContract } from "wagmi" +import type { Address } from "viem" import { contracts } from "@/contracts" /** - * 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. */ -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/useIsRewardsClaimableAcrossRollups.ts b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts new file mode 100644 index 000000000..1d5f67eae --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useIsRewardsClaimableAcrossRollups.ts @@ -0,0 +1,63 @@ +import { useMemo } from "react" +import { useReadContracts } from "wagmi" +import type { Address } from "viem" +import { contracts } from "@/contracts" + +/** + * Multicalls `isRewardsClaimable()` across a list of rollup contracts. + * Returns a map keyed by lowercased rollup address; `undefined` means the + * value is still loading (or the call reverted). + */ +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]) + + const isClaimable = (rollupAddress: Address): boolean | undefined => { + return claimableByRollup.get(rollupAddress.toLowerCase()) + } + + return { + claimableByRollup, + isClaimable, + isLoading, + error, + } +} diff --git a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts index b78488a57..ea315b17e 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerRewards.ts @@ -1,14 +1,18 @@ import { useReadContract } from "wagmi" -import { contracts } from "@/contracts" import type { Address } from "viem" +import { contracts } from "@/contracts" /** - * Hook to get sequencer rewards for a specific coinbase address + * Hook to get sequencer rewards for a specific coinbase address. + * + * @param coinbaseAddress - Coinbase address to query rewards for + * @param rollupAddress - Optional rollup contract to query. Defaults to the configured rollup. */ -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 7720f1c86..48127fe0f 100644 --- a/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts +++ b/staking-dashboard/src/hooks/rollup/useSequencerStatus.ts @@ -1,53 +1,28 @@ import type { Address } from "viem"; import { useBlock } from "wagmi"; -import { useAttesterView } from "./useAttesterView"; +import { useAttesterViewBestEffort } from "./useAttesterViewBestEffort"; import { useGovernanceWithdrawal } from "../governance/useGovernanceWithdrawal"; +import { SequencerStatus, getStatusLabel } from "./sequencerStatus"; -/** - * Enum for sequencer status values - * 0 = NONE - Does not exist in the setup - * 1 = VALIDATING - Participating as validator - * 2 = ZOMBIE - Not participating as validator, but have funds in setup (hit if slashed and going below the minimum) - * 3 = EXITING - In the process of exiting the system - */ -export enum SequencerStatus { - NONE = 0, - VALIDATING = 1, - ZOMBIE = 2, - EXITING = 3, -} - -/** - * Helper to get human-readable status label - */ -export function getStatusLabel(status: number | undefined): string { - if (status === undefined) return "Unknown"; - - switch (status) { - case SequencerStatus.NONE: - return "None"; - case SequencerStatus.VALIDATING: - return "Validating"; - case SequencerStatus.ZOMBIE: - return "Inactive"; - case SequencerStatus.EXITING: - return "Exiting/Unstaking"; - default: - return "Unknown"; - } -} +// Re-export so existing `from "@/hooks/rollup/useSequencerStatus"` imports +// across the codebase keep working without churn. +export { SequencerStatus, getStatusLabel }; /** - * Hook to get sequencer status information + * Hook to get sequencer status information. Delegates the + * canonical-vs-legacy-rollup lookup to `useAttesterViewBestEffort` so the + * same preference logic doesn't drift between this hook and `useStakeHealth`. + * * @param sequencerAddress - The address of the sequencer - * @returns Sequencer status, label, and related information + * @param rollupAddress - The delegation's original rollup. May be undefined + * while the caller's data is still loading. */ export function useSequencerStatus( sequencerAddress: Address | undefined, rollupAddress: Address | undefined, ) { const { status, effectiveBalance, exit, isLoading, error, refetch } = - useAttesterView(sequencerAddress, rollupAddress); + useAttesterViewBestEffort(sequencerAddress, rollupAddress); // Query the governance withdrawal to get the REAL unlock time const { withdrawal, isLoading: isLoadingWithdrawal } = useGovernanceWithdrawal(exit?.withdrawalId); @@ -59,7 +34,6 @@ export function useSequencerStatus( // In production, blockchain time ~= real time so this doesn't matter. const { data: block } = useBlock({ watch: true }); const blockTimestamp = block?.timestamp ?? BigInt(Math.floor(Date.now() / 1000)); - // const currentTimestamp = BigInt(Math.floor(Date.now() / 1000)); const statusLabel = getStatusLabel(status); const isActive = status === SequencerStatus.VALIDATING; diff --git a/staking-dashboard/src/hooks/rollup/useStakeHealth.ts b/staking-dashboard/src/hooks/rollup/useStakeHealth.ts index f812667e5..3a64c6222 100644 --- a/staking-dashboard/src/hooks/rollup/useStakeHealth.ts +++ b/staking-dashboard/src/hooks/rollup/useStakeHealth.ts @@ -1,5 +1,5 @@ import type { Address } from "viem" -import { useAttesterView } from "./useAttesterView" +import { useAttesterViewBestEffort } from "./useAttesterViewBestEffort" import { useEjectionThreshold } from "./useEjectionThreshold" import { useActivationThresholdFormatted } from "./useActivationThresholdFormatted" @@ -8,22 +8,28 @@ export interface StakeHealth { activationThreshold: bigint | undefined ejectionThreshold: bigint | undefined healthPercentage: number - slashCount: number + /** Cumulative amount lost from the original activation stake (>= 0). */ + lossAmount: bigint + /** `lossAmount` as a percentage of `activationThreshold`. 0 when at full stake. */ + lossPercentage: number isAtRisk: boolean isCritical: boolean } -// Slash amount is 2,000 tokens with 18 decimals -const SLASH_AMOUNT = 2000n * 10n ** 18n - /** - * Hook to calculate stake health for an attester - * Returns health percentage, slash count estimate, and risk indicators + * Hook to calculate stake health for an attester. Returns health percentage, + * the cumulative loss (raw + percentage of activation threshold), and risk + * indicators. * * Health percentage is calculated as: * - 100% = effectiveBalance equals activationThreshold (full stake, no slashes) * - 0% = effectiveBalance equals or below ejectionThreshold (will be ejected) * + * `lossAmount` / `lossPercentage` capture how much stake has been slashed + * regardless of the per-slash penalty. The slasher contract sets the penalty + * (and it changes over time), so we derive the loss purely from on-chain + * balance rather than estimating a count. + * * Risk levels: * - isAtRisk: healthPercentage < 50 (has been slashed significantly) * - isCritical: effectiveBalance <= ejectionThreshold (imminent ejection) @@ -32,8 +38,13 @@ export function useStakeHealth( attesterAddress: Address | undefined, rollupAddress: Address | undefined, ) { - const { effectiveBalance, status, isLoading: isLoadingAttester, error: attesterError, refetch: refetchAttester } = - useAttesterView(attesterAddress, rollupAddress) + const { + effectiveBalance, + status, + isLoading: isLoadingAttester, + error: attesterError, + refetch: refetchAttester, + } = useAttesterViewBestEffort(attesterAddress, rollupAddress) const { ejectionThreshold, isLoading: isLoadingEjection, error: ejectionError, refetch: refetchEjection } = useEjectionThreshold() @@ -45,27 +56,35 @@ export function useStakeHealth( const error = attesterError || ejectionError || activationError let healthPercentage = 100 - let slashCount = 0 + let lossAmount = 0n + let lossPercentage = 0 let isAtRisk = false let isCritical = false if (effectiveBalance !== undefined && activationThreshold !== undefined && ejectionThreshold !== undefined) { - // Calculate health as percentage between ejection threshold (0%) and activation threshold (100%) - const healthRange = activationThreshold - ejectionThreshold - const currentHealth = effectiveBalance - ejectionThreshold - - if (healthRange > 0n) { + // `healthPercentage` is "how much of the cushion between activation and + // ejection thresholds is left". This drives bar fill + color, since what + // the user actually cares about is proximity to ejection. The cumulative + // loss (`lossAmount` / `lossPercentage` below) is surfaced separately as + // a headline so a small slash isn't visually misread as "near ejection". + const cushionRange = activationThreshold - ejectionThreshold + const cushionLeft = effectiveBalance - ejectionThreshold + if (cushionRange > 0n) { healthPercentage = Math.max(0, Math.min(100, - Number((currentHealth * 100n) / healthRange) + Number((cushionLeft * 100n) / cushionRange) )) } - // Estimate slash count based on how much stake has been lost + // Cumulative loss vs the activation threshold. Bigint subtraction with a + // floor at 0 — effectiveBalance can briefly exceed activation in edge cases. if (effectiveBalance < activationThreshold) { - slashCount = Number((activationThreshold - effectiveBalance) / SLASH_AMOUNT) + lossAmount = activationThreshold - effectiveBalance + if (activationThreshold > 0n) { + // 10000 scale so 1% loss shows as 1.00 rather than rounding to 1. + lossPercentage = Number((lossAmount * 10000n) / activationThreshold) / 100 + } } - // Risk indicators isAtRisk = healthPercentage < 50 isCritical = effectiveBalance <= ejectionThreshold } @@ -80,7 +99,8 @@ export function useStakeHealth( activationThreshold, ejectionThreshold, healthPercentage, - slashCount, + lossAmount, + lossPercentage, isAtRisk, isCritical, status, diff --git a/staking-dashboard/src/hooks/splits/index.ts b/staking-dashboard/src/hooks/splits/index.ts index c058f8807..7e28c4da1 100644 --- a/staking-dashboard/src/hooks/splits/index.ts +++ b/staking-dashboard/src/hooks/splits/index.ts @@ -2,7 +2,6 @@ export * from "./useSplitHash"; export * from "./useSplitsWarehouse"; export * from "./useDistributeRewards"; export * from "./useWithdrawRewards"; -export * from "./useClaimSplitRewards"; export * from "./useWarehouseBalance"; export * from "./useTotalSplitRewards"; export * from "./types"; diff --git a/staking-dashboard/src/hooks/splits/types.ts b/staking-dashboard/src/hooks/splits/types.ts index 70480f487..05bab5bcb 100644 --- a/staking-dashboard/src/hooks/splits/types.ts +++ b/staking-dashboard/src/hooks/splits/types.ts @@ -6,5 +6,3 @@ export interface SplitData { totalAllocation: bigint distributionIncentive: number } - -export type ClaimStep = 'idle' | 'claiming' | 'distributing' | 'withdrawing' diff --git a/staking-dashboard/src/hooks/splits/useClaimAllSplitRewards.ts b/staking-dashboard/src/hooks/splits/useClaimAllSplitRewards.ts deleted file mode 100644 index c7a87fef7..000000000 --- a/staking-dashboard/src/hooks/splits/useClaimAllSplitRewards.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { useState, useEffect, useCallback, useMemo } from "react" -import { useAccount } from "wagmi" -import { useClaimSplitRewards } from "./useClaimSplitRewards" -import { useSequencerRewards } from "@/hooks/rollup/useSequencerRewards" -import { useERC20Balance } from "@/hooks/erc20/useERC20Balance" -import { useWarehouseBalance } from "./useWarehouseBalance" -import { useSplitsWarehouse } from "./useSplitsWarehouse" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" -import type { Address } from "viem" -import type { SplitData } from "./types" - -interface ClaimTask { - splitContract: Address - splitData: SplitData - tokenAddress: Address - userAddress: Address - onSuccess?: () => void -} - -type ProcessStep = 'idle' | 'processing' - -/** - * Hook to manage claiming multiple split contract rewards sequentially - * Processes one claim at a time through both distribute and withdraw steps - */ -export const useClaimAllSplitRewards = () => { - const { address: beneficiary } = useAccount() // TODO : should get the address from atp.beneficiary to handle the condition where the connected address is operator - - const [processStep, setProcessStep] = useState('idle') - const [currentIndex, setCurrentIndex] = useState(null) - const [tasks, setTasks] = useState([]) - const [error, setError] = useState(null) - const [completedCount, setCompletedCount] = useState(0) - const [hasTriggeredClaim, setHasTriggeredClaim] = useState(false) - - // Get the current task's split contract address - const currentTask = currentIndex !== null && tasks[currentIndex] ? tasks[currentIndex] : null - - // Get token address for balance queries - const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() - - // Fetch balances for current task - extract refetch functions - const { warehouseAddress, isLoading: isLoadingWarehouse } = useSplitsWarehouse(currentTask?.splitContract) - const { rewards: rollupBalance, isLoading: isLoadingRollupBalance, refetch: refetchRollup } = useSequencerRewards(currentTask?.splitContract || '') - const { balance: splitContractBalance, isLoading: isLoadingSplitContractBalance, refetch: refetchSplitContract } = useERC20Balance(tokenAddress, currentTask?.splitContract) - const { balance: warehouseBalance, isLoading: isLoadingWarehouseBalance, refetch: refetchWarehouse } = useWarehouseBalance(warehouseAddress, beneficiary, tokenAddress) - - const isLoading = isLoadingWarehouse || isLoadingRollupBalance || isLoadingSplitContractBalance || isLoadingWarehouseBalance - - // 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]) - - // Use the single claim hook for the current task - const claimHook = useClaimSplitRewards( - currentTask?.splitContract, - currentTask?.splitData || { recipients: [], allocations: [], totalAllocation: 0n, distributionIncentive: 0 }, - currentTask?.tokenAddress, - currentTask?.userAddress, - balances - ) - - // Monitor claim completion and move to next task - useEffect(() => { - if (!currentTask || processStep !== 'processing' || isLoading) return - - // If current claim succeeded, move to next task - if (claimHook.isSuccess && claimHook.claimStep === 'idle') { - const moveToNext = async () => { - setCompletedCount(prev => prev + 1) - - // Trigger onSuccess callback for current task - if (currentTask.onSuccess) { - currentTask.onSuccess() - } - - // Reset hook first to clean up state - claimHook.reset() - - // Small delay to ensure state is clean before moving to next task - await new Promise(resolve => setTimeout(resolve, 100)) - - // Move to next task or finish - if (currentIndex !== null && currentIndex < tasks.length - 1) { - setCurrentIndex(currentIndex + 1) - setHasTriggeredClaim(false) - } else { - // All tasks completed - setProcessStep('idle') - setCurrentIndex(null) - setHasTriggeredClaim(false) - } - } - - moveToNext() - } - - // Handle errors - cancel entire batch on any error - if (claimHook.isError) { - setError(claimHook.error as Error) - setProcessStep('idle') - setCurrentIndex(null) - setHasTriggeredClaim(false) - claimHook.reset() - } - }, [claimHook.isSuccess, claimHook.isError, claimHook.claimStep, currentIndex, tasks.length, processStep, currentTask, isLoading]) - - // Start claiming when we have a current task and we're in the 'processing' state - useEffect(() => { - if (!currentTask || !beneficiary || processStep !== 'processing' || isLoading) return - - // Only trigger claim if we're idle (not already claiming) and haven't triggered yet for this task - if (claimHook.claimStep === 'idle' && !claimHook.isClaiming && !hasTriggeredClaim) { - setHasTriggeredClaim(true) - claimHook.claim() - } - }, [currentTask, beneficiary, processStep, claimHook.claimStep, claimHook.isClaiming, hasTriggeredClaim, isLoading]) - - const claimAll = useCallback((newTasks: ClaimTask[]) => { - if (!beneficiary || newTasks.length === 0) return - - setTasks(newTasks) - setProcessStep('processing') - setError(null) - setCurrentIndex(0) - setCompletedCount(0) - setHasTriggeredClaim(false) - }, [beneficiary]) - - const cancel = useCallback(() => { - setProcessStep('idle') - setCurrentIndex(null) - setHasTriggeredClaim(false) - claimHook.reset() - }, []) - - const reset = useCallback(() => { - setProcessStep('idle') - setCurrentIndex(null) - setTasks([]) - setError(null) - setCompletedCount(0) - setHasTriggeredClaim(false) - claimHook.reset() - }, []) - - return { - claimAll, - cancel, - reset, - isProcessing: processStep === 'processing', - currentIndex, - totalTasks: tasks.length, - completedCount, - error, - progress: currentIndex !== null ? `${currentIndex + 1}/${tasks.length}` : null, - currentStep: currentTask ? claimHook.claimStep : 'idle', - skipMessage: currentTask ? claimHook.skipMessage : null, - completedMessage: currentTask ? claimHook.completedMessage : null, - distributeTxHash: claimHook.distributeTxHash, - withdrawTxHash: claimHook.withdrawTxHash, - tasks, - } -} diff --git a/staking-dashboard/src/hooks/splits/useClaimSplitRewards.ts b/staking-dashboard/src/hooks/splits/useClaimSplitRewards.ts deleted file mode 100644 index f60159f9b..000000000 --- a/staking-dashboard/src/hooks/splits/useClaimSplitRewards.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { useState, useEffect, useRef } from "react" -import { useDistributeRewards } from "./useDistributeRewards" -import { useWithdrawRewards } from "./useWithdrawRewards" -import { useSplitsWarehouse } from "./useSplitsWarehouse" -import { useClaimSequencerRewards } from "@/hooks/rollup/useClaimSequencerRewards" -import type { Address } from "viem" -import type { SplitData, ClaimStep } from "./types" - -interface BalanceData { - rollupBalance?: bigint - splitContractBalance?: bigint - warehouseBalance?: bigint - refetchRollup?: () => Promise - refetchSplitContract?: () => Promise - refetchWarehouse?: () => Promise -} - -type QueueStep = 'claiming' | 'distributing' | 'withdrawing' - -/** - * Hook to manage the complete claim flow for split contract rewards - * Sequential flow: claim from rollup → distribute to warehouse → withdraw to user - * Skips steps with zero balances - */ -export const useClaimSplitRewards = ( - splitContractAddress: Address | undefined, - splitData: SplitData, - tokenAddress: Address | undefined, - userAddress: Address | undefined, - balances?: BalanceData -) => { - const [queue, setQueue] = useState([]) - const [claimStep, setClaimStep] = useState('idle') - const [skipMessage, setSkipMessage] = useState(null) - const [completedMessage, setCompletedMessage] = useState(null) - const [isProcessing, setIsProcessing] = useState(false) - const [refetchError, setRefetchError] = useState(null) - - // Track which step is currently completing to prevent duplicate timeout scheduling - const completingStepRef = useRef(null) - - // Get warehouse address from split contract - const { warehouseAddress, isLoading: isLoadingWarehouse } = useSplitsWarehouse(splitContractAddress) - - const claimHook = useClaimSequencerRewards() - const distributeHook = useDistributeRewards(splitContractAddress) - const withdrawHook = useWithdrawRewards(warehouseAddress) - - /** - * Queue processor - processes first item in queue - */ - useEffect(() => { - if (queue.length === 0) { - setClaimStep('idle') - return - } - - if (isProcessing) return - - const currentStep = queue[0] - setClaimStep(currentStep as ClaimStep) - - // CLAIMING - if (currentStep === 'claiming') { - const balance = balances?.rollupBalance - // Wait for balance to load before processing - if (balance === undefined) return - - if (balance === 0n) { - setSkipMessage('No rewards to claim from rollup') - setTimeout(() => { - setSkipMessage(null) - setQueue(prev => prev.filter(step => step !== 'claiming')) - }, 1000) - } else if (splitContractAddress) { - setIsProcessing(true) - claimHook.claimRewards(splitContractAddress) - } - return - } - - // DISTRIBUTING - if (currentStep === 'distributing') { - const balance = balances?.splitContractBalance - // Wait for balance to load before processing - if (balance === undefined) return - - if (balance === 0n) { - setSkipMessage('No rewards to distribute') - setTimeout(() => { - setSkipMessage(null) - setQueue(prev => prev.filter(step => step !== 'distributing')) - }, 1000) - } else if (tokenAddress) { - setIsProcessing(true) - distributeHook.distribute(splitData, tokenAddress) - } - return - } - - // WITHDRAWING - if (currentStep === 'withdrawing') { - const balance = balances?.warehouseBalance - // Wait for balance to load before processing - if (balance === undefined) return - - if (balance === 0n) { - setSkipMessage('No rewards to withdraw') - setTimeout(() => { - setSkipMessage(null) - setQueue(prev => prev.filter(step => step !== 'withdrawing')) - }, 1000) - } else if (userAddress && tokenAddress) { - setIsProcessing(true) - withdrawHook.withdraw(userAddress, tokenAddress) - } - return - } - }, [queue, balances]) - - // Handle transaction success - show completion message, refetch balances, then remove from queue - useEffect(() => { - if (!isProcessing || queue.length === 0) return - - const currentStep = queue[0] - let stepCompleted = false - let message = '' - let stepToRemove: QueueStep | null = null - - if (currentStep === 'claiming' && claimHook.isSuccess) { - stepCompleted = true - message = 'Claimed successfully' - stepToRemove = 'claiming' - } else if (currentStep === 'distributing' && distributeHook.isSuccess) { - stepCompleted = true - message = 'Distributed successfully' - stepToRemove = 'distributing' - } else if (currentStep === 'withdrawing' && withdrawHook.isSuccess) { - stepCompleted = true - message = 'Withdrawn successfully' - stepToRemove = 'withdrawing' - } - - if (stepCompleted && stepToRemove) { - // Guard: prevent duplicate timeout scheduling if already completing this step - if (completingStepRef.current === stepToRemove) return - - completingStepRef.current = stepToRemove - setCompletedMessage(message) - - // Determine which balances need refetching for the NEXT step - const refetchPromises: Promise[] = [] - - if (stepToRemove === 'claiming') { - // Next step is 'distributing', which checks splitContractBalance - if (balances?.refetchSplitContract) { - refetchPromises.push(balances.refetchSplitContract()) - } - } else if (stepToRemove === 'distributing') { - // After distributing, tokens move from split contract to warehouse - // Refetch BOTH balances to keep the UI accurate - if (balances?.refetchSplitContract) { - refetchPromises.push(balances.refetchSplitContract()) - } - if (balances?.refetchWarehouse) { - refetchPromises.push(balances.refetchWarehouse()) - } - } - - // Wait for refetch to complete before advancing - if (refetchPromises.length > 0) { - Promise.all(refetchPromises) - .then(() => { - // Refetch succeeded - advance to next step after delay - setTimeout(() => { - setCompletedMessage(null) - setIsProcessing(false) - setQueue(prev => prev.filter(step => step !== stepToRemove)) - completingStepRef.current = null // Clear ref after advancing - }, 500) - }) - .catch(err => { - console.error('Balance refetch failed:', err) - // Treat refetch failure as an error - halt the flow completely - setRefetchError(err instanceof Error ? err : new Error('Balance refetch failed')) - setCompletedMessage(null) - setQueue([]) - setClaimStep('idle') - setIsProcessing(false) - completingStepRef.current = null // Clear ref on error - }) - } else { - // No refetch needed (e.g., last step) - advance immediately - setTimeout(() => { - setCompletedMessage(null) - setIsProcessing(false) - setQueue(prev => prev.filter(step => step !== stepToRemove)) - completingStepRef.current = null // Clear ref after advancing - }, 500) - } - } - }, [queue, claimHook.isSuccess, distributeHook.isSuccess, withdrawHook.isSuccess, isProcessing, balances]) - - // Handle errors - reset queue - useEffect(() => { - if (claimHook.isError || distributeHook.isError || withdrawHook.isError) { - setSkipMessage(null) - setQueue([]) - setClaimStep('idle') - setIsProcessing(false) - setRefetchError(null) - completingStepRef.current = null - claimHook.reset() - distributeHook.reset() - withdrawHook.reset() - } - }, [claimHook.isError, distributeHook.isError, withdrawHook.isError]) - - const claim = () => { - if (!warehouseAddress) return - setSkipMessage(null) - setRefetchError(null) - setIsProcessing(false) - completingStepRef.current = null - setQueue(['claiming', 'distributing', 'withdrawing']) - } - - const isClaiming = queue.length > 0 - const isSuccess = queue.length === 0 && withdrawHook.isSuccess - - return { - claim, - claimStep, - skipMessage, - completedMessage, - warehouseAddress, - isLoading: isLoadingWarehouse, - isClaiming, - isSuccess, - isError: claimHook.isError || distributeHook.isError || withdrawHook.isError || !!refetchError, - error: refetchError || claimHook.error || distributeHook.error || withdrawHook.error, - claimTxHash: claimHook.txHash, - distributeTxHash: distributeHook.txHash, - withdrawTxHash: withdrawHook.txHash, - reset: () => { - setQueue([]) - setClaimStep('idle') - setSkipMessage(null) - setCompletedMessage(null) - setIsProcessing(false) - setRefetchError(null) - completingStepRef.current = null - claimHook.reset() - distributeHook.reset() - withdrawHook.reset() - } - } -} diff --git a/staking-dashboard/src/hooks/splits/useDistributeRewards.ts b/staking-dashboard/src/hooks/splits/useDistributeRewards.ts index 2f39f69fd..4a4a796b9 100644 --- a/staking-dashboard/src/hooks/splits/useDistributeRewards.ts +++ b/staking-dashboard/src/hooks/splits/useDistributeRewards.ts @@ -1,8 +1,35 @@ import { useWriteContract, useWaitForTransactionReceipt } from "@/hooks/useWagmiStrategy" import { useAccount } from "wagmi" -import { type Address } from "viem" +import { encodeFunctionData, type Address } from "viem" import { SplitAbi } from "@/contracts/abis/Split" import type { SplitData } from "./types" +import type { RawTransaction } from "@/contexts/TransactionCartContextType" + +/** + * Build a `Split.distribute(splitData, token, distributor)` raw transaction. + */ +export function buildDistributeRewardsTx( + splitContractAddress: Address, + splitData: SplitData, + tokenAddress: Address, + distributorAddress: Address, +): RawTransaction { + const tuple = { + recipients: splitData.recipients, + allocations: splitData.allocations, + totalAllocation: splitData.totalAllocation, + distributionIncentive: splitData.distributionIncentive, + } + return { + to: splitContractAddress, + data: encodeFunctionData({ + abi: SplitAbi, + functionName: "distribute", + args: [tuple, tokenAddress, distributorAddress], + }), + value: 0n, + } +} /** * Hook to distribute rewards from Split contract diff --git a/staking-dashboard/src/hooks/splits/useWithdrawRewards.ts b/staking-dashboard/src/hooks/splits/useWithdrawRewards.ts index 46615ec0c..2d43c70ce 100644 --- a/staking-dashboard/src/hooks/splits/useWithdrawRewards.ts +++ b/staking-dashboard/src/hooks/splits/useWithdrawRewards.ts @@ -1,6 +1,26 @@ import { useWriteContract, useWaitForTransactionReceipt } from "@/hooks/useWagmiStrategy" -import { type Address } from "viem" +import { encodeFunctionData, type Address } from "viem" import { SplitsWarehouseAbi } from "@/contracts/abis/SplitsWarehouse" +import type { RawTransaction } from "@/contexts/TransactionCartContextType" + +/** + * Build a `SplitsWarehouse.withdraw(user, token)` raw transaction. + */ +export function buildWithdrawRewardsTx( + warehouseAddress: Address, + userAddress: Address, + tokenAddress: Address, +): RawTransaction { + return { + to: warehouseAddress, + data: encodeFunctionData({ + abi: SplitsWarehouseAbi, + functionName: "withdraw", + args: [userAddress, tokenAddress], + }), + value: 0n, + } +} /** * Hook to withdraw rewards from SplitsWarehouse after distribute() has been called diff --git a/staking-dashboard/src/hooks/staker/types.ts b/staking-dashboard/src/hooks/staker/types.ts index 0552f7247..e127d8252 100644 --- a/staking-dashboard/src/hooks/staker/types.ts +++ b/staking-dashboard/src/hooks/staker/types.ts @@ -4,6 +4,14 @@ export interface StakeWithProviderReward { totalRewards: bigint userRewards: bigint takeRate: number + /** Per-rollup `getSequencerRewards(splitContract)` breakdown — one row per + * rollup version the Registry has indexed. Drives the per-rollup claim + * fan-out at execution time. */ + rollupRewardsByRollup: Array<{ + rollupAddress: `0x${string}` + rollupVersion: string + rewards: bigint + }> } export interface G1Point { diff --git a/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts b/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts index 7a225bde8..4affb4923 100644 --- a/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts +++ b/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts @@ -1,8 +1,10 @@ +import { useMemo } from 'react' import { useReadContracts } from 'wagmi' +import type { Address } from 'viem' import { ERC20Abi } from '@/contracts/abis/ERC20' import { calculateTotalUserShareFromSplitRewards } from '@/utils/rewardCalculations' import { useStakingAssetTokenDetails } from '@/hooks/stakingRegistry' -import { contracts } from '@/contracts' +import { contracts, getRollupVersions, type RollupVersion } from '@/contracts' import type { Delegation } from '@/hooks/atp' import type { StakeWithProviderReward } from './types' @@ -12,12 +14,15 @@ interface MultipleStakeWithProviderRewardsParams { } /** - * Hook to calculate rewards for multiple delegations (stakeWithProvider method) + * Hook to calculate rewards for multiple delegations (stakeWithProvider method). * - * Reward Calculation Logic: - * 1. Get rollup rewards: rollup.getSequencerRewards(splitContract) - * 2. Get split contract balance: stakingToken.balanceOf(splitContract) - * 3. Calculate user's share from both sources using take rate + * Fans `getSequencerRewards(splitContract)` out across every rollup version the + * Registry has indexed so balances on old rollups still show up in totals and + * drive the per-rollup claim fan-out. Also queries each split contract's ERC20 + * balance for the post-claim distribute calc. + * + * Layout of the multicall, per delegation (stride = rollups.length + 1): + * [getSequencerRewards@r1, getSequencerRewards@r2, ..., balanceOf(split)] */ export const useMultipleStakeWithProviderRewards = ({ delegations, @@ -25,27 +30,42 @@ export const useMultipleStakeWithProviderRewards = ({ }: MultipleStakeWithProviderRewardsParams) => { const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() - // Build contracts array for both rollup rewards and split balance queries + // Rollups enumerated oldest first. Raw version ids are uint256s; we replace + // them with 1-based ordinals ("v1", "v2", …) for display. + const rollups = useMemo>(() => { + const versions = getRollupVersions() + if (versions.length > 0) { + return versions.map((v: RollupVersion, i) => ({ + address: v.address, + version: String(i + 1), + })) + } + return [{ address: contracts.rollup.address, version: '?' }] + }, []) + + // Multicall size grows as `delegations.length * (rollups.length + 1)`. With + // the current mainnet shape (2 rollups, single-digit delegations per user) + // this is tiny. If the Registry adds many more rollups, or a user holds + // dozens of delegations, consider chunking or adding an `enabled` gate per + // delegation so we don't refetch the entire matrix on every action. + const callsPerDelegation = rollups.length + 1 const rewardContracts = tokenAddress && delegations.length > 0 - ? delegations.flatMap(delegation => [ - // Query rollup rewards - { - address: contracts.rollup.address, + ? delegations.flatMap((delegation) => [ + ...rollups.map((r) => ({ + address: r.address, abi: contracts.rollup.abi, functionName: 'getSequencerRewards', - args: [delegation.splitContract as `0x${string}`], - }, - // Query split contract balance + args: [delegation.splitContract as Address], + })), { - address: tokenAddress as `0x${string}`, + address: tokenAddress as Address, abi: ERC20Abi, functionName: 'balanceOf', - args: [delegation.splitContract as `0x${string}`], + args: [delegation.splitContract as Address], }, ]) : [] - // Get rewards from both rollup and split contracts const { data: rewardData, isLoading, error, refetch } = useReadContracts({ contracts: rewardContracts, query: { @@ -53,17 +73,26 @@ export const useMultipleStakeWithProviderRewards = ({ }, }) - // Calculate user rewards for each delegation - const delegationRewards: StakeWithProviderReward[] = delegations.map((delegation, index) => { - const rollupRewards = (rewardData?.[index * 2]?.result as bigint) || 0n - const splitBalance = (rewardData?.[index * 2 + 1]?.result as bigint) || 0n + const delegationRewards: StakeWithProviderReward[] = delegations.map((delegation, dIdx) => { + const baseIndex = dIdx * callsPerDelegation + const rollupRewardsByRollup = rollups.map((r, rIdx) => { + const result = rewardData?.[baseIndex + rIdx] + const rewards = (result?.result as bigint | undefined) ?? 0n + return { + rollupAddress: r.address, + rollupVersion: r.version, + rewards, + } + }) + const rollupRewardsTotal = rollupRewardsByRollup.reduce((sum, r) => sum + r.rewards, 0n) + const splitBalance = (rewardData?.[baseIndex + rollups.length]?.result as bigint | undefined) ?? 0n - const totalRewards = rollupRewards + splitBalance + const totalRewards = rollupRewardsTotal + splitBalance const userRewards = calculateTotalUserShareFromSplitRewards( - rollupRewards, + rollupRewardsTotal, splitBalance, - 0n, // warehouse balance (omitted for this flow) - delegation.providerTakeRate + 0n, // warehouse balance — omitted; surfaced separately via useWarehouseBalance + delegation.providerTakeRate, ) return { @@ -71,12 +100,15 @@ export const useMultipleStakeWithProviderRewards = ({ splitContract: delegation.splitContract, totalRewards, userRewards, - takeRate: delegation.providerTakeRate + takeRate: delegation.providerTakeRate, + rollupRewardsByRollup, } }) - // Calculate total user rewards across all delegations - const totalUserRewards = delegationRewards.reduce((sum, delegation) => sum + delegation.userRewards, 0n) + const totalUserRewards = delegationRewards.reduce( + (sum, delegation) => sum + delegation.userRewards, + 0n, + ) return { delegationRewards, @@ -84,6 +116,6 @@ export const useMultipleStakeWithProviderRewards = ({ isLoading, error, isSuccess: !!rewardData, - refetch + refetch, } } diff --git a/staking-dashboard/src/hooks/stakingRegistry/useProviderRegisteredEvents.ts b/staking-dashboard/src/hooks/stakingRegistry/useProviderRegisteredEvents.ts index e782a471f..92a89e340 100644 --- a/staking-dashboard/src/hooks/stakingRegistry/useProviderRegisteredEvents.ts +++ b/staking-dashboard/src/hooks/stakingRegistry/useProviderRegisteredEvents.ts @@ -30,7 +30,7 @@ export function useProviderRegisteredEvents() { try { const blockNumber = await client.getBlockNumber(); - const CHUNK_SIZE = 50000n; + const CHUNK_SIZE = 9_000n; const MAX_BLOCKS_BACK = 200000n; const startBlock = blockNumber; const endBlock = diff --git a/staking-dashboard/src/utils/claimCart.ts b/staking-dashboard/src/utils/claimCart.ts new file mode 100644 index 000000000..784ac8964 --- /dev/null +++ b/staking-dashboard/src/utils/claimCart.ts @@ -0,0 +1,220 @@ +import { encodeFunctionData, type Address } from "viem" +import { + ClaimStepType, + type ClaimMetadata, + type RawTransaction, + type TransactionDependency, +} from "@/contexts/TransactionCartContextType" +import type { CartTransaction } from "@/contexts/TransactionCartContext" +import { buildDistributeRewardsTx } from "@/hooks/splits/useDistributeRewards" +import { buildWithdrawRewardsTx } from "@/hooks/splits/useWithdrawRewards" +import { contracts } from "@/contracts" +import { formatTokenAmountFull } from "./atpFormatters" + +/** + * Build a `claimSequencerRewards(coinbase)` raw transaction. Used for both + * direct coinbase claims and split-contract claims (pass the split contract + * as `coinbaseAddress`). Kept here next to the other claim-cart builders so + * the whole "cart entry construction" lives in one file. + */ +export function buildClaimSequencerRewardsTx( + coinbaseAddress: Address, + rollupAddress: Address, +): RawTransaction { + return { + to: rollupAddress, + data: encodeFunctionData({ + abi: contracts.rollup.abi, + functionName: "claimSequencerRewards", + args: [coinbaseAddress], + }), + value: 0n, + } +} + +/** + * The shape `addTransaction()` expects for a claim entry. Used by each of the + * three claim entry points so they produce identical cart entries given the + * same inputs. + */ +export type ClaimCartEntry = Omit, "id"> + +export interface DelegationClaimInputs { + splitContract: Address + providerTakeRate: number + providerRewardsRecipient: Address + /** Display name used in the cart entry's description. */ + providerLabel: string + /** Per-rollup unclaimed `getSequencerRewards(splitContract)` balances. */ + rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> + /** The connected wallet receiving the user's share. */ + beneficiary: Address + /** Reward token (fee asset). */ + tokenAddress: Address + decimals: number + symbol: string +} + +export interface DelegationClaimResult { + entries: ClaimCartEntry[] + /** `stepGroupIdentifier` of the delegation's distribute step. Pass to + * `buildWarehouseWithdrawEntry` so the warehouse withdraw can depend on + * this delegation's distribute. Null when nothing was produced. */ + distributeGroup: string | null +} + +/** + * One delegation's claim leg: one `claimSequencerRewards` entry per rollup + * with a non-zero balance, then distribute. Withdraw is intentionally NOT + * included — the caller adds a single warehouse withdraw at the end of the + * batch via `buildWarehouseWithdrawEntry`, since the warehouse is per-(user, + * token) and one withdraw drains everything. + */ +export function buildDelegationClaimEntries(inputs: DelegationClaimInputs): DelegationClaimResult { + const { + splitContract, + providerTakeRate, + providerRewardsRecipient, + providerLabel, + rollupRewardsByRollup, + beneficiary, + tokenAddress, + decimals, + symbol, + } = inputs + + const claimables = rollupRewardsByRollup.filter((r) => r.rewards > 0n) + if (claimables.length === 0) { + return { entries: [], distributeGroup: null } + } + + const stepGroup = `delegation:${splitContract.toLowerCase()}` + const entries: ClaimCartEntry[] = [] + + // One claim per rollup with balance. No distinction between canonical and + // non-canonical — they're all `claimSequencerRewards()` calls that move + // tokens from a rollup into the split contract. + // Per-claim stepGroupIdentifier is suffixed with the rollup address so each + // (delegation × rollup) claim is uniquely addressable. distribute below then + // declares a dependency on every single one. Without uniqueness, the cart's + // dependency resolver (`Array.find`) would only see the first claim and let + // the user move distribute past the rest — stranding tokens on the + // un-claimed rollups. + // + // The address is the unconditionally-unique identity. `rollupVersion` is + // display-only — callers normalise missing versions to placeholders like + // "?", which would collide here if we used it as the discriminator. + const claimGroupFor = (r: { rollupAddress: Address }) => + `${stepGroup}:${r.rollupAddress.toLowerCase()}` + for (const r of claimables) { + const metadata: ClaimMetadata = { + stepType: ClaimStepType.SplitClaim, + stepGroupIdentifier: claimGroupFor(r), + splitContract, + rollupAddress: r.rollupAddress, + rollupVersion: r.rollupVersion, + amount: r.rewards, + } + entries.push({ + type: "claim", + label: `Claim — Rollup v${r.rollupVersion}`, + description: `${formatTokenAmountFull(r.rewards, decimals, symbol)} for ${providerLabel}`, + transaction: buildClaimSequencerRewardsTx(splitContract, r.rollupAddress), + metadata, + }) + } + + // Distribute splits the split contract's balance between provider and user. + const totalAllocation = 10000n + const providerAllocation = BigInt(providerTakeRate) + const userAllocation = totalAllocation - providerAllocation + const splitData = { + recipients: [providerRewardsRecipient, beneficiary], + allocations: [providerAllocation, userAllocation], + totalAllocation, + distributionIncentive: 0, + } + const distributeDependsOn: TransactionDependency[] = claimables.map((r) => ({ + stepType: ClaimStepType.SplitClaim, + stepGroupIdentifier: claimGroupFor(r), + })) + entries.push({ + type: "claim", + label: `Distribute — ${providerLabel}`, + description: `Split between you and the provider`, + transaction: buildDistributeRewardsTx(splitContract, splitData, tokenAddress, beneficiary), + metadata: { + stepType: ClaimStepType.SplitDistribute, + stepGroupIdentifier: stepGroup, + splitContract, + tokenAddress, + dependsOn: distributeDependsOn, + }, + }) + + return { entries, distributeGroup: stepGroup } +} + +export interface CoinbaseClaimInputs { + coinbase: Address + rollupAddress: Address + rollupVersion?: string + rewards: bigint + decimals: number + symbol: string +} + +/** A single coinbase reward claim. No dependencies; each is independent. */ +export function buildCoinbaseClaimEntry(inputs: CoinbaseClaimInputs): ClaimCartEntry { + const { coinbase, rollupAddress, rollupVersion, rewards, decimals, symbol } = inputs + return { + type: "claim", + label: `Claim — Rollup v${rollupVersion ?? "?"}`, + description: `${formatTokenAmountFull(rewards, decimals, symbol)} for ${coinbase.slice(0, 10)}…${coinbase.slice(-8)}`, + transaction: buildClaimSequencerRewardsTx(coinbase, rollupAddress), + metadata: { + stepType: ClaimStepType.CoinbaseClaim, + stepGroupIdentifier: `coinbase:${coinbase.toLowerCase()}:${rollupAddress.toLowerCase()}`, + coinbase, + rollupAddress, + rollupVersion, + amount: rewards, + }, + } +} + +export interface WarehouseWithdrawInputs { + warehouseAddress: Address + beneficiary: Address + tokenAddress: Address + /** `stepGroupIdentifier` of the latest delegation's distribute step (from + * `DelegationClaimResult.distributeGroup`). Null if no distributes are + * upstream — the withdraw becomes standalone (e.g., for a pre-distributed + * pending warehouse balance). */ + dependsOnDistributeGroup: string | null +} + +/** + * A single warehouse withdraw that drains the user's accumulated balance for + * the given token. One per batch (warehouse is per-(user, token), and the + * cart deduplicates by tx signature anyway). + */ +export function buildWarehouseWithdrawEntry(inputs: WarehouseWithdrawInputs): ClaimCartEntry { + const { warehouseAddress, beneficiary, tokenAddress, dependsOnDistributeGroup } = inputs + const dependsOn: TransactionDependency[] = dependsOnDistributeGroup + ? [{ stepType: ClaimStepType.SplitDistribute, stepGroupIdentifier: dependsOnDistributeGroup }] + : [] + return { + type: "claim", + label: "Withdraw rewards from warehouse", + description: "Transfer your accumulated balance to your wallet", + transaction: buildWithdrawRewardsTx(warehouseAddress, beneficiary, tokenAddress), + metadata: { + stepType: ClaimStepType.SplitWithdraw, + stepGroupIdentifier: `warehouse:${warehouseAddress.toLowerCase()}`, + warehouseAddress, + tokenAddress, + dependsOn: dependsOn.length ? dependsOn : undefined, + }, + } +} From 4b45b8fc10e081fab8ced5bc00290125fdfb2533 Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 12 May 2026 10:59:28 +0400 Subject: [PATCH 2/5] fix: build error --- .../ATPDetailsDirectStakeItem.tsx | 3 ++- .../Stake/StakeFlowAtpSelection.tsx | 22 +++++++++++++------ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx index 3e22f80ea..7e2f72dba 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsDirectStakeItem.tsx @@ -346,7 +346,8 @@ export const ATPDetailsDirectStakeItem = ({ stake, stakerAddress, rollupVersion, activationThreshold={activationThreshold} ejectionThreshold={ejectionThreshold} healthPercentage={healthPercentage} - slashCount={slashCount} + lossAmount={lossAmount} + lossPercentage={lossPercentage} isAtRisk={isAtRisk} isCritical={isCritical} isLoading={isLoadingHealth} diff --git a/staking-dashboard/src/components/Stake/StakeFlowAtpSelection.tsx b/staking-dashboard/src/components/Stake/StakeFlowAtpSelection.tsx index e2265e077..e67fe447f 100644 --- a/staking-dashboard/src/components/Stake/StakeFlowAtpSelection.tsx +++ b/staking-dashboard/src/components/Stake/StakeFlowAtpSelection.tsx @@ -83,15 +83,22 @@ export const StakeFlowAtpSelection = ({ columns = 3, itemsPerPage: customItemsPe return } + // The `in` checks narrow `tx.metadata` to the staking-metadata variants + // (ClaimMetadata doesn't have `atpAddress`/`stakeCount`); without them the + // union widens and the field accesses fail to typecheck. const stakeCountFromTokenApprovalTx = transactions.filter(tx => tx.type === transactionType && - tx.metadata?.stepType === ATPStakingStepsWithTransaction.TokenApproval && - tx.metadata?.atpAddress === selectedAtp.atpAddress && - tx.metadata?.stakeCount + tx.metadata && + "atpAddress" in tx.metadata && + "stakeCount" in tx.metadata && + tx.metadata.stepType === ATPStakingStepsWithTransaction.TokenApproval && + tx.metadata.atpAddress === selectedAtp.atpAddress && + tx.metadata.stakeCount ) - if (stakeCountFromTokenApprovalTx.length && stakeCountFromTokenApprovalTx[0].metadata?.stakeCount) { - updateFormData({ stakeCount: stakeCountFromTokenApprovalTx[0].metadata?.stakeCount }) + const firstMatch = stakeCountFromTokenApprovalTx[0] + if (firstMatch && firstMatch.metadata && "stakeCount" in firstMatch.metadata && firstMatch.metadata.stakeCount) { + updateFormData({ stakeCount: firstMatch.metadata.stakeCount }) setIsCountModalOpen(false) } }, [transactions, selectedAtp, updateFormData]) @@ -113,8 +120,9 @@ export const StakeFlowAtpSelection = ({ columns = 3, itemsPerPage: customItemsPe const hasPendingTransactionsForAtp = useCallback((atp: ATPData) => { return transactions.some(tx => tx.status === 'pending' && - tx.metadata?.atpAddress === atp.atpAddress && - tx.type === transactionType + tx.type === transactionType && + tx.metadata && "atpAddress" in tx.metadata && + tx.metadata.atpAddress === atp.atpAddress ) }, [transactions]) From 974809c7f76fa86d495712c0dea5901672f795cb Mon Sep 17 00:00:00 2001 From: Robert Brada <44506010+robertbrada@users.noreply.github.com> Date: Tue, 12 May 2026 15:00:58 +0200 Subject: [PATCH 3/5] fix: restore partial-claim recovery for stranded split balance (#68) When a claim transaction succeeded but distribute never ran, tokens were left stuck on the split contract with no UI path to recover them. The button appeared enabled (because hasRewards was true via the split balance) but clicking it produced no transaction. buildDelegationClaimEntries now accepts the current split contract balance and emits a distribute-only entry when no per-rollup balance needs claiming but tokens are sitting on the split. The withdraw step then chains onto that distribute as usual. --- .../components/ATPDetailsModal/ATPDetailsModal.tsx | 1 + .../ATPStakingOverviewBreakdownSection.tsx | 2 ++ .../ClaimAllDelegationRewardsButton.tsx | 10 +++++++++- .../ClaimAllRewardsModal/ClaimAllRewardsModal.tsx | 1 + .../ClaimDelegationRewardsButton.tsx | 1 + .../WalletStakesDetailsModal.tsx | 1 + .../src/hooks/atp/useAggregatedStakingData.ts | 8 ++++++++ staking-dashboard/src/hooks/staker/types.ts | 5 +++++ .../staker/useMultipleStakeWithProviderRewards.ts | 1 + staking-dashboard/src/utils/claimCart.ts | 12 +++++++++++- 10 files changed, 40 insertions(+), 2 deletions(-) diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx index cd5239336..00b9ed2c9 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx @@ -435,6 +435,7 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef providerRewardsRecipient: d.providerRewardsRecipient as Address, rewards: rewards?.userRewards ?? 0n, rollupRewardsByRollup: rewards?.rollupRewardsByRollup, + splitContractBalance: rewards?.splitContractBalance, providerName: d.providerName, providerId: d.providerId, } diff --git a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx index 3d3d17007..1573e3615 100644 --- a/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx +++ b/staking-dashboard/src/components/ATPStakingOverview/ATPStakingOverviewBreakdownSection.tsx @@ -230,6 +230,7 @@ export const ATPStakingOverviewBreakdownSection = ({ providerRewardsRecipient: d.providerRewardsRecipient as `0x${string}`, rewards: d.rewards, rollupRewardsByRollup: d.rollupRewardsByRollup, + splitContractBalance: d.splitContractBalance, providerName: d.providerName, providerId: d.providerId, })), @@ -239,6 +240,7 @@ export const ATPStakingOverviewBreakdownSection = ({ providerRewardsRecipient: d.providerRewardsRecipient as `0x${string}`, rewards: d.rewards, rollupRewardsByRollup: d.rollupRewardsByRollup, + splitContractBalance: d.splitContractBalance, providerName: d.providerName, providerId: d.providerId, })) diff --git a/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx b/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx index f532eccff..f17dc531c 100644 --- a/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx +++ b/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx @@ -23,6 +23,10 @@ interface DelegationClaim { rewards: bigint /** Required for the helper to fan out per-rollup claims. */ rollupRewardsByRollup?: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> + /** Tokens already on the split contract awaiting distribute. Needed so a + * partially-executed claim (claim ran, distribute didn't) can still be + * recovered as a distribute-only entry. */ + splitContractBalance?: bigint providerName?: string | null providerId?: number } @@ -52,12 +56,15 @@ export const ClaimAllDelegationRewardsButton = ({ const firstSplit = delegations[0]?.splitContract const { warehouseAddress } = useSplitsWarehouse(firstSplit) - // Filter to delegations that have *anything* claimable — canonical or stranded. + // Filter to delegations that have *anything* claimable — canonical, stranded + // on a non-canonical rollup, or already swept into the split contract but + // awaiting distribute. const claimableDelegations = useMemo(() => { const canonicalRollup = contracts.rollup.address.toLowerCase() return delegations.filter((d) => { const perRollup = d.rollupRewardsByRollup ?? [] return perRollup.some((r) => r.rewards > 0n) + || (d.splitContractBalance ?? 0n) > 0n || (d.rewards > 0n && !perRollup.some((r) => r.rollupAddress.toLowerCase() === canonicalRollup)) }) }, [delegations]) @@ -93,6 +100,7 @@ export const ClaimAllDelegationRewardsButton = ({ tokenAddress, decimals: decimals ?? 18, symbol: symbol ?? "", + splitContractBalance: d.splitContractBalance, }) entries.push(...delegationEntries) if (distributeGroup) lastDistributeGroup = distributeGroup diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index 5601bf7a5..032371ad2 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -73,6 +73,7 @@ export const ClaimAllRewardsModal = ({ tokenAddress, decimals: decimals ?? 18, symbol: symbol ?? "", + splitContractBalance: d.splitContractBalance, }) entriesToAdd.push(...entries) if (distributeGroup) lastDistributeGroup = distributeGroup diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx index 105f263be..cb8038049 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx @@ -89,6 +89,7 @@ export const ClaimDelegationRewardsButton = ({ tokenAddress, decimals: decimals ?? 18, symbol: symbol ?? "", + splitContractBalance: currentSplitBalance, }) const withdraw = entries.length > 0 || currentWarehouseBalance > 0n ? buildWarehouseWithdrawEntry({ diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx index dd9598184..0397b5123 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletStakesDetailsModal.tsx @@ -170,6 +170,7 @@ export const WalletStakesDetailsModal = ({ providerRewardsRecipient: d.providerRewardsRecipient, rewards: d.rewards, rollupRewardsByRollup: d.rollupRewardsByRollup, + splitContractBalance: d.splitContractBalance, providerName: d.providerName, providerId: d.providerId, }))} diff --git a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts index a29015efd..e3fcb67a8 100644 --- a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts +++ b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts @@ -58,6 +58,10 @@ export interface DelegationBreakdown { /** Per-rollup unclaimed `getSequencerRewards(splitContract)` balances. Used by the * claim engine to pre-sweep stranded balances from non-canonical rollups. */ rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> + /** ERC20 balance currently sitting on the split contract. Non-zero means a + * previous claim landed tokens here but distribute hasn't run yet — the + * claim engine needs this to surface a distribute-only recovery flow. */ + splitContractBalance: bigint } export interface Erc20DelegationBreakdown { @@ -81,6 +85,8 @@ export interface Erc20DelegationBreakdown { /** Per-rollup unclaimed `getSequencerRewards(splitContract)` balances. Used by the * claim engine to pre-sweep stranded balances from non-canonical rollups. */ rollupRewardsByRollup: Array<{ rollupAddress: Address; rollupVersion: string; rewards: bigint }> + /** See {@link DelegationBreakdown.splitContractBalance}. */ + splitContractBalance: bigint } export interface Erc20DirectStakeBreakdown { @@ -321,6 +327,7 @@ function parseDelegation( timestamp: delegation.timestamp, blockNumber: delegation.blockNumber, rollupRewardsByRollup: rollupBalancesByRollup, + splitContractBalance, } } @@ -390,6 +397,7 @@ function parseErc20Delegation( timestamp: delegation.timestamp, blockNumber: delegation.blockNumber, rollupRewardsByRollup: rollupBalancesByRollup, + splitContractBalance, } } diff --git a/staking-dashboard/src/hooks/staker/types.ts b/staking-dashboard/src/hooks/staker/types.ts index e127d8252..76496f96a 100644 --- a/staking-dashboard/src/hooks/staker/types.ts +++ b/staking-dashboard/src/hooks/staker/types.ts @@ -12,6 +12,11 @@ export interface StakeWithProviderReward { rollupVersion: string rewards: bigint }> + /** ERC20 balance currently sitting on the split contract. Non-zero with no + * per-rollup balance means a prior claim landed tokens here but distribute + * hasn't run yet — the claim engine needs this to surface a distribute-only + * recovery flow. */ + splitContractBalance: bigint } export interface G1Point { diff --git a/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts b/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts index 4affb4923..37f3745ea 100644 --- a/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts +++ b/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts @@ -102,6 +102,7 @@ export const useMultipleStakeWithProviderRewards = ({ userRewards, takeRate: delegation.providerTakeRate, rollupRewardsByRollup, + splitContractBalance: splitBalance, } }) diff --git a/staking-dashboard/src/utils/claimCart.ts b/staking-dashboard/src/utils/claimCart.ts index 784ac8964..ce6118b39 100644 --- a/staking-dashboard/src/utils/claimCart.ts +++ b/staking-dashboard/src/utils/claimCart.ts @@ -53,6 +53,11 @@ export interface DelegationClaimInputs { tokenAddress: Address decimals: number symbol: string + /** Current ERC20 balance sitting on the split contract — i.e. tokens already + * claimed from a rollup but not yet distributed. When this is non-zero and + * no per-rollup balances need claiming, the helper emits a distribute-only + * plan so a previously stranded balance can still be swept to the user. */ + splitContractBalance?: bigint } export interface DelegationClaimResult { @@ -81,10 +86,15 @@ export function buildDelegationClaimEntries(inputs: DelegationClaimInputs): Dele tokenAddress, decimals, symbol, + splitContractBalance = 0n, } = inputs const claimables = rollupRewardsByRollup.filter((r) => r.rewards > 0n) - if (claimables.length === 0) { + // Distribute-only recovery: no rollup balance to claim, but the split + // contract still holds tokens from a prior partially-executed claim. Emit + // just the distribute (no claim deps) so the user can sweep the stranded + // balance. Without this branch the button/modal becomes a silent no-op. + if (claimables.length === 0 && splitContractBalance === 0n) { return { entries: [], distributeGroup: null } } From 4f8a65c763913f09102abfb8484ea94cc8c08882 Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 12 May 2026 17:23:46 +0400 Subject: [PATCH 4/5] fix: build error --- .../src/components/ATPDetailsModal/ATPDetailsModal.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx index 00b9ed2c9..7a7946f25 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsModal.tsx @@ -468,6 +468,7 @@ export const ATPDetailsModal = ({ atp, isOpen, onClose, onWithdrawSuccess, onRef userRewards: 0n, takeRate: delegation.providerTakeRate, rollupRewardsByRollup: [], + splitContractBalance: 0n, }} isLoadingDelegationRewards={isLoadingDelegationRewards && !delegation.hasFailedDeposit} stakerAddress={stakerAddress} From cb6059ac6f9b43e98832f56bea692089addcee53 Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 12 May 2026 17:38:16 +0400 Subject: [PATCH 5/5] fix: only recover if more than 0.5 $AZTEC --- staking-dashboard/src/utils/claimCart.ts | 31 +++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/staking-dashboard/src/utils/claimCart.ts b/staking-dashboard/src/utils/claimCart.ts index ce6118b39..2f27849cc 100644 --- a/staking-dashboard/src/utils/claimCart.ts +++ b/staking-dashboard/src/utils/claimCart.ts @@ -53,13 +53,22 @@ export interface DelegationClaimInputs { tokenAddress: Address decimals: number symbol: string - /** Current ERC20 balance sitting on the split contract — i.e. tokens already - * claimed from a rollup but not yet distributed. When this is non-zero and - * no per-rollup balances need claiming, the helper emits a distribute-only - * plan so a previously stranded balance can still be swept to the user. */ + /** Current ERC20 balance sitting on the split contract. When non-zero with + * no per-rollup balance to claim, the helper emits a distribute-only entry + * to sweep stranded tokens — but only if the balance is above + * `RECOVERY_DUST_THRESHOLD_NUMERATOR / 10` of one whole token, to avoid + * queuing a useless distribute for the rounding-dust most splits carry + * after any partial distribute. */ splitContractBalance?: bigint } +/** + * Dust threshold (numerator / 10) used to gate the distribute-only recovery + * flow. `5` here means "0.5 of a whole token". Below this we assume the + * leftover is rounding-dust and not worth queuing a transaction for. + */ +const RECOVERY_DUST_THRESHOLD_NUMERATOR = 5n + export interface DelegationClaimResult { entries: ClaimCartEntry[] /** `stepGroupIdentifier` of the delegation's distribute step. Pass to @@ -90,11 +99,15 @@ export function buildDelegationClaimEntries(inputs: DelegationClaimInputs): Dele } = inputs const claimables = rollupRewardsByRollup.filter((r) => r.rewards > 0n) - // Distribute-only recovery: no rollup balance to claim, but the split - // contract still holds tokens from a prior partially-executed claim. Emit - // just the distribute (no claim deps) so the user can sweep the stranded - // balance. Without this branch the button/modal becomes a silent no-op. - if (claimables.length === 0 && splitContractBalance === 0n) { + // Dust threshold scaled by the asset's decimals (0.5 of one whole token). + // Splits almost always carry a tiny non-zero balance after a partial + // distribute, so a bare `splitContractBalance > 0n` check made the bulk + // path queue a useless distribute every click. Only treat balances above + // the threshold as a real stranded amount worth a distribute-only recovery. + const dustThreshold = decimals >= 1 + ? RECOVERY_DUST_THRESHOLD_NUMERATOR * 10n ** BigInt(decimals - 1) + : 0n + if (claimables.length === 0 && splitContractBalance < dustThreshold) { return { entries: [], distributeGroup: null } }