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) : '-'}