diff --git a/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx b/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
index fbbf9e592..258dc552a 100644
--- a/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
+++ b/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
@@ -220,8 +220,7 @@ function RewardsBenefitsForAccount({ initialTier }: { initialTier: RewardsTier |
} = useRewardsUserData();
const { data: tierBenefits } = useTierBenefits();
const confirmed = useRewardsUpgradeStore(state => state.confirmed);
- const pending = useRewardsUpgradeStore(state => !!state.pendingUntil && state.savingsConfirmed);
- const timedOut = useRewardsUpgradeStore(state => state.timedOut);
+ const pending = useRewardsUpgradeStore(state => !!state.pendingUntil);
const currentTier = isError ? undefined : confirmed?.currentTier;
const upgradeTarget = (tier: RewardsTier) =>
rewardsData?.fuseSkipLine?.enabled
@@ -527,12 +526,6 @@ function RewardsBenefitsForAccount({ initialTier }: { initialTier: RewardsTier |
sidebarTopGutter={Platform.OS === 'web' ? 0 : undefined}
blurTargetRef={selectorBlurTarget}
>
- {timedOut && (
-
- Savings refreshed. No higher tier has been confirmed yet. Check your FUSE Savings
- balance and tier requirement before adding more.
-
- )}
{/* Desktop: the row is three columns wide, so clip the neighbouring tiers at
the column's edge — on mobile they simply hang off-screen. */}
diff --git a/components/Swap/BuyFuseScreen.tsx b/components/Swap/BuyFuseScreen.tsx
index 8dac3d54a..4cc56d593 100644
--- a/components/Swap/BuyFuseScreen.tsx
+++ b/components/Swap/BuyFuseScreen.tsx
@@ -68,7 +68,7 @@ function BuyFuseForAccount({ requestedTier, upgradeContext }: BuyFuseScreenProps
const insets = useSafeAreaInsets();
const { data: rewardsData, isError } = useRewardsUserData({ enabled: !upgradeContext });
const confirmed = useRewardsUpgradeStore(state => state.confirmed);
- const pending = useRewardsUpgradeStore(state => !!state.pendingUntil && state.savingsConfirmed);
+ const pending = useRewardsUpgradeStore(state => !!state.pendingUntil);
const [purchased, setPurchased] = useState(false);
const { independentField, typedValue, selectCurrency, typeInput, resetForm } = useSwapState(
diff --git a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx
index 85df5f159..e7cd02290 100644
--- a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx
+++ b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx
@@ -58,7 +58,7 @@ jest.mock('@/store/swapStore', () => ({
}));
jest.mock('@/store/useRewardsUpgradeStore', () => ({
useRewardsUpgradeStore: (selector: (state: any) => unknown) =>
- selector({ confirmed: undefined, pendingUntil: undefined, savingsConfirmed: false }),
+ selector({ confirmed: undefined, pendingUntil: undefined }),
}));
jest.mock('@/store/useSupportDrawerStore', () => ({ openSupportDrawer: jest.fn() }));
jest.mock('@/store/useUserStore', () => ({
diff --git a/hooks/__tests__/useRewards.test.tsx b/hooks/__tests__/useRewards.test.tsx
index 59309f69b..3c887233c 100644
--- a/hooks/__tests__/useRewards.test.tsx
+++ b/hooks/__tests__/useRewards.test.tsx
@@ -114,7 +114,7 @@ it('polls past 60 seconds and celebrates once after the actual API promotion', a
fetchData.mockResolvedValue(data(RewardsTier.CORE));
await mount(true);
await act(async () => {
- useRewardsUpgradeStore.getState().savingsChanged('a');
+ useRewardsUpgradeStore.getState().tierPurchased('a');
});
for (let i = 0; i < 12; i++)
await act(async () => {
@@ -138,13 +138,13 @@ it('ends reconciliation after 90 seconds without submitting anything again', asy
fetchData.mockResolvedValue(data(RewardsTier.CORE));
await mount(true);
await act(async () => {
- useRewardsUpgradeStore.getState().savingsChanged('a');
+ useRewardsUpgradeStore.getState().tierPurchased('a');
});
for (let i = 0; i < 18; i++)
await act(async () => {
await jest.advanceTimersByTimeAsync(5000);
});
- expect(useRewardsUpgradeStore.getState().timedOut).toBe(true);
+ expect(useRewardsUpgradeStore.getState().pendingUntil).toBeUndefined();
expect(useRewardsUpgradeStore.getState().success).toBeUndefined();
const calls = fetchData.mock.calls.length;
await act(async () => {
diff --git a/hooks/useRewards.ts b/hooks/useRewards.ts
index 80c0e6844..0eb821703 100644
--- a/hooks/useRewards.ts
+++ b/hooks/useRewards.ts
@@ -186,8 +186,7 @@ export const useClaimYieldBoost = () => {
},
onSuccess: ({ summary, userId, safeAddress }) => {
queryClient.setQueryData([REWARDS, 'yieldBoost', userId], summary);
- // The payout lands in the soFUSE vault, which is savings — and FUSE held
- // there can move the user's tier.
+ // The payout lands in the soFUSE vault, so the savings figures move.
refreshRewardsAfterSavings(queryClient, userId, safeAddress);
},
});
diff --git a/hooks/useTierMembership.ts b/hooks/useTierMembership.ts
index e0caa4011..910f7a430 100644
--- a/hooks/useTierMembership.ts
+++ b/hooks/useTierMembership.ts
@@ -218,36 +218,40 @@ export const useTierUpgradeChainState = (contracts?: {
});
};
-/** Everything an upgrade invalidates, in one place so no path forgets one. */
+/** Everything a membership change invalidates, in one place so no path forgets one. */
const useInvalidateAfterUpgrade = () => {
const queryClient = useQueryClient();
- return useCallback(() => {
- queryClient.invalidateQueries({ queryKey: [TIER_MEMBERSHIP_QUERY_KEY] });
- queryClient.invalidateQueries({ queryKey: [TIER_UPGRADE_BALANCES_QUERY_KEY] });
- // The tier itself has moved, so anything describing it is stale — the
- // rewards screen, the benefits table, the fees the user is quoted. Same key
- // shape `refreshRewardsAfterSavings` invalidates, minus the user id, so one
- // upgrade refreshes whichever account is selected.
- queryClient.invalidateQueries({ queryKey: ['rewards', 'userData'] });
-
- // And open the reconciliation window, which is what actually gets the
- // "You're on Prime now!" card shown.
- //
- // `RewardsUpgradeFeedback` celebrates a tier it sees *rise* between two
- // reads of the rewards payload. One invalidation gives it a single read,
- // taken the instant the transaction lands — before the backend has
- // re-derived the tier from a lock it has not indexed yet, or a
- // subscription row written in the same breath. That read returns the old
- // tier, nothing appears to have risen, and the upgrade the user just paid
- // for is never acknowledged.
- //
- // Arming the window makes it poll until the new tier arrives, exactly as a
- // savings deposit does. Same mechanism, so all four routes into a tier —
- // points, savings, a lock, an annual fee — get the identical celebration.
- const userId = selectedRewardsUserId();
- if (userId) useRewardsUpgradeStore.getState().savingsChanged(userId);
- }, [queryClient]);
+ return useCallback(
+ ({ purchased }: { purchased: boolean }) => {
+ queryClient.invalidateQueries({ queryKey: [TIER_MEMBERSHIP_QUERY_KEY] });
+ queryClient.invalidateQueries({ queryKey: [TIER_UPGRADE_BALANCES_QUERY_KEY] });
+ // The tier itself has moved, so anything describing it is stale — the
+ // rewards screen, the benefits table, the fees the user is quoted. Same key
+ // shape `refreshRewardsAfterSavings` invalidates, minus the user id, so one
+ // upgrade refreshes whichever account is selected.
+ queryClient.invalidateQueries({ queryKey: ['rewards', 'userData'] });
+
+ // A purchase also opens the reconciliation window, which is what actually
+ // gets the "You're on Prime now!" card shown.
+ //
+ // `RewardsUpgradeFeedback` celebrates a tier it sees *rise* between two
+ // reads of the rewards payload. One invalidation gives it a single read,
+ // taken the instant the transaction lands — before the backend has
+ // re-derived the tier from a lock it has not indexed yet, or a
+ // subscription row written in the same breath. That read returns the old
+ // tier, nothing appears to have risen, and the upgrade the user just paid
+ // for is never acknowledged. Arming the window makes it poll until the new
+ // tier arrives.
+ //
+ // Cancelling or resuming a membership raises nothing, so it has nothing to
+ // wait for.
+ if (!purchased) return;
+ const userId = selectedRewardsUserId();
+ if (userId) useRewardsUpgradeStore.getState().tierPurchased(userId);
+ },
+ [queryClient],
+ );
};
/**
@@ -332,7 +336,7 @@ export const useLockFuseForTier = () => {
},
onSuccess: result => {
if (!result) return;
- invalidate();
+ invalidate({ purchased: true });
track(TRACKING_EVENTS.TIER_LOCK_COMPLETED, {
tier: result.tier,
fuse_amount: result.fuseAmount,
@@ -443,7 +447,7 @@ export const useSubscribeToTier = () => {
},
onSuccess: result => {
if (!result) return;
- invalidate();
+ invalidate({ purchased: true });
track(TRACKING_EVENTS.TIER_SUBSCRIBE_COMPLETED, {
tier: result.tier,
price_usd: result.priceUsd,
@@ -480,7 +484,7 @@ export const useCancelTierSubscription = () => {
return useMutation({
mutationFn: (reason?: string) => cancelTierSubscription({ reason }),
onSuccess: subscription => {
- invalidate();
+ invalidate({ purchased: false });
track(TRACKING_EVENTS.TIER_SUBSCRIPTION_CANCEL_COMPLETED, {
tier: subscription.tier,
period_end: subscription.currentPeriodEnd,
@@ -496,7 +500,7 @@ export const useResumeTierSubscription = () => {
return useMutation({
mutationFn: () => resumeTierSubscription(),
onSuccess: subscription => {
- invalidate();
+ invalidate({ purchased: false });
track(TRACKING_EVENTS.TIER_SUBSCRIPTION_RESUME_COMPLETED, { tier: subscription.tier });
},
});
diff --git a/lib/__tests__/refreshRewardsAfterSavings.test.ts b/lib/__tests__/refreshRewardsAfterSavings.test.ts
new file mode 100644
index 000000000..487c45dcd
--- /dev/null
+++ b/lib/__tests__/refreshRewardsAfterSavings.test.ts
@@ -0,0 +1,42 @@
+import { QueryClient } from '@tanstack/react-query';
+
+import { refreshRewardsAfterSavings } from '@/lib/refreshRewardsAfterSavings';
+import {
+ REWARDS_UPGRADE_CLEARED_STATE,
+ useRewardsUpgradeStore,
+} from '@/store/useRewardsUpgradeStore';
+
+jest.mock('@/store/useUserStore', () => {
+ // Jest factories resolve mocks before ES imports.
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const { create } = require('zustand');
+ return { useUserStore: create(() => ({ users: [{ userId: 'a', selected: true }] })) };
+});
+
+beforeEach(() => {
+ useRewardsUpgradeStore.setState({ userId: 'a', session: 0, ...REWARDS_UPGRADE_CLEARED_STATE });
+});
+
+/**
+ * A yield-boost claim or a savings deposit cannot raise a tier, so it must not
+ * open the window that waits for one — that window only ever ran out, and the
+ * benefits screen then said no higher tier had been confirmed.
+ */
+it('refetches the rewards payload without waiting for a promotion', () => {
+ const queryClient = new QueryClient();
+ const invalidate = jest.spyOn(queryClient, 'invalidateQueries').mockResolvedValue();
+
+ refreshRewardsAfterSavings(queryClient, 'a', '0xSafe');
+
+ expect(invalidate).toHaveBeenCalledWith({ queryKey: ['rewards', 'userData', 'a'] });
+ expect(useRewardsUpgradeStore.getState().pendingUntil).toBeUndefined();
+});
+
+it('ignores an account that is no longer selected', () => {
+ const queryClient = new QueryClient();
+ const invalidate = jest.spyOn(queryClient, 'invalidateQueries').mockResolvedValue();
+
+ refreshRewardsAfterSavings(queryClient, 'b');
+
+ expect(invalidate).not.toHaveBeenCalled();
+});
diff --git a/lib/realtime/realtimeClient.ts b/lib/realtime/realtimeClient.ts
index 37d2e9485..5540ef525 100644
--- a/lib/realtime/realtimeClient.ts
+++ b/lib/realtime/realtimeClient.ts
@@ -832,14 +832,14 @@ class RealtimeClient {
.invalidateQueries({ queryKey: cashbacksQueryKey })
.catch(() => undefined);
}
- // External deposits, withdrawals and share transfers can change tier
- // eligibility. Reconcile past the vault cache without inferring a tier.
+ // External deposits, withdrawals and share transfers move the savings
+ // the rewards screen reports, so refetch it.
if (
['deposit', 'withdrawal', 'transfer_in', 'transfer_out', 'bonus'].includes(
event.balance.changeType,
)
) {
- refreshRewardsAfterSavings(queryClient, userId, user?.safeAddress, false);
+ refreshRewardsAfterSavings(queryClient, userId, user?.safeAddress);
}
}),
delay,
diff --git a/lib/refreshRewardsAfterSavings.ts b/lib/refreshRewardsAfterSavings.ts
index 68b4ad203..fcf4aec79 100644
--- a/lib/refreshRewardsAfterSavings.ts
+++ b/lib/refreshRewardsAfterSavings.ts
@@ -1,15 +1,21 @@
import { QueryClient } from '@tanstack/react-query';
-import { selectedRewardsUserId, useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore';
+import { selectedRewardsUserId } from '@/store/useRewardsUpgradeStore';
+/**
+ * Refetch what a savings balance change makes stale: the rewards payload and
+ * the account's balances.
+ *
+ * It does not wait for a promotion. Only a lock or an annual fee raises a tier
+ * now — see `tierPurchased` — so polling after a deposit or a claim only ever
+ * ran out, and told the user no higher tier had been confirmed.
+ */
export const refreshRewardsAfterSavings = (
queryClient: QueryClient,
userId: string,
safeAddress?: string,
- confirmedSavings = true,
) => {
if (selectedRewardsUserId() !== userId) return;
- useRewardsUpgradeStore.getState().savingsChanged(userId, confirmedSavings);
void queryClient.invalidateQueries({ queryKey: ['rewards', 'userData', userId] });
if (safeAddress) {
const address = safeAddress.toLowerCase();
diff --git a/store/__tests__/useRewardsUpgradeStore.test.ts b/store/__tests__/useRewardsUpgradeStore.test.ts
index 76a5b7963..73f458b2c 100644
--- a/store/__tests__/useRewardsUpgradeStore.test.ts
+++ b/store/__tests__/useRewardsUpgradeStore.test.ts
@@ -36,11 +36,11 @@ it('initial load does not celebrate; an actual later promotion does exactly once
});
it('keeps the backend tier while waiting past its 60-second cache and resolves only on promotion', () => {
observe(RewardsTier.CORE);
- store.getState().savingsChanged('a');
+ store.getState().tierPurchased('a');
const deadline = store.getState().pendingUntil;
jest.advanceTimersByTime(65_000);
observe(RewardsTier.CORE);
- store.getState().savingsChanged('a');
+ store.getState().tierPurchased('a');
expect(store.getState().pendingUntil).toBe(deadline);
expect(store.getState().confirmed?.currentTier).toBe(RewardsTier.CORE);
expect(store.getState().success).toBeUndefined();
@@ -48,22 +48,22 @@ it('keeps the backend tier while waiting past its 60-second cache and resolves o
expect(store.getState().pendingUntil).toBeUndefined();
expect(store.getState().success?.currentTier).toBe(RewardsTier.PRIME);
});
-it('times out without inventing a tier, and a later fresh promotion may confirm it', () => {
+it('times out quietly without inventing a tier, and a later fresh promotion may confirm it', () => {
observe(RewardsTier.CORE);
- store.getState().savingsChanged('a');
+ store.getState().tierPurchased('a');
expect(store.getState().pendingUntil).toBe(Date.now() + REWARDS_RECONCILIATION_MS);
store.getState().finishWaiting();
+ expect(store.getState().pendingUntil).toBeUndefined();
expect(store.getState().success).toBeUndefined();
- expect(store.getState().timedOut).toBe(true);
observe(RewardsTier.PRIME);
- expect(store.getState().timedOut).toBe(false);
+ expect(store.getState().success?.currentTier).toBe(RewardsTier.PRIME);
});
it('ignores old account responses, including switch-away-and-back races', () => {
observe(RewardsTier.CORE);
- store.getState().savingsChanged('a');
+ store.getState().tierPurchased('a');
store.getState().selectAccount('b');
store.getState().observe('a', 0, data(RewardsTier.ULTRA));
- store.getState().savingsChanged('a');
+ store.getState().tierPurchased('a');
expect(store.getState().success).toBeUndefined();
expect(store.getState().pendingUntil).toBeUndefined();
store.getState().observe('b', 1, data(RewardsTier.ULTRA));
@@ -109,11 +109,19 @@ it('still celebrates a real upgrade taken after such a dip', () => {
expect(store.getState().success?.currentTier).toBe(RewardsTier.ULTRA);
});
-it('polls an ambiguous balance event quietly without blocking a wallet-funded upgrade', () => {
- store.setState({ savingsConfirmed: false });
- store.getState().savingsChanged('a', false);
- expect(store.getState().pendingUntil).toBeDefined();
- expect(store.getState().savingsConfirmed).toBe(false);
+/**
+ * The "Keep Ultra" lock: a trial already lends the tier, so the purchase makes
+ * it the user's own without the tier ever rising. The window runs out with
+ * nothing to celebrate, and must leave nothing behind.
+ */
+it('ends a window on a tier the user already had without leaving any state behind', () => {
+ observe(RewardsTier.ULTRA);
+ store.getState().tierPurchased('a');
+ observe(RewardsTier.ULTRA);
store.getState().finishWaiting();
- expect(store.getState().timedOut).toBe(false);
+ expect(store.getState()).toMatchObject({
+ pendingUntil: undefined,
+ success: undefined,
+ peak: RewardsTier.ULTRA,
+ });
});
diff --git a/store/useRewardsUpgradeStore.ts b/store/useRewardsUpgradeStore.ts
index 29cd1136e..fd34ad0ce 100644
--- a/store/useRewardsUpgradeStore.ts
+++ b/store/useRewardsUpgradeStore.ts
@@ -25,12 +25,20 @@ interface RewardsUpgradeState {
*/
peak?: RewardsTier;
success?: RewardsUserData;
+ /**
+ * While set, a tier purchase — a lock or an annual fee — has landed and the
+ * rewards payload is polled until the new tier shows up or this passes.
+ *
+ * Only a purchase arms it. Savings deposits, yield-boost claims and balance
+ * events used to as well, back when FUSE held in savings could move a tier;
+ * none of them can now, so the window they opened always ran out — and
+ * ended on a "no higher tier has been confirmed" notice over a screen where
+ * the user had only claimed a payout.
+ */
pendingUntil?: number;
- savingsConfirmed: boolean;
- timedOut: boolean;
selectAccount: (userId?: string) => void;
observe: (userId: string, session: number, data: RewardsUserData) => void;
- savingsChanged: (userId: string, confirmedSavings?: boolean) => void;
+ tierPurchased: (userId: string) => void;
finishWaiting: () => void;
dismiss: () => void;
}
@@ -47,8 +55,6 @@ export const REWARDS_UPGRADE_CLEARED_STATE = {
peak: undefined,
success: undefined,
pendingUntil: undefined,
- timedOut: false,
- savingsConfirmed: false,
} as const;
// Transient and global: one popup per observed promotion, even when several
@@ -56,8 +62,6 @@ export const REWARDS_UPGRADE_CLEARED_STATE = {
export const useRewardsUpgradeStore = create((set, get) => ({
userId: selectedRewardsUserId(),
session: 0,
- timedOut: false,
- savingsConfirmed: false,
selectAccount: userId => {
if (userId === get().userId) return;
set({ userId, session: get().session + 1, ...REWARDS_UPGRADE_CLEARED_STATE });
@@ -74,20 +78,18 @@ export const useRewardsUpgradeStore = create((set, get) =>
: state.success?.currentTier === data.currentTier
? state.success
: undefined,
- ...(promoted ? { pendingUntil: undefined, timedOut: false, savingsConfirmed: false } : {}),
+ ...(promoted ? { pendingUntil: undefined } : {}),
});
},
- savingsChanged: (userId, confirmedSavings = true) => {
+ tierPurchased: userId => {
if (get().userId !== userId) return;
- set({
- pendingUntil: get().pendingUntil ?? Date.now() + REWARDS_RECONCILIATION_MS,
- savingsConfirmed: get().savingsConfirmed || confirmedSavings,
- timedOut: false,
- });
+ set({ pendingUntil: get().pendingUntil ?? Date.now() + REWARDS_RECONCILIATION_MS });
},
- finishWaiting: () =>
- set({ pendingUntil: undefined, timedOut: get().savingsConfirmed, savingsConfirmed: false }),
- dismiss: () => set({ success: undefined, timedOut: false }),
+ // Ends quietly. A purchase is confirmed on chain before this opens, so a
+ // window that runs out means the tier is slow to show, not that it failed —
+ // and the tier it bought may be one the user already had (keeping a trial).
+ finishWaiting: () => set({ pendingUntil: undefined }),
+ dismiss: () => set({ success: undefined }),
}));
useUserStore.subscribe(() => {