Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 1 addition & 8 deletions components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -527,12 +526,6 @@ function RewardsBenefitsForAccount({ initialTier }: { initialTier: RewardsTier |
sidebarTopGutter={Platform.OS === 'web' ? 0 : undefined}
blurTargetRef={selectorBlurTarget}
>
{timedOut && (
<Text className="mt-28 px-5 text-center text-white/70">
Savings refreshed. No higher tier has been confirmed yet. Check your FUSE Savings
balance and tier requirement before adding more.
</Text>
)}
<GestureDetector gesture={swipeGesture} touchAction="pan-y">
{/* Desktop: the row is three columns wide, so clip the neighbouring tiers at
the column's edge — on mobile they simply hang off-screen. */}
Expand Down
2 changes: 1 addition & 1 deletion components/Swap/BuyFuseScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
6 changes: 3 additions & 3 deletions hooks/__tests__/useRewards.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
3 changes: 1 addition & 2 deletions hooks/useRewards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,8 +186,7 @@ export const useClaimYieldBoost = () => {
},
onSuccess: ({ summary, userId, safeAddress }) => {
queryClient.setQueryData<YieldBoostSummary>([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);
},
});
Expand Down
66 changes: 35 additions & 31 deletions hooks/useTierMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
);
};

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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 });
},
});
Expand Down
42 changes: 42 additions & 0 deletions lib/__tests__/refreshRewardsAfterSavings.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
6 changes: 3 additions & 3 deletions lib/realtime/realtimeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 9 additions & 3 deletions lib/refreshRewardsAfterSavings.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
36 changes: 22 additions & 14 deletions store/__tests__/useRewardsUpgradeStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,34 +36,34 @@ 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();
observe(RewardsTier.PRIME);
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));
Expand Down Expand Up @@ -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,
});
});
Loading
Loading