From 578166c61754bce5495324ba7928df0592fdea83 Mon Sep 17 00:00:00 2001 From: Amin Sammara Date: Mon, 13 Jul 2026 11:11:36 -0400 Subject: [PATCH] fix: claim rewards pipeline must use the rollup fee asset, not the staking asset Sequencer rewards are paid in Rollup.getFeeAsset() (RewardLib.claimSequencerRewards does feeAsset.safeTransfer). On mainnet feeAsset == stakingAsset so the pipeline happened to work; on testnet they differ, so Split.distribute() was called with the staking asset, distributed a zero balance, and left the actual fee-asset rewards stranded on the split contract. Adds useFeeAssetTokenDetails (reads getFeeAsset from the canonical rollup) and swaps it into every reward claim/read path: claim buttons and modals, aggregated staking data, split reward totals, and the operator page. --- .../ClaimAllDelegationRewardsButton.tsx | 4 +- .../ClaimAllRewardsModal.tsx | 4 +- .../ClaimDelegationRewardsButton.tsx | 4 +- .../ClaimDelegationRewardsModal.tsx | 4 +- .../ClaimSelfStakeRewardsModal.tsx | 4 +- .../src/contracts/abis/Rollup.ts | 13 +++++ .../src/hooks/atp/useAggregatedStakingData.ts | 4 +- staking-dashboard/src/hooks/rollup/index.ts | 1 + .../hooks/rollup/useFeeAssetTokenDetails.ts | 56 +++++++++++++++++++ .../src/hooks/splits/useTotalSplitRewards.ts | 4 +- .../useMultipleStakeWithProviderRewards.ts | 4 +- .../src/pages/Operator/OperatorPage.tsx | 4 +- 12 files changed, 88 insertions(+), 18 deletions(-) create mode 100644 staking-dashboard/src/hooks/rollup/useFeeAssetTokenDetails.ts diff --git a/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx b/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx index 09b4168a7..9b2c9484f 100644 --- a/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx +++ b/staking-dashboard/src/components/ClaimAllDelegationRewardsButton/ClaimAllDelegationRewardsButton.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react" import { useAccount } from "wagmi" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" +import { useFeeAssetTokenDetails } from "@/hooks/rollup" import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" import { useTransactionCart } from "@/contexts/TransactionCartContext" @@ -59,7 +59,7 @@ export const ClaimAllDelegationRewardsButton = ({ onSuccess, }: ClaimAllDelegationRewardsButtonProps) => { const { address: beneficiary } = useAccount() - const { stakingAssetAddress: tokenAddress, decimals, symbol } = useStakingAssetTokenDetails() + const { feeAssetAddress: tokenAddress, decimals, symbol } = useFeeAssetTokenDetails() const { isRewardsClaimable } = useIsRewardsClaimable() const { addTransaction, checkTransactionInQueue, openCart, replaceTransactionByTx } = useTransactionCart() diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index ac95ace7d..057414ece 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -1,7 +1,7 @@ import { createPortal } from "react-dom" import { useAccount } from "wagmi" import { Icon } from "@/components/Icon" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" +import { useFeeAssetTokenDetails } from "@/hooks/rollup" import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" import { ClaimAllRewardsSummary } from "./ClaimAllRewardsSummary" @@ -41,7 +41,7 @@ export const ClaimAllRewardsModal = ({ onSuccess, }: ClaimAllRewardsModalProps) => { const { address: beneficiary } = useAccount() - const { symbol, decimals, stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { symbol, decimals, feeAssetAddress: tokenAddress } = useFeeAssetTokenDetails() const { isRewardsClaimable } = useIsRewardsClaimable() const { addTransaction, openCart, replaceTransactionByTx } = useTransactionCart() const { showAlert } = useAlert() diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx index 5a3cb63c1..084e30063 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx @@ -1,5 +1,5 @@ import { useAccount } from "wagmi" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" +import { useFeeAssetTokenDetails } from "@/hooks/rollup" import { useERC20Balance } from "@/hooks/erc20/useERC20Balance" import { useWarehouseBalance } from "@/hooks/splits/useWarehouseBalance" import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" @@ -50,7 +50,7 @@ export const ClaimDelegationRewardsButton = ({ variant = 'default' }: ClaimDelegationRewardsButtonProps) => { const { address: beneficiary } = useAccount() - const { stakingAssetAddress: tokenAddress, decimals, symbol } = useStakingAssetTokenDetails() + const { feeAssetAddress: tokenAddress, decimals, symbol } = useFeeAssetTokenDetails() const { warehouseAddress } = useSplitsWarehouse(splitContract) const { balance: splitContractBalance } = useERC20Balance(tokenAddress!, splitContract) diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx index ae8e8334c..b2bb9243b 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx @@ -5,7 +5,7 @@ import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { Tooltip } from "@/components/Tooltip" import { formatTokenAmount } from "@/utils/atpFormatters" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" +import { useFeeAssetTokenDetails } from "@/hooks/rollup" import { ClaimDelegationRewardsButton } from "@/components/ClaimDelegationRewardsButton" import { useWarehouseBalance } from "@/hooks/splits/useWarehouseBalance" import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewardsAcrossRollups" @@ -46,7 +46,7 @@ export const ClaimDelegationRewardsModal = ({ onSuccess }: ClaimDelegationRewardsModalProps) => { const { address: beneficiary } = useAccount() - const { symbol, decimals, stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { symbol, decimals, feeAssetAddress: tokenAddress } = useFeeAssetTokenDetails() // Get warehouse address from split contract const { warehouseAddress } = useSplitsWarehouse(delegation.splitContract) diff --git a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx index 2fff17ebb..2699a80e6 100644 --- a/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimSelfStakeRewardsModal/ClaimSelfStakeRewardsModal.tsx @@ -6,7 +6,7 @@ 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 { useFeeAssetTokenDetails } from "@/hooks/rollup" import { buildClaimSequencerRewardsTx } from "@/utils/claimCart" import { useIsRewardsClaimableAcrossRollups } from "@/hooks/rollup/useIsRewardsClaimableAcrossRollups" import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewardsAcrossRollups" @@ -42,7 +42,7 @@ export const ClaimSelfStakeRewardsModal = ({ atp, onSuccess, }: ClaimSelfStakeRewardsModalProps) => { - const { symbol, decimals } = useStakingAssetTokenDetails() + const { symbol, decimals } = useFeeAssetTokenDetails() const [coinbaseAddress, setCoinbaseAddress] = useState("") const [hasCheckedRewards, setHasCheckedRewards] = useState(false) const [isDebouncing, setIsDebouncing] = useState(false) diff --git a/staking-dashboard/src/contracts/abis/Rollup.ts b/staking-dashboard/src/contracts/abis/Rollup.ts index a698f7333..d1e9e3a7b 100644 --- a/staking-dashboard/src/contracts/abis/Rollup.ts +++ b/staking-dashboard/src/contracts/abis/Rollup.ts @@ -1,4 +1,17 @@ export const RollupAbi = [ + { + "type": "function", + "name": "getFeeAsset", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "address", + "internalType": "contract IERC20" + } + ], + "stateMutability": "view" + }, { "type": "function", "name": "getExitDelay", diff --git a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts index c61c075f2..9387f989c 100644 --- a/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts +++ b/staking-dashboard/src/hooks/atp/useAggregatedStakingData.ts @@ -6,7 +6,7 @@ import { ERC20Abi } from '@/contracts/abis/ERC20' import { SplitAbi } from '@/contracts/abis/Split' import { SplitWarehouseAbi } from '@/contracts/abis/SplitWarehouse' import { calculateTotalUserShareFromSplitRewards } from '@/utils/rewardCalculations' -import { useStakingAssetTokenDetails } from '@/hooks/stakingRegistry' +import { useFeeAssetTokenDetails } from '@/hooks/rollup' import { contracts, getRollupVersions, type RollupVersion } from '@/contracts' import type { Address } from 'viem' import { stringToBigInt } from '@/utils/atpFormatters' @@ -478,7 +478,7 @@ function parseErc20DirectStake(stake: ApiErc20DirectStake): Erc20DirectStakeBrea */ export const useAggregatedStakingData = (): AggregatedStakingData => { const { address } = useAccount() - const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { feeAssetAddress: tokenAddress } = useFeeAssetTokenDetails() // Fetch staking data from API const { diff --git a/staking-dashboard/src/hooks/rollup/index.ts b/staking-dashboard/src/hooks/rollup/index.ts index 0dc52180e..db32b6382 100644 --- a/staking-dashboard/src/hooks/rollup/index.ts +++ b/staking-dashboard/src/hooks/rollup/index.ts @@ -1,6 +1,7 @@ export { useRollupData } from "./useRollupData"; export { useActivationThresholdFormatted } from "./useActivationThresholdFormatted"; export { useSequencerRewards } from "./useSequencerRewards"; +export { useFeeAssetTokenDetails } from "./useFeeAssetTokenDetails"; export { useIsRewardsClaimable } from "./useIsRewardsClaimable"; export { useIsRewardsClaimableAcrossRollups } from "./useIsRewardsClaimableAcrossRollups"; export { useEjectionThreshold } from "./useEjectionThreshold"; diff --git a/staking-dashboard/src/hooks/rollup/useFeeAssetTokenDetails.ts b/staking-dashboard/src/hooks/rollup/useFeeAssetTokenDetails.ts new file mode 100644 index 000000000..89ed538ad --- /dev/null +++ b/staking-dashboard/src/hooks/rollup/useFeeAssetTokenDetails.ts @@ -0,0 +1,56 @@ +import { useReadContract } from "wagmi" +import { useERC20TokenDetails } from "../erc20/useERC20TokenDetails" +import { contracts } from "../../contracts" +import type { Address } from "viem" + +/** + * Hook to get the fee asset token details from the canonical rollup. + * + * Sequencer rewards are denominated in the rollup's FEE asset + * (`Rollup.getFeeAsset()`), NOT the staking asset. On mainnet the two are the + * same token, but on testnet they differ — using the staking asset in the + * claim pipeline made `Split.distribute()` distribute a zero balance while + * the actual fee-asset rewards sat stranded on the split contract. Any code + * that reads, distributes, or withdraws sequencer rewards must use this hook + * rather than `useStakingAssetTokenDetails`. + */ +export function useFeeAssetTokenDetails() { + const { data: feeAssetAddress, isLoading: isLoadingAddress, error: addressError } = useReadContract({ + abi: contracts.rollup.abi, + address: contracts.rollup.address, + functionName: "getFeeAsset", + query: { + staleTime: Infinity, + gcTime: Infinity, + }, + }) + + const { + tokenDetails, + isLoading: isLoadingTokenDetails, + name, + symbol, + decimals, + totalSupply + } = useERC20TokenDetails(feeAssetAddress as Address) + + return { + // Fee asset address (reward token) + feeAssetAddress: feeAssetAddress as Address | undefined, + + // Token details + tokenDetails, + name, + symbol, + decimals, + totalSupply, + + // Loading states + isLoading: isLoadingAddress || isLoadingTokenDetails, + isLoadingAddress, + isLoadingTokenDetails, + + // Error handling + error: addressError, + } +} diff --git a/staking-dashboard/src/hooks/splits/useTotalSplitRewards.ts b/staking-dashboard/src/hooks/splits/useTotalSplitRewards.ts index 6d1024c57..08e7429a5 100644 --- a/staking-dashboard/src/hooks/splits/useTotalSplitRewards.ts +++ b/staking-dashboard/src/hooks/splits/useTotalSplitRewards.ts @@ -2,7 +2,7 @@ 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 { useFeeAssetTokenDetails } from "@/hooks/rollup" import { calculateTotalUserShareFromSplitRewards, calculateUserShareFromTakeRate } from "@/utils/rewardCalculations" import type { Address } from "viem" @@ -16,7 +16,7 @@ export const useTotalSplitRewards = ( beneficiary: Address | undefined, providerTakeRate: number ) => { - const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { feeAssetAddress: tokenAddress } = useFeeAssetTokenDetails() // Get warehouse address from split contract const { warehouseAddress } = useSplitsWarehouse(splitContractAddress) diff --git a/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts b/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts index 37f3745ea..599e80d3f 100644 --- a/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts +++ b/staking-dashboard/src/hooks/staker/useMultipleStakeWithProviderRewards.ts @@ -3,7 +3,7 @@ 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 { useFeeAssetTokenDetails } from '@/hooks/rollup' import { contracts, getRollupVersions, type RollupVersion } from '@/contracts' import type { Delegation } from '@/hooks/atp' import type { StakeWithProviderReward } from './types' @@ -28,7 +28,7 @@ export const useMultipleStakeWithProviderRewards = ({ delegations, enabled = true }: MultipleStakeWithProviderRewardsParams) => { - const { stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { feeAssetAddress: tokenAddress } = useFeeAssetTokenDetails() // Rollups enumerated oldest first. Raw version ids are uint256s; we replace // them with 1-based ordinals ("v1", "v2", …) for display. diff --git a/staking-dashboard/src/pages/Operator/OperatorPage.tsx b/staking-dashboard/src/pages/Operator/OperatorPage.tsx index 14b12e095..f6bdf735f 100644 --- a/staking-dashboard/src/pages/Operator/OperatorPage.tsx +++ b/staking-dashboard/src/pages/Operator/OperatorPage.tsx @@ -5,7 +5,7 @@ import { PageHeader } from "@/components/PageHeader" import { Icon } from "@/components/Icon" import { CopyButton } from "@/components/CopyButton" import { TooltipIcon } from "@/components/Tooltip" -import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" +import { useFeeAssetTokenDetails } from "@/hooks/rollup" import { useIsRewardsClaimable } from "@/hooks/rollup/useIsRewardsClaimable" import { useConnectedOperatorIdentities, @@ -56,7 +56,7 @@ export default function OperatorPage() { hasError: identitiesError, refetch: refetchIdentities, } = useConnectedOperatorIdentities() - const { symbol, decimals, stakingAssetAddress: tokenAddress } = useStakingAssetTokenDetails() + const { symbol, decimals, feeAssetAddress: tokenAddress } = useFeeAssetTokenDetails() const { isRewardsClaimable } = useIsRewardsClaimable() // Cosmetic filter — historical delegations are kept in the underlying data // (a now-exited delegator might still have unclaimed rollup rewards on the