From ea29e19c9405074db0bbbb586de27b08db3c307f Mon Sep 17 00:00:00 2001 From: y3v63n Date: Mon, 14 Sep 2026 10:43:39 +0200 Subject: [PATCH] Fix: stop offering reward withdrawals that move no tokens Every split contract keeps 1 wei of the reward token after it distributes. A user who has already withdrawn everything therefore still reads a non-zero warehouse balance, and the dashboard treated that residue as a claimable reward: the summary showed a "ready to withdraw" row, the claim button stayed enabled, and the queued transaction spent gas to move an amount too small to see. The claim cart already has a shared dust threshold (half a token) for exactly this case, but the warehouse checks used a bare "greater than zero" test instead. Apply the same threshold to them. A distribute queued in the same batch still schedules its withdrawal, so freshly distributed rewards are unaffected. The delegation breakdown keeps showing the true on-chain balance and only drops the highlight that made the residue look claimable. Co-Authored-By: Claude Opus 5 (1M context) --- .../ClaimAllRewardsModal/ClaimAllRewardsModal.tsx | 9 ++++++++- .../ClaimAllRewardsSummary.tsx | 14 +++++++++++--- .../ClaimDelegationRewardsButton.tsx | 11 +++++++++-- .../ClaimDelegationRewardsModal.tsx | 10 +++++++++- 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx index 057414ece..ef11a8059 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsModal.tsx @@ -11,6 +11,7 @@ import { buildDelegationClaimEntries, buildCoinbaseClaimEntry, buildWarehouseWithdrawEntry, + getRecoveryDustThreshold, type ClaimCartEntry, } from "@/utils/claimCart" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" @@ -117,7 +118,13 @@ export const ClaimAllRewardsModal = ({ // 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 + // A distribute in this batch always needs a withdraw to follow it. A + // pre-existing warehouse balance only does when it is above the dust + // threshold: every PullSplit leaves 1 wei behind after a distribute, so a + // drained warehouse still reads non-zero and would queue a withdraw that + // burns gas and moves nothing. + const needsWithdraw = + lastDistributeGroup !== null || pendingWarehouseWithdrawal >= getRecoveryDustThreshold(decimals ?? 18) if (needsWithdraw && warehouseAddress) { const withdrawEntry = buildWarehouseWithdrawEntry({ warehouseAddress, diff --git a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx index deacdefd2..e91b036fb 100644 --- a/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx +++ b/staking-dashboard/src/components/ClaimAllRewardsModal/ClaimAllRewardsSummary.tsx @@ -1,5 +1,6 @@ import { Icon } from "@/components/Icon" import { formatTokenAmountFull } from "@/utils/atpFormatters" +import { getRecoveryDustThreshold } from "@/utils/claimCart" import type { DelegationBreakdown } from "@/hooks/atp/useAggregatedStakingData" import type { CoinbaseBreakdown } from "@/hooks/rewards/rewardsTypes" @@ -40,11 +41,18 @@ export const ClaimAllRewardsSummary = ({ ) const coinbasesWithRewards = coinbases.filter(c => c.rewards > 0n) + // Every PullSplit keeps 1 wei after a distribute, so a drained warehouse + // still reports a non-zero balance. Treat anything under the shared dust + // threshold as nothing to withdraw, otherwise the summary advertises a + // "ready to withdraw" row that costs gas and moves no tokens. + const warehouseWithdrawal = + pendingWarehouseWithdrawal >= getRecoveryDustThreshold(decimals) ? pendingWarehouseWithdrawal : 0n + // Calculate totals const totalDelegationRewards = delegationsWithRewards.reduce((sum, d) => sum + d.rewards, 0n) const totalCoinbaseRewards = coinbasesWithRewards.reduce((sum, c) => sum + c.rewards, 0n) // Include pending warehouse withdrawal in total - const totalRewards = totalDelegationRewards + totalCoinbaseRewards + pendingWarehouseWithdrawal + const totalRewards = totalDelegationRewards + totalCoinbaseRewards + warehouseWithdrawal const hasRewards = totalRewards > 0n @@ -242,7 +250,7 @@ export const ClaimAllRewardsSummary = ({ )} {/* Pending Warehouse Withdrawal */} - {pendingWarehouseWithdrawal > 0n && ( + {warehouseWithdrawal > 0n && (
Pending Withdrawal @@ -256,7 +264,7 @@ export const ClaimAllRewardsSummary = ({
- {formatTokenAmountFull(pendingWarehouseWithdrawal, decimals, symbol)} + {formatTokenAmountFull(warehouseWithdrawal, decimals, symbol)}
diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx index 084e30063..b425762c6 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsButton/ClaimDelegationRewardsButton.tsx @@ -8,6 +8,7 @@ import { Icon } from "@/components/Icon" import { buildDelegationClaimEntries, buildWarehouseWithdrawEntry, + getRecoveryDustThreshold, type ClaimCartEntry, } from "@/utils/claimCart" import type { Address } from "viem" @@ -64,10 +65,16 @@ export const ClaimDelegationRewardsButton = ({ const currentWarehouseBalance = warehouseBalance ?? 0n const totalRollupRewards = rollupRewardsByRollup.reduce((sum, r) => sum + r.rewards, 0n) + // A PullSplit permanently holds 1 wei once it has distributed, so a fully + // withdrawn warehouse still reports a balance. Only treat a balance above + // the shared dust threshold as something worth claiming, otherwise the + // button stays enabled forever and every click burns gas for no tokens. + const hasWarehouseBalance = currentWarehouseBalance >= getRecoveryDustThreshold(decimals ?? 18) + const hasRewards = totalRollupRewards > 0n || currentSplitBalance > 0n || - currentWarehouseBalance > 0n + hasWarehouseBalance const isReady = !!warehouseAddress && !!tokenAddress && !!beneficiary const isDisabled = !isReady || !hasRewards @@ -99,7 +106,7 @@ export const ClaimDelegationRewardsButton = ({ symbol: symbol ?? "", splitContractBalance: currentSplitBalance, }) - const withdraw = entries.length > 0 || currentWarehouseBalance > 0n + const withdraw = entries.length > 0 || hasWarehouseBalance ? buildWarehouseWithdrawEntry({ warehouseAddress, beneficiary, diff --git a/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx b/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx index b2bb9243b..12cc583df 100644 --- a/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx +++ b/staking-dashboard/src/components/ClaimDelegationRewardsModal/ClaimDelegationRewardsModal.tsx @@ -12,6 +12,7 @@ import { useCoinbaseRewardsAcrossRollups } from "@/hooks/rewards/useCoinbaseRewa import { useERC20Balance } from "@/hooks/erc20/useERC20Balance" import { useSplitsWarehouse } from "@/hooks/splits/useSplitsWarehouse" import { calculateTotalUserShareFromSplitRewards, calculateUserShareFromTakeRate } from "@/utils/rewardCalculations" +import { getRecoveryDustThreshold } from "@/utils/claimCart" import type { Address } from "viem" export interface DelegationModalData { @@ -76,6 +77,13 @@ export const ClaimDelegationRewardsModal = ({ const isLoadingBalances = isLoadingRollup || isLoadingSplitContract || isLoadingWarehouse + // A PullSplit keeps 1 wei forever once it has distributed, so a fully + // withdrawn warehouse still reports a balance. The figure below stays + // truthful, but only highlight it when there is enough to actually + // withdraw — a highlighted 1 wei reads as "claim me" and cannot be. + const hasWithdrawableWarehouseBalance = + (warehouseBalance ?? 0n) >= getRecoveryDustThreshold(decimals ?? 18) + const handleSuccess = () => { onSuccess?.() onClose() @@ -285,7 +293,7 @@ export const ClaimDelegationRewardsModal = ({ 3 Warehouse (Shared)
-
0n ? 'text-chartreuse' : 'text-parchment/40'}`}> +
{decimals && symbol ? formatTokenAmount(warehouseBalance || 0n, decimals, symbol, 2) : '-'}