From ce679c71f41903bcca61453a16168abe0abfeb60 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 08:23:47 +0000 Subject: [PATCH] fix(rewards): stop the stuck "No higher tier has been confirmed" notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Explore Tiers screen showed "Savings refreshed. No higher tier has been confirmed yet…" at the top whenever a 90-second promotion window ran out without a higher tier. That window was armed by actions that cannot raise a tier any more — yield-boost claims, FUSE savings deposits, realtime balance events, cancelling or resuming a membership — and by a lock that keeps a trial tier the user already holds, including on Ultra where there is nothing higher. Every one of those ended on the notice, and it stayed until the session ended. Only a lock or an annual fee raises a tier now, so only those arm the window (`tierPurchased`). Savings refreshes still refetch the rewards payload and balances, but no longer wait for a promotion. The window now ends quietly: a purchase is confirmed on chain before it opens, so running out means the tier is slow to show, not that it failed. The notice, and the `timedOut` / `savingsConfirmed` state behind it, are removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CcwfLYRST5mRidUcxWBQjX --- .../NewRewards/RewardsBenefitsScreenNew.tsx | 9 +-- components/Swap/BuyFuseScreen.tsx | 2 +- .../__tests__/BuyFuseScreen.upgrade.test.tsx | 2 +- hooks/__tests__/useRewards.test.tsx | 6 +- hooks/useRewards.ts | 3 +- hooks/useTierMembership.ts | 66 ++++++++++--------- .../refreshRewardsAfterSavings.test.ts | 42 ++++++++++++ lib/realtime/realtimeClient.ts | 6 +- lib/refreshRewardsAfterSavings.ts | 12 +++- .../__tests__/useRewardsUpgradeStore.test.ts | 36 ++++++---- store/useRewardsUpgradeStore.ts | 36 +++++----- 11 files changed, 137 insertions(+), 83 deletions(-) create mode 100644 lib/__tests__/refreshRewardsAfterSavings.test.ts 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(() => {