diff --git a/app/(protected)/(tabs)/rewards/_layout.tsx b/app/(protected)/(tabs)/rewards/_layout.tsx
index fabc7418..a1c9699f 100644
--- a/app/(protected)/(tabs)/rewards/_layout.tsx
+++ b/app/(protected)/(tabs)/rewards/_layout.tsx
@@ -33,6 +33,10 @@ export default function RewardsLayout() {
headerShown: false,
}}
/>
+ {/* Both upgrade screens draw their own header — back, title and a dismiss
+ that leaves the flow rather than stepping back through it. */}
+
+
);
}
diff --git a/app/(protected)/(tabs)/rewards/upgrade-review.tsx b/app/(protected)/(tabs)/rewards/upgrade-review.tsx
new file mode 100644
index 00000000..332b8b64
--- /dev/null
+++ b/app/(protected)/(tabs)/rewards/upgrade-review.tsx
@@ -0,0 +1,5 @@
+import UpgradeTierReviewScreen from '@/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen';
+
+export default function RewardsUpgradeReview() {
+ return ;
+}
diff --git a/app/(protected)/(tabs)/rewards/upgrade.tsx b/app/(protected)/(tabs)/rewards/upgrade.tsx
new file mode 100644
index 00000000..9ebbc971
--- /dev/null
+++ b/app/(protected)/(tabs)/rewards/upgrade.tsx
@@ -0,0 +1,5 @@
+import UpgradeTierScreen from '@/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen';
+
+export default function RewardsUpgrade() {
+ return ;
+}
diff --git a/components/Earn/EarnScreen.tsx b/components/Earn/EarnScreen.tsx
index 49ad9873..ef470003 100644
--- a/components/Earn/EarnScreen.tsx
+++ b/components/Earn/EarnScreen.tsx
@@ -3,12 +3,15 @@ import { View } from 'react-native';
import { Href, router } from 'expo-router';
import { BalanceHeadline, BalancePillRow } from '@/components/BalanceHeadline';
+import LockedFuseTile from '@/components/Earn/LockedFuseTile';
import HeaderHelpButton from '@/components/Navbar/HeaderHelpButton';
import PageLayout from '@/components/PageLayout';
import SavingsHelpModal from '@/components/Savings/NewSavings/SavingsHelpModal';
import Skeleton from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { path } from '@/constants/path';
import { useMaxAPY } from '@/hooks/useAnalytics';
+import { useTierMembership } from '@/hooks/useTierMembership';
import { useTotalSavingsUSD } from '@/hooks/useTotalSavingsUSD';
import { type AssetPath } from '@/lib/assets';
import { isDevFeatureEnabled } from '@/lib/config';
@@ -71,6 +74,10 @@ export default function EarnScreen() {
const [isHelpOpen, setIsHelpOpen] = useState(false);
useVaultDetailPrefetch();
const { data: portfolioTotal, valuesByVault, isLoading } = useTotalSavingsUSD();
+ // Deliberately outside the portfolio total: a locked position cannot be
+ // withdrawn, and adding it to a headline the withdraw flow then refuses is
+ // worse than showing it as its own line.
+ const { data: membership } = useTierMembership();
const usdcApy = useMaxAPY(VaultType.USDC);
const ethApy = useMaxAPY(VaultType.ETH);
const fuseApy = useMaxAPY(VaultType.FUSE);
@@ -157,6 +164,13 @@ export default function EarnScreen() {
{row.length === 1 && }
))}
+
+ {membership?.lock ? (
+ router.push(path.REWARDS_UPGRADE)}
+ />
+ ) : null}
{/* Tokenized assets are still an in-development feature (the Stocks
diff --git a/components/Earn/LockedFuseTile.tsx b/components/Earn/LockedFuseTile.tsx
new file mode 100644
index 00000000..f4b7f7a9
--- /dev/null
+++ b/components/Earn/LockedFuseTile.tsx
@@ -0,0 +1,64 @@
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { getTierDisplayName } from '@/lib/tierNames';
+import { formatFuse, formatMembershipDay } from '@/lib/tierUpgrade';
+import { RewardsTier, type TierLockState } from '@/lib/types';
+
+interface LockedFuseTileProps {
+ lock: TierLockState;
+ onPress: () => void;
+}
+
+/**
+ * The FUSE a user has committed to hold a tier, shown apart from their savings.
+ *
+ * Its own row rather than folded into the FUSE vault tile, because it behaves
+ * differently in the one way that matters: it cannot be withdrawn until its
+ * date. Adding it to the savings figure would make the Earn page promise a
+ * balance the withdraw flow then refuses, which is worse than a second line.
+ *
+ * It is still earning — what is locked is the vault share, not the asset — so
+ * the tile says so rather than reading as money set aside and idle.
+ */
+const LockedFuseTile = ({ lock, onPress }: LockedFuseTileProps) => {
+ if (!lock.enabled || lock.lockedFuse <= 0) return null;
+
+ const unlockLabel =
+ lock.maturedFuse > 0
+ ? 'Unlocking now'
+ : lock.nextUnlockAt
+ ? `Unlocks ${formatMembershipDay(lock.nextUnlockAt)}`
+ : null;
+
+ return (
+
+
+ Locked FUSE
+
+ {lock.unlockedTier !== RewardsTier.CORE ? (
+
+
+ {getTierDisplayName(lock.unlockedTier)}
+
+
+ ) : null}
+
+
+
+ {formatFuse(lock.lockedFuse)} FUSE
+
+
+
+ {unlockLabel ? `${unlockLabel} · still earning` : 'Still earning while locked'}
+
+
+ );
+};
+
+export default LockedFuseTile;
diff --git a/components/Rewards/NewRewards/PointsHeadline.tsx b/components/Rewards/NewRewards/PointsHeadline.tsx
index 49200c25..956a8c3a 100644
--- a/components/Rewards/NewRewards/PointsHeadline.tsx
+++ b/components/Rewards/NewRewards/PointsHeadline.tsx
@@ -1,4 +1,4 @@
-import { TextStyle, View } from 'react-native';
+import { Pressable, TextStyle, View } from 'react-native';
import { Image } from 'expo-image';
import { Text } from '@/components/ui/text';
@@ -33,17 +33,30 @@ interface PointsHeadlineProps {
* first thing to say about the tier just above it.
*/
badge?: React.ReactNode;
+ /**
+ * Opens the membership sheet. The tier name is the affordance for it
+ * everywhere it appears, so it is a press target here rather than a separate
+ * "details" control the design does not have.
+ */
+ onPressTier?: () => void;
}
/** Current-tier badge + compact points count (e.g. "Prime" / "10.5M Points"). */
-const PointsHeadline = ({ tier, points, badge }: PointsHeadlineProps) => {
+const PointsHeadline = ({ tier, points, badge, onPressTier }: PointsHeadlineProps) => {
const formattedPoints = compactNumberFormat(points ?? 0);
const numberParts = formattedPoints.match(/^([\d,]+)(\.\d+)?(.*)$/);
const fadedNumberPart = numberParts ? `${numberParts[2] ?? ''}${numberParts[3] ?? ''}` : '';
return (
-
+ {
>
{getTierDisplayName(tier)}
-
+
{badge}
{numberParts ? numberParts[1] : formattedPoints}
diff --git a/components/Rewards/NewRewards/RewardsScreenNew.tsx b/components/Rewards/NewRewards/RewardsScreenNew.tsx
index 349dbc5d..bfadf122 100644
--- a/components/Rewards/NewRewards/RewardsScreenNew.tsx
+++ b/components/Rewards/NewRewards/RewardsScreenNew.tsx
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
import { Platform, Pressable, View } from 'react-native';
-import { router, useLocalSearchParams } from 'expo-router';
+import { Href, router, useLocalSearchParams } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useQuery } from '@tanstack/react-query';
@@ -15,14 +15,13 @@ import { path } from '@/constants/path';
import { SPIN_WIN } from '@/constants/spinWinDesign';
import { cardDetailsQueryOptions } from '@/hooks/cardDetailsQueryOptions';
import { useOptInToRewards, useReferralSummary, useRewardsUserData } from '@/hooks/useRewards';
-import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow';
import { useSpinStatus } from '@/hooks/useSpinWin';
+import { useTierMembership } from '@/hooks/useTierMembership';
import { monthlyCashbackTotal } from '@/lib/cashbackProgress';
import { isDevFeatureEnabled } from '@/lib/config';
import { resolveUserCashbackRate } from '@/lib/tierCashback';
+import { nextPurchasableTier } from '@/lib/tierUpgrade';
import { RewardsTier } from '@/lib/types';
-import { useSwapState } from '@/store/swapStore';
-import { useDepositStore } from '@/store/useDepositStore';
import { useRewardsIntroStore } from '@/store/useRewardsIntroStore';
import { useRewardsWelcomePopupStore } from '@/store/useRewardsWelcomePopupStore';
import { useSpinWinModalStore } from '@/store/useSpinWinModalStore';
@@ -34,9 +33,9 @@ import RewardsSummaryCard from './RewardsSummaryCard';
import { resolveTierUpgradeCardData } from './skipTheLine';
import { resolveTierBenefitRates } from './tierBenefitCards';
import TierBenefitsGrid from './TierBenefitsGrid';
+import TierMembershipSheet from './TierMembershipSheet';
import TierTrialPill from './TierTrialPill';
import TierUpgradeCard from './TierUpgradeCard';
-import UpgradeTierSheet from './UpgradeTierSheet';
/**
* Redesigned rewards screen (Apple "glass" style), shown only on qa/preview
@@ -55,9 +54,8 @@ export default function RewardsScreenNew() {
const { data: referralSummary } = useReferralSummary();
const { data: cardDetails } = useQuery(cardDetailsQueryOptions(selectedUserId));
const { data: spinStatus } = useSpinStatus();
+ const { data: membership } = useTierMembership();
const openSpinWinModal = useSpinWinModalStore(state => state.setModal);
- const openBuyFuse = useSwapState(state => state.actions.openBuyFuse);
- const { selectToken: selectSavingsFundToken } = useSavingsFundFlow();
const { mutate: joinRewards, isPending: isJoining } = useOptInToRewards();
const hasCompletedIntro = useRewardsIntroStore(
state => !selectedUserId || Boolean(state.completedByUserId[selectedUserId]),
@@ -69,32 +67,28 @@ export default function RewardsScreenNew() {
const { referral: referralParam } = useLocalSearchParams<{ referral?: string }>();
const [isReferralModalOpen, setIsReferralModalOpen] = useState(false);
const [isHelpOpen, setIsHelpOpen] = useState(false);
- const [isUpgradeSheetOpen, setIsUpgradeSheetOpen] = useState(false);
- const [upgradeTier, setUpgradeTier] = useState(
- RewardsTier.PRIME,
- );
-
- const handleUpgradeTier = useCallback((tier: RewardsTier | null) => {
- if (tier !== RewardsTier.PRIME && tier !== RewardsTier.ULTRA) return;
-
- setUpgradeTier(tier);
- setIsUpgradeSheetOpen(true);
- }, []);
+ const [isMembershipSheetOpen, setIsMembershipSheetOpen] = useState(false);
- const handleDepositFuse = useCallback(() => {
- setIsUpgradeSheetOpen(false);
+ /**
+ * Opens the upgrade flow on a specific tier.
+ *
+ * A screen rather than the old sheet: v3 sells the tier outright, so the
+ * decision now carries a price, a term and a choice of how to pay — more than
+ * a 470px sheet can put in front of someone before they sign for it.
+ */
+ const handleUpgradeTier = useCallback(
+ (tier: RewardsTier | null) => {
+ const target =
+ tier === RewardsTier.PRIME || tier === RewardsTier.ULTRA
+ ? tier
+ : nextPurchasableTier(membership);
- const depositStore = useDepositStore.getState();
- depositStore.resetDepositFlow();
- depositStore.setSavingsFundIntent('savings');
- depositStore.setDepositFromSolid(false);
- selectSavingsFundToken('WFUSE');
- }, [selectSavingsFundToken]);
+ if (!target) return;
- const handleBuyFuse = useCallback(() => {
- setIsUpgradeSheetOpen(false);
- openBuyFuse(upgradeTier);
- }, [openBuyFuse, upgradeTier]);
+ router.push({ pathname: '/rewards/upgrade', params: { tier: target } } as Href);
+ },
+ [membership],
+ );
// The rewards program requires an explicit opt-in; `hasOptedIn` defaults to
// true when the backend doesn't send it, so we never prompt prematurely.
@@ -230,6 +224,7 @@ export default function RewardsScreenNew() {
tier={currentTier}
points={totalPoints}
badge={}
+ onPressTier={() => setIsMembershipSheetOpen(true)}
/>
setIsReferralModalOpen(false)}
/>
-
+
);
}
diff --git a/components/Rewards/NewRewards/TierBenefitsGrid.tsx b/components/Rewards/NewRewards/TierBenefitsGrid.tsx
index caa46ef9..b6b47fdf 100644
--- a/components/Rewards/NewRewards/TierBenefitsGrid.tsx
+++ b/components/Rewards/NewRewards/TierBenefitsGrid.tsx
@@ -1,6 +1,5 @@
import { type ReactElement } from 'react';
import { Platform, Pressable, View } from 'react-native';
-import Svg, { G, Path } from 'react-native-svg';
import { useIsSidebarShell } from '@/components/Navbar/Sidebar';
import { Text } from '@/components/ui/text';
@@ -10,116 +9,12 @@ import CashbackDetailsSheet from './CashbackDetailsSheet';
import { subscriptionCategoriesSentence } from './subscriptionBrands';
import SubscriptionCashbackSheet from './SubscriptionCashbackSheet';
import { chunkIntoRows, resolveTierBenefitKeys, type TierBenefitKey } from './tierBenefitCards';
+import { CashbackIcon, ReferralsIcon, SubscriptionIcon, YieldBoostIcon } from './tierBenefitIcons';
import YieldBoostSheet from './YieldBoostSheet';
import type { CashbackDetailsData } from './CashbackDetailsSheet.types';
import type { YieldBoostData } from './YieldBoostSheet.types';
-const CASHBACK_DIAMOND_PATH =
- 'M3.06451 2.81534C3.81171 1.80329 4.18531 1.29727 4.73962 1.02363C5.29394 0.750001 5.94542 0.750001 7.2484 0.750001H12.25H17.2517C18.5546 0.750001 19.2061 0.750001 19.7604 1.02363C20.3148 1.29727 20.6883 1.80329 21.4355 2.81534L22.198 3.84805C23.2487 5.27123 23.7741 5.98283 23.7492 6.78503C23.7243 7.58723 23.1556 8.26822 22.0182 9.6303L15.2824 17.6974C14.3461 18.8187 13.8779 19.3794 13.3317 19.6215C12.6462 19.9255 11.8538 19.9255 11.1683 19.6215C10.6221 19.3794 10.1539 18.8187 9.21767 17.6974L2.48175 9.6303C1.34443 8.26822 0.775785 7.58723 0.75085 6.78503C0.725927 5.98283 1.25129 5.27123 2.30204 3.84805L3.06451 2.81534Z';
-const CASHBACK_SLASH_PATH =
- 'M1.64728 0.750063L1.00889 1.65796C0.630253 2.19645 0.67028 2.90693 1.10728 3.4041L3.56244 6.19743';
-// Lightning bolt with speed lines, drawn at the badge's own 50x49 scale so it
-// lands exactly where the design places it inside the circle.
-const YIELD_BOOST_PATH =
- 'M20.4366 32.3333H13.0221M17.0664 25H11M20.4366 17.6667H13.6962M31.2214 13L22.3288 25.3133C21.9352 25.8584 21.7384 26.1308 21.7469 26.358C21.7543 26.5559 21.8503 26.7401 22.0087 26.8611C22.1907 27 22.5292 27 23.2064 27H29.8733L28.5252 37L37.4178 24.6867C37.8114 24.1416 38.0082 23.8692 37.9997 23.642C37.9923 23.4441 37.8963 23.2599 37.7379 23.1389C37.5559 23 37.2174 23 36.5402 23H29.8733L31.2214 13Z';
-
-/** The 50x49 circle every benefit icon sits in. */
-const IconBadge = ({ children }: { children: ReactElement }) => (
-
- {children}
-
-);
-
-const CashbackIcon = () => (
-
-
-
-);
-
-const ReferralsIcon = () => (
-
-
-
-);
-
-const YieldBoostIcon = () => (
-
-
-
-);
-
-/** The subscription card's icon is its own rate, set in the badge. */
-const SubscriptionIcon = ({ rate }: { rate: string }) => (
-
- {rate}
-
-);
-
interface BenefitCardProps {
title: string;
description: string;
diff --git a/components/Rewards/NewRewards/TierMembershipSheet.native.tsx b/components/Rewards/NewRewards/TierMembershipSheet.native.tsx
new file mode 100644
index 00000000..be09650c
--- /dev/null
+++ b/components/Rewards/NewRewards/TierMembershipSheet.native.tsx
@@ -0,0 +1,57 @@
+import { useCallback, useEffect, useRef } from 'react';
+import { BottomSheetBackdrop, BottomSheetModal, BottomSheetView } from '@gorhom/bottom-sheet';
+
+import TierMembershipSheetContent from './TierMembershipSheetContent';
+
+import type { TierMembershipSheetProps } from './TierMembershipSheet.types';
+
+const TierMembershipSheet = ({ open, onOpenChange }: TierMembershipSheetProps) => {
+ const sheetRef = useRef(null);
+
+ useEffect(() => {
+ if (open) sheetRef.current?.present();
+ else sheetRef.current?.dismiss();
+ }, [open]);
+
+ const renderBackdrop = useCallback(
+ (props: React.ComponentProps) => (
+
+ ),
+ [],
+ );
+
+ return (
+ onOpenChange(false)}
+ >
+
+ onOpenChange(false)} />
+
+
+ );
+};
+
+export default TierMembershipSheet;
diff --git a/components/Rewards/NewRewards/TierMembershipSheet.tsx b/components/Rewards/NewRewards/TierMembershipSheet.tsx
new file mode 100644
index 00000000..00f79f69
--- /dev/null
+++ b/components/Rewards/NewRewards/TierMembershipSheet.tsx
@@ -0,0 +1,30 @@
+import { useWindowDimensions } from 'react-native';
+
+import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
+
+import TierMembershipSheetContent from './TierMembershipSheetContent';
+
+import type { TierMembershipSheetProps } from './TierMembershipSheet.types';
+
+const MAX_SHEET_WIDTH = 419;
+
+const TierMembershipSheet = ({ open, onOpenChange }: TierMembershipSheetProps) => {
+ const { width: windowWidth } = useWindowDimensions();
+
+ return (
+
+ );
+};
+
+export default TierMembershipSheet;
diff --git a/components/Rewards/NewRewards/TierMembershipSheet.types.ts b/components/Rewards/NewRewards/TierMembershipSheet.types.ts
new file mode 100644
index 00000000..511753a1
--- /dev/null
+++ b/components/Rewards/NewRewards/TierMembershipSheet.types.ts
@@ -0,0 +1,4 @@
+export interface TierMembershipSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}
diff --git a/components/Rewards/NewRewards/TierMembershipSheetContent.tsx b/components/Rewards/NewRewards/TierMembershipSheetContent.tsx
new file mode 100644
index 00000000..f7ad693b
--- /dev/null
+++ b/components/Rewards/NewRewards/TierMembershipSheetContent.tsx
@@ -0,0 +1,161 @@
+import { useState } from 'react';
+import { Pressable, StyleSheet, View } from 'react-native';
+import { Image } from 'expo-image';
+
+import TierStar from '@/components/Rewards/NewRewards/TierHero/TierStar';
+import TierDetailRow from '@/components/Rewards/NewRewards/UpgradeTier/TierDetailRow';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { useCancelTierSubscription, useTierMembership } from '@/hooks/useTierMembership';
+import { getAsset } from '@/lib/assets';
+import { getTierDisplayName } from '@/lib/tierNames';
+import {
+ formatFuse,
+ formatMembershipDate,
+ formatMembershipDay,
+ formatUsd,
+ membershipDateLabel,
+} from '@/lib/tierUpgrade';
+import { RewardsTier, TierSubscriptionStatus } from '@/lib/types';
+
+const GLOW_ASSET = 'images/rewards-tiers/glow.svg' as const;
+const GLOW_SIZE = 260;
+const STAR_SIZE = 150;
+
+interface TierMembershipSheetContentProps {
+ onClose: () => void;
+ topPadding?: number;
+}
+
+/**
+ * What the user's membership actually is: which tier, since when, and what is
+ * holding it up.
+ *
+ * Opened by tapping the tier anywhere it is shown, and deliberately readable
+ * rather than promotional — this is the screen someone comes to when they want
+ * to know when their FUSE comes back or when they will next be charged, so
+ * every line is a fact with a date on it.
+ */
+const TierMembershipSheetContent = ({
+ onClose,
+ topPadding = 12,
+}: TierMembershipSheetContentProps) => {
+ const { data: membership } = useTierMembership();
+ const { mutateAsync: cancelSubscription, isPending: isCancelling } = useCancelTierSubscription();
+ const [isConfirmingCancel, setIsConfirmingCancel] = useState(false);
+
+ const tier = membership?.currentTier ?? RewardsTier.CORE;
+ const subscription = membership?.subscription ?? null;
+ const lock = membership?.lock;
+ const dateLabel = membershipDateLabel(membership);
+
+ // A membership already set to end has nothing left to cancel, and neither has
+ // one that has run out — offering the action there would be offering to do
+ // something that has already happened.
+ const canCancel =
+ subscription !== null &&
+ !subscription.cancelAtPeriodEnd &&
+ subscription.status !== TierSubscriptionStatus.EXPIRED &&
+ subscription.status !== TierSubscriptionStatus.CANCELLED;
+
+ return (
+
+
+
+
+
+
+
+
+ {getTierDisplayName(tier)}
+
+
+
+ Tier membership
+
+
+
+
+
+
+
+ {lock && lock.lockedFuse > 0 ? (
+
+
+ 0
+ ? 'Unlocking now'
+ : '—'
+ }
+ />
+
+ ) : null}
+
+ {subscription ? (
+
+
+ {dateLabel ? (
+
+ ) : null}
+
+ ) : null}
+
+ {/* The cancel path is two presses, not a dialog: the sheet is already a
+ modal, and stacking another over it to ask one question is a worse
+ answer than asking it here. */}
+ {canCancel ? (
+ {
+ if (isConfirmingCancel) void cancelSubscription(undefined);
+ else setIsConfirmingCancel(true);
+ }}
+ disabled={isCancelling}
+ hitSlop={8}
+ className="mt-5 transition-opacity active:opacity-60"
+ >
+
+ {isCancelling
+ ? 'Cancelling…'
+ : isConfirmingCancel
+ ? `Tap again to stop renewing — you keep ${getTierDisplayName(tier)} until ${formatMembershipDate(subscription?.currentPeriodEnd)}`
+ : 'Cancel membership'}
+
+
+ ) : null}
+
+
+
+ );
+};
+
+const styles = StyleSheet.create({
+ glow: {
+ opacity: 0.35,
+ position: 'absolute',
+ },
+});
+
+export default TierMembershipSheetContent;
diff --git a/components/Rewards/NewRewards/UpgradeTier/TierDetailRow.tsx b/components/Rewards/NewRewards/UpgradeTier/TierDetailRow.tsx
new file mode 100644
index 00000000..f8d19b21
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/TierDetailRow.tsx
@@ -0,0 +1,68 @@
+import { type ReactNode } from 'react';
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+
+interface TierDetailRowProps {
+ label: string;
+ value: string;
+ /** The smaller grey line under the value, e.g. a FUSE amount's USD worth. */
+ secondaryValue?: string;
+ /** A "?" the user can tap for an explanation, as on the lock-duration row. */
+ onExplain?: () => void;
+ /** Drawn under the row. The last row in a card does not have one. */
+ withDivider?: boolean;
+ /** Replaces the value, for a row that renders something other than text. */
+ children?: ReactNode;
+}
+
+/**
+ * One label/value line in an upgrade card.
+ *
+ * Every card on the upgrade and confirmation screens is a stack of these —
+ * annual fee, balance, FUSE amount, lock duration, tier, fee — so the row is
+ * one component. Writing it per screen is how "Annual Fee" and "Lock duration"
+ * end up a pixel apart on two screens the user moves between in one tap.
+ */
+const TierDetailRow = ({
+ label,
+ value,
+ secondaryValue,
+ onExplain,
+ withDivider = false,
+ children,
+}: TierDetailRowProps) => (
+
+
+
+ {label}
+ {onExplain ? (
+
+ ?
+
+ ) : null}
+
+
+ {children ?? (
+
+ {value}
+ {secondaryValue ? (
+
+ {secondaryValue}
+
+ ) : null}
+
+ )}
+
+
+ {withDivider ? : null}
+
+);
+
+export default TierDetailRow;
diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx
new file mode 100644
index 00000000..d8bde3da
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx
@@ -0,0 +1,66 @@
+import { Pressable, View } from 'react-native';
+
+import { Text } from '@/components/ui/text';
+import { cn } from '@/lib/utils';
+
+import type { TierUpgradeRoute } from '@/lib/tierUpgrade';
+
+const ROUTE_LABEL: Record = {
+ cash: 'Cash',
+ lock: 'Locked FUSE',
+};
+
+interface UpgradeRouteSwitchProps {
+ routes: TierUpgradeRoute[];
+ selected: TierUpgradeRoute;
+ onSelect: (route: TierUpgradeRoute) => void;
+}
+
+/**
+ * How the tier is being paid for.
+ *
+ * Renders whatever routes are actually on offer, which is why it takes a list
+ * rather than a boolean: Prime is sold both ways and shows two segments, Ultra
+ * is FUSE-only and shows one full-width segment. A disabled second segment
+ * would advertise a way to buy Ultra that does not exist.
+ *
+ * Draws nothing at all for a single route on a tier that has no alternative —
+ * a switch with one option is a label, and the row above it already says what
+ * this is.
+ */
+const UpgradeRouteSwitch = ({ routes, selected, onSelect }: UpgradeRouteSwitchProps) => {
+ if (routes.length === 0) return null;
+
+ return (
+
+ {routes.map(route => {
+ const isSelected = route === selected;
+
+ return (
+ onSelect(route)}
+ className={cn(
+ 'h-[42px] flex-1 items-center justify-center rounded-full transition-all active:opacity-80',
+ isSelected && 'bg-white',
+ )}
+ >
+
+ {ROUTE_LABEL[route]}
+
+
+ );
+ })}
+
+ );
+};
+
+export default UpgradeRouteSwitch;
diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeader.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeader.tsx
new file mode 100644
index 00000000..2e6852e0
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeader.tsx
@@ -0,0 +1,43 @@
+import { Pressable, View } from 'react-native';
+import { router } from 'expo-router';
+import { X } from 'lucide-react-native';
+
+import { BackButton } from '@/components/ui/back-button';
+import { Text } from '@/components/ui/text';
+import { path } from '@/constants/path';
+
+interface UpgradeTierHeaderProps {
+ title: string;
+ /** Where "back" lands when there is no history — a deep link into this flow. */
+ fallbackHref?: string;
+}
+
+/**
+ * Back on the left, title in the middle, dismiss on the right.
+ *
+ * The two are not the same action and the design gives them separate controls:
+ * back steps to the previous screen of the flow, while ✕ leaves the flow
+ * altogether and returns to Rewards. Collapsing them would strand a user on the
+ * confirmation screen with only a way back to the step they had finished with.
+ */
+const UpgradeTierHeader = ({
+ title,
+ fallbackHref = path.REWARDS as string,
+}: UpgradeTierHeaderProps) => (
+
+
+
+ {title}
+
+ router.replace(path.REWARDS)}
+ className="h-[50px] w-[50px] items-center justify-center rounded-full bg-[#2A2A2A] transition-all active:scale-95 active:opacity-80"
+ >
+
+
+
+);
+
+export default UpgradeTierHeader;
diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx
new file mode 100644
index 00000000..d6d2a812
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx
@@ -0,0 +1,109 @@
+import { StyleSheet, View } from 'react-native';
+import { Image } from 'expo-image';
+
+import {
+ CashbackIcon,
+ IconBadge,
+ SubscriptionIcon,
+ YieldBoostIcon,
+} from '@/components/Rewards/NewRewards/tierBenefitIcons';
+import TierStar from '@/components/Rewards/NewRewards/TierHero/TierStar';
+import { Text } from '@/components/ui/text';
+import { type AssetPath, getAsset } from '@/lib/assets';
+import { getTierDisplayName } from '@/lib/tierNames';
+import { RewardsTier } from '@/lib/types';
+
+import type { TierUpgradeBenefit } from './tierUpgradeBenefits';
+
+/** The per-tier card texture. Already in the asset registry, unused until now. */
+const TIER_TEXTURE: Record = {
+ [RewardsTier.CORE]: 'images/rewards-tiers/core-summary.png',
+ [RewardsTier.PRIME]: 'images/rewards-tiers/prime-summary.png',
+ [RewardsTier.ULTRA]: 'images/rewards-tiers/ultra-summary.png',
+};
+
+/** The benefit glyphs, at the 33px the tier card draws them. */
+const BENEFIT_ICON_SIZE = 33;
+
+interface UpgradeTierHeroCardProps {
+ tier: RewardsTier;
+ benefits: TierUpgradeBenefit[];
+ /** The pill in the top-right, e.g. "Renews on 10 Sep, 2027". Hidden when absent. */
+ statusLabel?: string;
+}
+
+/**
+ * The tier being bought, and what it gets you.
+ *
+ * The texture behind it is the tier's own, from the asset registry rather than
+ * a gradient written here — Prime's silver sheen and Ultra's darker one are
+ * design assets, and reproducing them in code is how they stop matching the
+ * rest of the rewards screens.
+ */
+const UpgradeTierHeroCard = ({ tier, benefits, statusLabel }: UpgradeTierHeroCardProps) => (
+
+
+
+
+
+
+
+
+ {getTierDisplayName(tier)}
+
+
+
+ {statusLabel ? (
+
+ {statusLabel}
+
+ ) : null}
+
+
+ Membership
+
+
+ {benefits.map(benefit => (
+
+
+
+ {benefit.label}
+
+
+ ))}
+
+
+
+);
+
+/**
+ * The glyph for one benefit line.
+ *
+ * The cashback cap has no mark of its own in the icon set, so it borrows the
+ * subscription badge's shape with a "$" in it — the design draws it as a
+ * currency mark in the same circle, and a made-up SVG would be one more thing
+ * to keep in step with the rest.
+ */
+const BenefitIcon = ({ benefitKey }: { benefitKey: TierUpgradeBenefit['key'] }) => {
+ switch (benefitKey) {
+ case 'cashback':
+ return ;
+ case 'yield-boost':
+ return ;
+ case 'subscription':
+ return ;
+ case 'cashback-cap':
+ return (
+
+ $
+
+ );
+ }
+};
+
+export default UpgradeTierHeroCard;
diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx
new file mode 100644
index 00000000..70406192
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierReviewScreen.tsx
@@ -0,0 +1,196 @@
+import { useMemo, useState } from 'react';
+import { View } from 'react-native';
+import { router, useLocalSearchParams } from 'expo-router';
+import { KeyRound } from 'lucide-react-native';
+
+import Loading from '@/components/Loading';
+import PageLayout from '@/components/PageLayout';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { path } from '@/constants/path';
+import { TRACKING_EVENTS } from '@/constants/tracking-events';
+import {
+ useLockFuseForTier,
+ useSubscribeToTier,
+ useTierMembership,
+ useTierUpgradeChainState,
+} from '@/hooks/useTierMembership';
+import { track } from '@/lib/analytics';
+import { getTierDisplayName } from '@/lib/tierNames';
+import {
+ findOffer,
+ formatFuse,
+ formatLockDuration,
+ formatUsd,
+ remainingFuseForTier,
+ type TierUpgradeRoute,
+} from '@/lib/tierUpgrade';
+import { RewardsTier } from '@/lib/types';
+
+import TierDetailRow from './TierDetailRow';
+import UpgradeTierHeader from './UpgradeTierHeader';
+
+/**
+ * The last screen before the signature: exactly what is being committed, and
+ * for how long.
+ *
+ * Separate from the upgrade screen on purpose. Locking FUSE for a year is not
+ * reversible by asking nicely, and the term is the part a user is most likely
+ * to have skimmed — so it gets a screen where it is one of four lines rather
+ * than one row among a price, a balance and a toggle.
+ */
+export default function UpgradeTierReviewScreen() {
+ const { tier: tierParam, route: routeParam } = useLocalSearchParams<{
+ tier?: string;
+ route?: string;
+ }>();
+ const { data: membership, isLoading } = useTierMembership();
+ const { data: chain } = useTierUpgradeChainState(membership?.contracts);
+ const { lockFuse, isLocking, error: lockError } = useLockFuseForTier();
+ const { subscribe, isSubscribing, error: subscribeError } = useSubscribeToTier();
+ const [failure, setFailure] = useState(null);
+
+ const tier = useMemo(
+ () => (tierParam === RewardsTier.PRIME || tierParam === RewardsTier.ULTRA ? tierParam : null),
+ [tierParam],
+ );
+ const route: TierUpgradeRoute = routeParam === 'cash' ? 'cash' : 'lock';
+ const offer = tier ? findOffer(membership, tier) : undefined;
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // Reached with a tier that is no longer on offer — a stale deep link, or the
+ // route was switched off while the screen was open. Sending them back to the
+ // upgrade screen re-derives a real offer rather than signing a stale one.
+ if (!membership || !tier || !offer) {
+ return (
+
+
+
+
+ That upgrade is no longer available.
+
+
+
+
+ );
+ }
+
+ const remainingFuse = remainingFuseForTier(offer, membership.lock.lockedFuse);
+ const isPending = isLocking || isSubscribing;
+ const message = failure ?? lockError ?? subscribeError;
+
+ const handleUpgrade = async () => {
+ setFailure(null);
+
+ try {
+ if (route === 'lock') {
+ if (!membership.contracts.lockAddress || !membership.contracts.shareTokenAddress) {
+ throw new Error('Locking is not available right now.');
+ }
+ track(TRACKING_EVENTS.TIER_LOCK_PRESSED, { tier, fuse_amount: remainingFuse });
+
+ const result = await lockFuse({
+ tier,
+ fuseAmount: remainingFuse,
+ // The rate read alongside the balances this screen was built from, so
+ // the share count matches the FUSE figure the user has just approved.
+ rate: chain?.rate ?? 0n,
+ lockAddress: membership.contracts.lockAddress,
+ shareTokenAddress: membership.contracts.shareTokenAddress,
+ });
+
+ // Null is the passkey prompt being dismissed — a decision, not a
+ // failure, so the user stays on the screen they chose to leave.
+ if (result) router.replace(path.REWARDS);
+ return;
+ }
+
+ if (!membership.contracts.subscriptionModuleAddress) {
+ throw new Error('Memberships are not available right now.');
+ }
+ track(TRACKING_EVENTS.TIER_SUBSCRIBE_PRESSED, { tier, price_usd: offer.annualFeeUsd });
+
+ const result = await subscribe({
+ tier,
+ priceUsd: offer.annualFeeUsd,
+ moduleAddress: membership.contracts.subscriptionModuleAddress,
+ moduleEnabled: chain?.moduleEnabled ?? false,
+ });
+
+ if (result) router.replace(path.REWARDS);
+ } catch (error) {
+ setFailure(error instanceof Error ? error.message : 'Something went wrong. Try again.');
+ }
+ };
+
+ return (
+
+
+
+
+
+
+
+ {route === 'lock' ? (
+ <>
+
+
+
+ >
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ {route === 'lock'
+ ? `Your FUSE will be unlocked automatically ${formatLockDuration(
+ membership.lock.durationDays,
+ )} from now, and keeps earning until then.`
+ : 'Your membership renews once a year. Cancel any time — you keep the tier to the end of the period you have paid for.'}
+
+
+ {message ? (
+ {message}
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx
new file mode 100644
index 00000000..87966a72
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierScreen.tsx
@@ -0,0 +1,273 @@
+import { useEffect, useMemo, useState } from 'react';
+import { Linking, Pressable, View } from 'react-native';
+import { router, useLocalSearchParams } from 'expo-router';
+
+import Loading from '@/components/Loading';
+import PageLayout from '@/components/PageLayout';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { path } from '@/constants/path';
+import { TRACKING_EVENTS } from '@/constants/tracking-events';
+import { useTierBenefits } from '@/hooks/useRewards';
+import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow';
+import { useTierMembership, useTierUpgradeChainState } from '@/hooks/useTierMembership';
+import { track } from '@/lib/analytics';
+import { getTierDisplayName } from '@/lib/tierNames';
+import {
+ availableRoutes,
+ canAffordUpgrade,
+ findOffer,
+ formatFuse,
+ formatLockDuration,
+ formatMembershipDate,
+ formatUsd,
+ membershipDateLabel,
+ nextPurchasableTier,
+ remainingFuseForTier,
+ type TierUpgradeRoute,
+} from '@/lib/tierUpgrade';
+import { RewardsTier } from '@/lib/types';
+import { useDepositStore } from '@/store/useDepositStore';
+
+import TierDetailRow from './TierDetailRow';
+import { findTierBenefits, resolveTierUpgradeBenefits } from './tierUpgradeBenefits';
+import UpgradeRouteSwitch from './UpgradeRouteSwitch';
+import UpgradeTierHeader from './UpgradeTierHeader';
+import UpgradeTierHeroCard from './UpgradeTierHeroCard';
+
+/** Where "Learn more" and "How to earn points?" send the user. */
+const MEMBERSHIP_HELP_URL = 'https://docs.solid.money/rewards/tiers';
+
+/**
+ * Buying a tier: what it costs by each route, what the user has, and one action.
+ *
+ * The screen does not buy anything itself. Its whole job is to get the user to
+ * the point where one press is unambiguous — which is why the CTA is either
+ * "Top up" or "Review upgrade" and never both, and why the review step is a
+ * separate screen: committing FUSE for a year is worth a second look at the
+ * term before the passkey prompt.
+ */
+export default function UpgradeTierScreen() {
+ const { tier: tierParam } = useLocalSearchParams<{ tier?: string }>();
+ const { data: membership, isLoading } = useTierMembership();
+ const { data: tierBenefits } = useTierBenefits();
+ const { data: chain } = useTierUpgradeChainState(membership?.contracts);
+ const { selectToken: selectSavingsFundToken } = useSavingsFundFlow();
+
+ // The tier from the deep link when it names one, else the cheapest the user
+ // does not already hold — so "Upgrade" from anywhere lands somewhere useful.
+ const tier = useMemo(() => {
+ if (tierParam === RewardsTier.PRIME || tierParam === RewardsTier.ULTRA) return tierParam;
+ return nextPurchasableTier(membership);
+ }, [membership, tierParam]);
+
+ const offer = tier ? findOffer(membership, tier) : undefined;
+ // Memoised because the effect below depends on it: a fresh array every render
+ // would re-run the effect every render for no reason.
+ const routes = useMemo(() => availableRoutes(offer), [offer]);
+ const [route, setRoute] = useState(null);
+
+ // Settles on a route once the offer is known, and re-settles if the one in
+ // hand stops being available — a tier taken off cash sale while the screen is
+ // open must not leave a "Cash" tab selected that cannot be completed.
+ useEffect(() => {
+ if (routes.length === 0) return;
+ if (route && routes.includes(route)) return;
+ setRoute(routes[0]);
+ }, [route, routes]);
+
+ useEffect(() => {
+ if (tier) track(TRACKING_EVENTS.TIER_UPGRADE_OPENED, { tier });
+ }, [tier]);
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ // Nothing to sell: the routes are switched off, or the user already holds
+ // everything. Either way an upgrade screen is the wrong thing to be looking
+ // at, so it hands back to Rewards rather than rendering an empty offer.
+ if (!membership?.enabled || !tier || !offer || !route) {
+ return (
+
+
+
+
+ {membership?.currentTier === RewardsTier.ULTRA
+ ? 'You are on the highest tier.'
+ : 'Tier upgrades are not available right now.'}
+
+
+
+
+ );
+ }
+
+ // A deep link can name a tier the user already holds. Offering it would price
+ // an upgrade at nothing and hand the review screen zero FUSE to lock, so the
+ // honest answer is that there is nothing to buy.
+ if (offer.held) {
+ return (
+
+
+
+
+ You already hold {getTierDisplayName(tier)}.
+
+
+
+
+ );
+ }
+
+ const benefits = resolveTierUpgradeBenefits(findTierBenefits(tierBenefits, tier));
+ const dateLabel = membershipDateLabel(membership);
+ const remainingFuse = remainingFuseForTier(offer, membership.lock.lockedFuse);
+ const availableFuse = chain?.fuse ?? 0;
+ const availableUsdc = chain?.usdcAmount ?? 0;
+
+ const affordable = canAffordUpgrade({
+ route,
+ offer,
+ lockedFuse: membership.lock.lockedFuse,
+ availableFuse,
+ availableUsdc,
+ });
+
+ const handleRoute = (next: TierUpgradeRoute) => {
+ setRoute(next);
+ track(TRACKING_EVENTS.TIER_UPGRADE_ROUTE_SELECTED, { tier, route: next });
+ };
+
+ /**
+ * Short of what the upgrade costs, so the press has to fix that first.
+ *
+ * Each route tops up in its own currency and through the flow that already
+ * exists for it: FUSE through the savings funding flow, USDC through deposit.
+ */
+ const handleTopUp = () => {
+ const depositStore = useDepositStore.getState();
+ depositStore.resetDepositFlow();
+
+ if (route === 'lock') {
+ depositStore.setSavingsFundIntent('savings');
+ depositStore.setDepositFromSolid(false);
+ selectSavingsFundToken('WFUSE');
+ return;
+ }
+
+ router.push(path.DEPOSIT);
+ };
+
+ const handleReview = () => {
+ track(TRACKING_EVENTS.TIER_UPGRADE_REVIEWED, { tier, route });
+ router.push({
+ pathname: '/rewards/upgrade-review',
+ params: { tier, route },
+ } as never);
+ };
+
+ return (
+
+
+
+
+
+
+
+ Upgrade tier with
+
+
+
+
+
+ {route === 'cash' ? (
+ <>
+
+
+ >
+ ) : (
+ <>
+
+ void Linking.openURL(MEMBERSHIP_HELP_URL)}
+ withDivider
+ />
+
+ >
+ )}
+
+
+
+ {route === 'cash'
+ ? `Upgrade to the ${offer.tier === RewardsTier.ULTRA ? 'Ultra' : 'Prime'} tier with an annual fee. `
+ : `Lock FUSE for ${formatLockDuration(membership.lock.durationDays)} to hold the tier — it keeps earning while it is locked. `}
+ void Linking.openURL(MEMBERSHIP_HELP_URL)}
+ className="text-[15px] leading-5 text-white underline"
+ >
+ Learn more
+
+
+
+
+
+ {/* Only ever shown when it changes the decision: the user has the money
+ but it is in the wrong place, which "Top up" does not describe. */}
+ {!affordable && route === 'lock' && availableFuse > 0 ? (
+
+
+ {formatFuse(remainingFuse - availableFuse)} FUSE short — add more to Savings
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/components/Rewards/NewRewards/UpgradeTier/tierUpgradeBenefits.ts b/components/Rewards/NewRewards/UpgradeTier/tierUpgradeBenefits.ts
new file mode 100644
index 00000000..90cbf25d
--- /dev/null
+++ b/components/Rewards/NewRewards/UpgradeTier/tierUpgradeBenefits.ts
@@ -0,0 +1,52 @@
+import { RewardsTier, TierBenefits } from '@/lib/types';
+
+/** One line of the tier card's benefit list. */
+export interface TierUpgradeBenefit {
+ key: 'cashback' | 'yield-boost' | 'subscription' | 'cashback-cap';
+ label: string;
+}
+
+/**
+ * The four things a tier gets you, as the upgrade card lists them.
+ *
+ * Read off the same `tier-benefits` payload the comparison screen renders, so
+ * the card cannot promise a rate the comparison table contradicts. A benefit the
+ * backend has no copy for is dropped rather than rendered empty — a tier card
+ * with three true lines is better than one with four and a blank.
+ *
+ * `subscriptionDiscount` is null for a tier that does not grant it, which is why
+ * it is the only one that has to be checked for existence rather than for text.
+ */
+export const resolveTierUpgradeBenefits = (
+ benefits: TierBenefits | undefined,
+): TierUpgradeBenefit[] => {
+ if (!benefits) return [];
+
+ const lines: (TierUpgradeBenefit | null)[] = [
+ benefits.cardCashback?.title
+ ? { key: 'cashback', label: `${benefits.cardCashback.title} Cashback` }
+ : null,
+ benefits.depositBoost?.title
+ ? { key: 'yield-boost', label: `${benefits.depositBoost.title} Yield boost` }
+ : null,
+ benefits.subscriptionDiscount?.title
+ ? {
+ key: 'subscription',
+ label: benefits.subscriptionDiscount.subtitle
+ ? `${benefits.subscriptionDiscount.title} ${benefits.subscriptionDiscount.subtitle}`
+ : benefits.subscriptionDiscount.title,
+ }
+ : null,
+ benefits.cardCashbackCap?.title
+ ? { key: 'cashback-cap', label: benefits.cardCashbackCap.title }
+ : null,
+ ];
+
+ return lines.filter((line): line is TierUpgradeBenefit => line !== null);
+};
+
+/** The benefits block for one tier, from the list the endpoint returns. */
+export const findTierBenefits = (
+ benefits: TierBenefits[] | undefined,
+ tier: RewardsTier,
+): TierBenefits | undefined => benefits?.find(entry => entry.tier === tier);
diff --git a/components/Rewards/NewRewards/skipTheLine.ts b/components/Rewards/NewRewards/skipTheLine.ts
index f8903fdc..90812588 100644
--- a/components/Rewards/NewRewards/skipTheLine.ts
+++ b/components/Rewards/NewRewards/skipTheLine.ts
@@ -14,6 +14,38 @@ const FALLBACK_TIER_POINT_THRESHOLDS: Partial> = {
[RewardsTier.ULTRA]: 35_000_000,
};
+/**
+ * The FUSE thresholds at launch, for the preview fallback only.
+ *
+ * `getBuyFuseTierTargets` prices its rungs from the backend's own block, which
+ * is exactly what the fallback exists because it does not have — so the
+ * fallback has to hand it a stand-in rather than call it with nothing and get
+ * an empty list back. These match the shipped `fuse_staking.tier*.amount`
+ * defaults; real config always wins when the backend sends it.
+ */
+const FALLBACK_SKIP_LINE: FuseSkipLine = {
+ enabled: true,
+ balanceFuse: 0,
+ balanceUsd: 0,
+ unlockedTier: RewardsTier.CORE,
+ tiers: [
+ {
+ tier: RewardsTier.PRIME,
+ requiredFuse: 50_000,
+ unlocked: false,
+ remainingFuse: 50_000,
+ progressPct: 0,
+ },
+ {
+ tier: RewardsTier.ULTRA,
+ requiredFuse: 400_000,
+ unlocked: false,
+ remainingFuse: 400_000,
+ progressPct: 0,
+ },
+ ],
+};
+
/**
* Whether the "Skip the line" section has anything to show.
*
@@ -59,7 +91,7 @@ export const resolveTierUpgradeCardData = ({
balanceFuse: 0,
balanceUsd: 0,
unlockedTier: currentTier,
- tiers: getBuyFuseTierTargets(currentTier),
+ tiers: getBuyFuseTierTargets(currentTier, FALLBACK_SKIP_LINE),
}
: undefined);
const resolvedTargetPoints =
diff --git a/components/Rewards/NewRewards/tierBenefitIcons.tsx b/components/Rewards/NewRewards/tierBenefitIcons.tsx
new file mode 100644
index 00000000..f77c9d54
--- /dev/null
+++ b/components/Rewards/NewRewards/tierBenefitIcons.tsx
@@ -0,0 +1,128 @@
+import { type ReactElement } from 'react';
+import { View } from 'react-native';
+import Svg, { G, Path } from 'react-native-svg';
+
+import { Text } from '@/components/ui/text';
+
+/**
+ * The circular benefit glyphs the rewards screens share.
+ *
+ * Lifted out of `TierBenefitsGrid` when the upgrade screens needed the same
+ * marks at a smaller size. They are one set rather than two because they are
+ * one set in the design: the same cashback diamond and yield-boost bolt appear
+ * on the benefits grid at 50px and inside the upgrade card's tier summary at
+ * 33px, and a second copy is how the two would drift apart.
+ */
+
+/** The circle every benefit icon sits in. 50px on the grid, 33px on a tier card. */
+export const IconBadge = ({ children, size = 50 }: { children: ReactElement; size?: number }) => (
+
+ {children}
+
+);
+
+const CASHBACK_DIAMOND_PATH =
+ 'M3.06451 2.81534C3.81171 1.80329 4.18531 1.29727 4.73962 1.02363C5.29394 0.750001 5.94542 0.750001 7.2484 0.750001H12.25H17.2517C18.5546 0.750001 19.2061 0.750001 19.7604 1.02363C20.3148 1.29727 20.6883 1.80329 21.4355 2.81534L22.198 3.84805C23.2487 5.27123 23.7741 5.98283 23.7492 6.78503C23.7243 7.58723 23.1556 8.26822 22.0182 9.6303L15.2824 17.6974C14.3461 18.8187 13.8779 19.3794 13.3317 19.6215C12.6462 19.9255 11.8538 19.9255 11.1683 19.6215C10.6221 19.3794 10.1539 18.8187 9.21767 17.6974L2.48175 9.6303C1.34443 8.26822 0.775785 7.58723 0.75085 6.78503C0.725927 5.98283 1.25129 5.27123 2.30204 3.84805L3.06451 2.81534Z';
+const CASHBACK_SLASH_PATH =
+ 'M1.64728 0.750063L1.00889 1.65796C0.630253 2.19645 0.67028 2.90693 1.10728 3.4041L3.56244 6.19743';
+// Lightning bolt with speed lines, drawn at the badge's own 50x49 scale so it
+// lands exactly where the design places it inside the circle.
+const YIELD_BOOST_PATH =
+ 'M20.4366 32.3333H13.0221M17.0664 25H11M20.4366 17.6667H13.6962M31.2214 13L22.3288 25.3133C21.9352 25.8584 21.7384 26.1308 21.7469 26.358C21.7543 26.5559 21.8503 26.7401 22.0087 26.8611C22.1907 27 22.5292 27 23.2064 27H29.8733L28.5252 37L37.4178 24.6867C37.8114 24.1416 38.0082 23.8692 37.9997 23.642C37.9923 23.4441 37.8963 23.2599 37.7379 23.1389C37.5559 23 37.2174 23 36.5402 23H29.8733L31.2214 13Z';
+
+export const CashbackIcon = ({ size = 50 }: { size?: number }) => (
+
+
+
+);
+
+export const ReferralsIcon = ({ size = 50 }: { size?: number }) => (
+
+
+
+);
+
+export const YieldBoostIcon = ({ size = 50 }: { size?: number }) => (
+
+
+
+);
+
+/** The subscription card's icon is its own rate, set in the badge. */
+export const SubscriptionIcon = ({ rate, size = 50 }: { rate: string; size?: number }) => (
+
+ {rate}
+
+);
diff --git a/constants/path.ts b/constants/path.ts
index 88c7340f..513183ee 100644
--- a/constants/path.ts
+++ b/constants/path.ts
@@ -87,6 +87,7 @@ type Path = {
POINTS_LEADERBOARD: Href;
REWARDS: Href;
REWARDS_BENEFITS: Href;
+ REWARDS_UPGRADE: Href;
OVERVIEW: Href;
/**
* @deprecated Same story as `CARD` — `/card-onboard` served the standalone card
@@ -150,6 +151,7 @@ export const path: Path = {
POINTS_LEADERBOARD: '/points/leaderboard',
REWARDS: '/rewards',
REWARDS_BENEFITS: '/rewards/benefits',
+ REWARDS_UPGRADE: '/rewards/upgrade',
OVERVIEW: '/overview',
CARD_WAITLIST: '/card-onboard',
CARD_WAITLIST_SUCCESS: '/card-onboard/success',
diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts
index 0f0db929..0bdcf992 100644
--- a/constants/tracking-events.ts
+++ b/constants/tracking-events.ts
@@ -406,6 +406,25 @@ export const TRACKING_EVENTS = {
TRUSTPILOT_WIDGET_SHOWN: 'trustpilot_widget_shown',
TRUSTPILOT_WIDGET_UNAVAILABLE: 'trustpilot_widget_unavailable',
TRUSTPILOT_REVIEW_LINK_OPENED: 'trustpilot_review_link_opened',
+
+ // Tier membership (rewards v3): buying a tier by locking FUSE or paying an
+ // annual fee. Both routes carry `tier` so they can be compared directly, and
+ // the funnel is deliberately split at the signature — a user who dismisses
+ // the passkey prompt has made a decision, not hit an error.
+ TIER_UPGRADE_OPENED: 'tier_upgrade_opened',
+ TIER_UPGRADE_ROUTE_SELECTED: 'tier_upgrade_route_selected',
+ TIER_UPGRADE_REVIEWED: 'tier_upgrade_reviewed',
+ TIER_LOCK_PRESSED: 'tier_lock_pressed',
+ TIER_LOCK_CANCELLED: 'tier_lock_cancelled',
+ TIER_LOCK_COMPLETED: 'tier_lock_completed',
+ TIER_LOCK_FAILED: 'tier_lock_failed',
+ TIER_SUBSCRIBE_PRESSED: 'tier_subscribe_pressed',
+ TIER_SUBSCRIBE_CANCELLED: 'tier_subscribe_cancelled',
+ TIER_SUBSCRIBE_COMPLETED: 'tier_subscribe_completed',
+ TIER_SUBSCRIBE_FAILED: 'tier_subscribe_failed',
+ TIER_SUBSCRIPTION_CANCEL_COMPLETED: 'tier_subscription_cancel_completed',
+ TIER_SUBSCRIPTION_RESUME_COMPLETED: 'tier_subscription_resume_completed',
+ TIER_MEMBERSHIP_SHEET_OPENED: 'tier_membership_sheet_opened',
} as const;
export type TrackingEvent = (typeof TRACKING_EVENTS)[keyof typeof TRACKING_EVENTS];
diff --git a/hooks/useTierMembership.ts b/hooks/useTierMembership.ts
new file mode 100644
index 00000000..dc78321c
--- /dev/null
+++ b/hooks/useTierMembership.ts
@@ -0,0 +1,437 @@
+import { useCallback, useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Address, encodeFunctionData, erc20Abi, formatUnits } from 'viem';
+import { fuse } from 'viem/chains';
+
+import { TRACKING_EVENTS } from '@/constants/tracking-events';
+import useUser from '@/hooks/useUser';
+import { Safe_ABI } from '@/lib/abis/Safe';
+import { SolidSubscriptionModule_ABI } from '@/lib/abis/SolidSubscriptionModule';
+import { SolidTierLock_ABI } from '@/lib/abis/SolidTierLock';
+import { track } from '@/lib/analytics';
+import {
+ cancelTierSubscription,
+ confirmTierLock,
+ confirmTierSubscription,
+ fetchTierMembership,
+ resumeTierSubscription,
+} from '@/lib/api';
+import { ADDRESSES } from '@/lib/config';
+import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute';
+import { fuseSharesForAmount } from '@/lib/tierUpgrade';
+import { RewardsTier, TierMembershipState } from '@/lib/types';
+import { publicClient } from '@/lib/wagmi';
+import { useUserStore } from '@/store/useUserStore';
+
+export const TIER_MEMBERSHIP_QUERY_KEY = 'tierMembership';
+export const TIER_UPGRADE_BALANCES_QUERY_KEY = 'tierUpgradeBalances';
+
+/** soFUSE shares, the accountant rate and the share token all use 18 decimals. */
+const SHARE_DECIMALS = 18;
+/** USDC, and therefore every amount the subscription module moves. */
+const BILLING_DECIMALS = 6;
+/** The billing period a mandate is signed for. */
+const YEAR_SECONDS = 365n * 24n * 60n * 60n;
+
+/** What the upgrade screens need from the chain, read fresh at the moment of use. */
+export interface TierUpgradeChainState {
+ /** soFUSE shares the Safe holds, raw. */
+ shares: bigint;
+ /** That position in FUSE. */
+ fuse: number;
+ /** soFUSE→FUSE rate, raw (18 decimals). */
+ rate: bigint;
+ /** USDC the Safe holds, raw (6 decimals). */
+ usdc: bigint;
+ /** That balance as a number, for display. */
+ usdcAmount: number;
+ /** Whether the subscription module is already enabled on the Safe. */
+ moduleEnabled: boolean;
+ /** Whether the Safe has already written a mandate. */
+ hasMandate: boolean;
+}
+
+/**
+ * What each tier costs by either route, and what the user has already bought.
+ *
+ * Served by the backend as one payload rather than assembled here: the price,
+ * the thresholds and the user's own position have to describe the same instant,
+ * and three requests is how a screen ends up offering a tier the user already
+ * holds.
+ */
+export const useTierMembership = () => {
+ const selectedUserId = useUserStore(state => state.users.find(user => user.selected)?.userId);
+
+ return useQuery({
+ queryKey: [TIER_MEMBERSHIP_QUERY_KEY, selectedUserId],
+ queryFn: fetchTierMembership,
+ enabled: Boolean(selectedUserId),
+ staleTime: 30_000,
+ });
+};
+
+/**
+ * The chain state an upgrade is actually built from.
+ *
+ * Read directly rather than taken from the backend for the same reason
+ * `useCardSpendRegistration` does it: the module's consent and the Safe's
+ * balances are on-chain facts a user can change from any Safe client with no
+ * call to us, and a screen that offers to spend money has to be reading the
+ * chain, not a cache of it.
+ */
+export const useTierUpgradeChainState = (contracts?: {
+ lockAddress: string | null;
+ subscriptionModuleAddress: string | null;
+ shareTokenAddress: string | null;
+ billingTokenAddress: string | null;
+}) => {
+ const { user } = useUser();
+ const safeAddress = user?.safeAddress as Address | undefined;
+ const shareToken = (contracts?.shareTokenAddress ?? ADDRESSES.fuse.fuseVault) as Address;
+ const billingToken = contracts?.billingTokenAddress as Address | undefined;
+ const moduleAddress = contracts?.subscriptionModuleAddress as Address | undefined;
+
+ return useQuery({
+ queryKey: [
+ TIER_UPGRADE_BALANCES_QUERY_KEY,
+ safeAddress,
+ shareToken,
+ billingToken,
+ moduleAddress,
+ ],
+ enabled: Boolean(safeAddress),
+ staleTime: 15_000,
+ queryFn: async () => {
+ const client = publicClient(fuse.id);
+
+ // All five in flight together: this drives a screen that has to price an
+ // offer against a balance, and fetching them in sequence is how the two
+ // end up describing different moments.
+ const [shares, rate, usdc, moduleEnabled, subscription] = await Promise.all([
+ client.readContract({
+ address: shareToken,
+ abi: erc20Abi,
+ functionName: 'balanceOf',
+ args: [safeAddress!],
+ }),
+ client.readContract({
+ address: ADDRESSES.fuse.fuseAccountant,
+ abi: [
+ {
+ inputs: [],
+ name: 'getRate',
+ outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ ] as const,
+ functionName: 'getRate',
+ }),
+ billingToken
+ ? client.readContract({
+ address: billingToken,
+ abi: erc20Abi,
+ functionName: 'balanceOf',
+ args: [safeAddress!],
+ })
+ : Promise.resolve(0n),
+ moduleAddress
+ ? client.readContract({
+ address: moduleAddress,
+ abi: SolidSubscriptionModule_ABI,
+ functionName: 'isModuleEnabledOn',
+ args: [safeAddress!],
+ })
+ : Promise.resolve(false),
+ moduleAddress
+ ? client.readContract({
+ address: moduleAddress,
+ abi: SolidSubscriptionModule_ABI,
+ functionName: 'subscriptionOf',
+ args: [safeAddress!],
+ })
+ : Promise.resolve(undefined),
+ ]);
+
+ return {
+ shares,
+ // Shares are yield-bearing, so a raw balance understates the position —
+ // it has to go through the accountant rate to read as FUSE.
+ fuse: Number(formatUnits((shares * rate) / 10n ** BigInt(SHARE_DECIMALS), SHARE_DECIMALS)),
+ rate,
+ usdc,
+ usdcAmount: Number(formatUnits(usdc, BILLING_DECIMALS)),
+ moduleEnabled,
+ hasMandate: subscription?.registered === true && subscription.cancelledAt === 0n,
+ };
+ },
+ });
+};
+
+/** Everything an upgrade 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'] });
+ }, [queryClient]);
+};
+
+/**
+ * Lock FUSE to hold a tier.
+ *
+ * Two calls in one user operation: approve the shares to the lock, then lock
+ * them. Batched so the user signs once and so neither half can land without the
+ * other — an approval left standing with no lock behind it is a permission the
+ * user did not mean to leave lying around.
+ *
+ * The amount is worked out in shares, not FUSE. The tier threshold is measured
+ * in FUSE and the vault's rate converts between them, so the share count is
+ * rounded *up*: locking a share too few would leave the position a wei short of
+ * the threshold and buy nothing.
+ */
+export const useLockFuseForTier = () => {
+ const { user, safeAA } = useUser();
+ const invalidate = useInvalidateAfterUpgrade();
+ const [error, setError] = useState(null);
+
+ const mutation = useMutation({
+ mutationFn: async ({
+ tier,
+ fuseAmount,
+ rate,
+ lockAddress,
+ shareTokenAddress,
+ }: {
+ tier: RewardsTier;
+ /** FUSE the user is committing. */
+ fuseAmount: number;
+ /** soFUSE→FUSE rate, raw. */
+ rate: bigint;
+ lockAddress: string;
+ shareTokenAddress: string;
+ }) => {
+ if (!user?.suborgId || !user?.signWith) {
+ throw new Error('Your wallet is still setting up. Please try again shortly.');
+ }
+
+ const shares = fuseSharesForAmount(fuseAmount, rate);
+ if (shares <= 0n) throw new Error('Enter an amount to lock.');
+
+ const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith);
+
+ const result = await executeTransactions(
+ smartAccountClient,
+ [
+ {
+ to: shareTokenAddress as Address,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [lockAddress as Address, shares],
+ }),
+ value: 0n,
+ },
+ {
+ to: lockAddress as Address,
+ data: encodeFunctionData({
+ abi: SolidTierLock_ABI,
+ functionName: 'lock',
+ args: [shares],
+ }),
+ value: 0n,
+ },
+ ],
+ 'Failed to lock your FUSE',
+ fuse,
+ );
+
+ if (result === USER_CANCELLED_TRANSACTION) {
+ track(TRACKING_EVENTS.TIER_LOCK_CANCELLED, { tier, fuse_amount: fuseAmount });
+ return null;
+ }
+
+ // Told to the backend afterwards, not before: the lock is already on
+ // chain and the tier is already the user's. This is what drops the cached
+ // position so it shows immediately, and registers them for the automatic
+ // return when the term is up.
+ const state = await confirmTierLock({ transactionHash: result.transactionHash });
+
+ return { state, transactionHash: result.transactionHash, fuseAmount, tier };
+ },
+ onSuccess: result => {
+ if (!result) return;
+ invalidate();
+ track(TRACKING_EVENTS.TIER_LOCK_COMPLETED, {
+ tier: result.tier,
+ fuse_amount: result.fuseAmount,
+ transaction_hash: result.transactionHash,
+ });
+ },
+ onError: (mutationError: Error) => {
+ const message = mutationError?.message || 'Failed to lock your FUSE';
+ setError(message);
+ track(TRACKING_EVENTS.TIER_LOCK_FAILED, { error: message });
+ },
+ });
+
+ return {
+ lockFuse: mutation.mutateAsync,
+ isLocking: mutation.isPending,
+ error,
+ clearError: () => setError(null),
+ };
+};
+
+/**
+ * Buy a tier with the annual fee.
+ *
+ * One user operation again: enable the module on the Safe, then write the
+ * mandate. Batching is what makes the permission and its bounds a single
+ * decision — a Safe cannot end up with the module enabled and no mandate, which
+ * would be a standing permission with nothing shaping it.
+ *
+ * The mandate is set to exactly the price. A cap with headroom in it would
+ * survive a price rise without asking, and asking is the point: a subscription
+ * whose price goes up is a new agreement, and the user should sign it.
+ */
+export const useSubscribeToTier = () => {
+ const { user, safeAA } = useUser();
+ const invalidate = useInvalidateAfterUpgrade();
+ const [error, setError] = useState(null);
+
+ const mutation = useMutation({
+ mutationFn: async ({
+ tier,
+ priceUsd,
+ moduleAddress,
+ moduleEnabled,
+ }: {
+ tier: RewardsTier;
+ priceUsd: number;
+ moduleAddress: string;
+ /** Whether the Safe has already enabled the module. */
+ moduleEnabled: boolean;
+ }) => {
+ if (!user?.suborgId || !user?.signWith || !user?.safeAddress) {
+ throw new Error('Your wallet is still setting up. Please try again shortly.');
+ }
+
+ // Rounded to whole cents before scaling, so a price stored as a decimal
+ // cannot become a mandate a fraction under what will be charged.
+ const mandate = BigInt(Math.round(priceUsd * 100)) * 10n ** BigInt(BILLING_DECIMALS - 2);
+ if (mandate <= 0n) throw new Error('This tier is not available with an annual fee.');
+
+ const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith);
+
+ const result = await executeTransactions(
+ smartAccountClient,
+ [
+ ...(moduleEnabled
+ ? []
+ : [
+ {
+ to: user.safeAddress as Address,
+ data: encodeFunctionData({
+ abi: Safe_ABI,
+ functionName: 'enableModule',
+ args: [moduleAddress as Address],
+ }),
+ value: 0n,
+ },
+ ]),
+ {
+ to: moduleAddress as Address,
+ data: encodeFunctionData({
+ abi: SolidSubscriptionModule_ABI,
+ functionName: 'subscribe',
+ args: [mandate, YEAR_SECONDS],
+ }),
+ value: 0n,
+ },
+ ],
+ 'Failed to start your membership',
+ fuse,
+ );
+
+ if (result === USER_CANCELLED_TRANSACTION) {
+ track(TRACKING_EVENTS.TIER_SUBSCRIBE_CANCELLED, { tier, price_usd: priceUsd });
+ return null;
+ }
+
+ // The first payment is taken here, server-side, against the mandate that
+ // has just landed. It can fail — most often for a Safe short of USDC —
+ // and when it does the error carries the module's own reason.
+ const state = await confirmTierSubscription({
+ tier,
+ transactionHash: result.transactionHash,
+ });
+
+ return { state, transactionHash: result.transactionHash, tier, priceUsd };
+ },
+ onSuccess: result => {
+ if (!result) return;
+ invalidate();
+ track(TRACKING_EVENTS.TIER_SUBSCRIBE_COMPLETED, {
+ tier: result.tier,
+ price_usd: result.priceUsd,
+ transaction_hash: result.transactionHash,
+ });
+ },
+ onError: (mutationError: Error) => {
+ const message = mutationError?.message || 'Failed to start your membership';
+ setError(message);
+ track(TRACKING_EVENTS.TIER_SUBSCRIBE_FAILED, { error: message });
+ },
+ });
+
+ return {
+ subscribe: mutation.mutateAsync,
+ isSubscribing: mutation.isPending,
+ error,
+ clearError: () => setError(null),
+ };
+};
+
+/**
+ * Stop a membership renewing.
+ *
+ * Off-chain only, and that is the whole of it as far as money is concerned:
+ * Solid's billing engine is the only thing that draws against the mandate, so
+ * ceasing to draw ends the charges. The on-chain `cancel()` is offered
+ * separately for anyone who wants the permission itself gone rather than merely
+ * unused.
+ */
+export const useCancelTierSubscription = () => {
+ const invalidate = useInvalidateAfterUpgrade();
+
+ return useMutation({
+ mutationFn: (reason?: string) => cancelTierSubscription({ reason }),
+ onSuccess: subscription => {
+ invalidate();
+ track(TRACKING_EVENTS.TIER_SUBSCRIPTION_CANCEL_COMPLETED, {
+ tier: subscription.tier,
+ period_end: subscription.currentPeriodEnd,
+ });
+ },
+ });
+};
+
+/** Undo a cancellation while the paid period is still running. */
+export const useResumeTierSubscription = () => {
+ const invalidate = useInvalidateAfterUpgrade();
+
+ return useMutation({
+ mutationFn: () => resumeTierSubscription(),
+ onSuccess: subscription => {
+ invalidate();
+ track(TRACKING_EVENTS.TIER_SUBSCRIPTION_RESUME_COMPLETED, { tier: subscription.tier });
+ },
+ });
+};
diff --git a/lib/__tests__/tierUpgrade.test.ts b/lib/__tests__/tierUpgrade.test.ts
new file mode 100644
index 00000000..5cc6c4a8
--- /dev/null
+++ b/lib/__tests__/tierUpgrade.test.ts
@@ -0,0 +1,310 @@
+import {
+ availableRoutes,
+ canAffordUpgrade,
+ formatFuse,
+ formatLockDuration,
+ formatMembershipDate,
+ formatMembershipDay,
+ formatUsd,
+ fuseForShares,
+ fuseSharesForAmount,
+ membershipDateLabel,
+ nextPurchasableTier,
+ remainingFuseForTier,
+} from '@/lib/tierUpgrade';
+import { RewardsTier, TierMembershipState, TierOffer, TierSubscriptionStatus } from '@/lib/types';
+
+const ONE = 10n ** 18n;
+
+const offer = (overrides: Partial = {}): TierOffer => ({
+ tier: RewardsTier.PRIME,
+ lockFuse: 50_000,
+ lockAvailable: true,
+ annualFeeUsd: 199,
+ cashAvailable: true,
+ held: false,
+ ...overrides,
+});
+
+const membership = (overrides: Partial = {}): TierMembershipState => ({
+ enabled: true,
+ pointsUnlockEnabled: false,
+ offers: [offer(), offer({ tier: RewardsTier.ULTRA, lockFuse: 400_000, annualFeeUsd: 0 })],
+ lock: {
+ enabled: true,
+ lockAddress: '0xlock',
+ durationDays: 365,
+ lockedFuse: 0,
+ lockedShares: '0',
+ unlockedTier: RewardsTier.CORE,
+ lockedSince: null,
+ nextUnlockAt: null,
+ nextUnlockFuse: 0,
+ maturedFuse: 0,
+ },
+ subscription: null,
+ currentTier: RewardsTier.CORE,
+ memberSince: null,
+ contracts: {
+ chainId: 122,
+ lockAddress: '0xlock',
+ subscriptionModuleAddress: '0xmodule',
+ shareTokenAddress: '0xshare',
+ billingTokenAddress: '0xusdc',
+ },
+ ...overrides,
+});
+
+describe('fuseSharesForAmount', () => {
+ it('converts at par', () => {
+ expect(fuseSharesForAmount(50_000, ONE)).toBe(50_000n * ONE);
+ });
+
+ it('needs fewer shares as the rate grows', () => {
+ // At 1.25 FUSE per share, 50,000 FUSE is 40,000 shares.
+ expect(fuseSharesForAmount(50_000, ONE + ONE / 4n)).toBe(40_000n * ONE);
+ });
+
+ /**
+ * The case that decides whether an upgrade works at all: the contract floors
+ * `shares * rate`, so the exact quotient can value a wei under the threshold
+ * and buy nothing.
+ */
+ it('rounds up so the locked position is never a wei short', () => {
+ const rate = ONE + 1n; // A rate that does not divide evenly.
+ const shares = fuseSharesForAmount(50_000, rate);
+
+ expect((shares * rate) / ONE).toBeGreaterThanOrEqual(50_000n * ONE);
+ });
+
+ it('is zero for an amount or a rate it cannot use', () => {
+ expect(fuseSharesForAmount(0, ONE)).toBe(0n);
+ expect(fuseSharesForAmount(-5, ONE)).toBe(0n);
+ expect(fuseSharesForAmount(Number.NaN, ONE)).toBe(0n);
+ expect(fuseSharesForAmount(50_000, 0n)).toBe(0n);
+ });
+
+ /**
+ * `toFixed` gives up and returns exponential notation at 1e21, which BigInt
+ * cannot parse. No tier is priced anywhere near that — this is a guard
+ * against a crash, not a case to support.
+ */
+ it('refuses an amount too large to convert rather than throwing', () => {
+ expect(fuseSharesForAmount(1e21, ONE)).toBe(0n);
+ expect(fuseSharesForAmount(Number.MAX_VALUE, ONE)).toBe(0n);
+ });
+});
+
+describe('fuseForShares', () => {
+ it('values a position the way the lock contract does', () => {
+ expect(fuseForShares(40_000n * ONE, ONE + ONE / 4n)).toBe(50_000);
+ });
+
+ it('is zero without shares or a rate', () => {
+ expect(fuseForShares(0n, ONE)).toBe(0);
+ expect(fuseForShares(ONE, 0n)).toBe(0);
+ });
+});
+
+describe('remainingFuseForTier', () => {
+ it('counts what is already locked', () => {
+ expect(remainingFuseForTier(offer({ lockFuse: 400_000 }), 50_000)).toBe(350_000);
+ });
+
+ it('is zero once the threshold is met', () => {
+ expect(remainingFuseForTier(offer(), 50_000)).toBe(0);
+ expect(remainingFuseForTier(offer(), 60_000)).toBe(0);
+ });
+});
+
+describe('availableRoutes', () => {
+ it('offers both when both are configured', () => {
+ expect(availableRoutes(offer())).toEqual(['cash', 'lock']);
+ });
+
+ /** Ultra is FUSE-only, which the design shows as a single full-width option. */
+ it('offers only the lock when a tier is not sold for cash', () => {
+ expect(availableRoutes(offer({ cashAvailable: false }))).toEqual(['lock']);
+ });
+
+ it('offers nothing for a tier that is not sold at all', () => {
+ expect(availableRoutes(offer({ cashAvailable: false, lockAvailable: false }))).toEqual([]);
+ expect(availableRoutes(undefined)).toEqual([]);
+ });
+});
+
+describe('canAffordUpgrade', () => {
+ const base = { offer: offer(), lockedFuse: 0, availableFuse: 0, availableUsdc: 0 };
+
+ it('needs the whole annual fee in USDC', () => {
+ expect(canAffordUpgrade({ ...base, route: 'cash', availableUsdc: 198.99 })).toBe(false);
+ expect(canAffordUpgrade({ ...base, route: 'cash', availableUsdc: 199 })).toBe(true);
+ });
+
+ it('needs only the remaining FUSE, not the whole threshold', () => {
+ expect(
+ canAffordUpgrade({
+ ...base,
+ route: 'lock',
+ offer: offer({ lockFuse: 400_000 }),
+ lockedFuse: 350_000,
+ availableFuse: 50_000,
+ }),
+ ).toBe(true);
+ });
+
+ it('cannot be afforded for cash when the tier is not sold for cash', () => {
+ expect(
+ canAffordUpgrade({
+ ...base,
+ route: 'cash',
+ offer: offer({ annualFeeUsd: 0 }),
+ availableUsdc: 1_000,
+ }),
+ ).toBe(false);
+ });
+});
+
+describe('nextPurchasableTier', () => {
+ it('offers the cheapest tier the user does not hold', () => {
+ expect(nextPurchasableTier(membership())).toBe(RewardsTier.PRIME);
+ });
+
+ it('moves on once that tier is held', () => {
+ expect(
+ nextPurchasableTier(
+ membership({
+ offers: [
+ offer({ held: true }),
+ offer({ tier: RewardsTier.ULTRA, lockFuse: 400_000, annualFeeUsd: 0 }),
+ ],
+ }),
+ ),
+ ).toBe(RewardsTier.ULTRA);
+ });
+
+ it('offers nothing once every tier is held', () => {
+ expect(
+ nextPurchasableTier(
+ membership({
+ offers: [offer({ held: true }), offer({ tier: RewardsTier.ULTRA, held: true })],
+ }),
+ ),
+ ).toBeNull();
+ });
+
+ it('skips a tier that is not currently sold', () => {
+ expect(
+ nextPurchasableTier(
+ membership({
+ offers: [
+ offer({ cashAvailable: false, lockAvailable: false }),
+ offer({ tier: RewardsTier.ULTRA }),
+ ],
+ }),
+ ),
+ ).toBe(RewardsTier.ULTRA);
+ });
+});
+
+describe('membershipDateLabel', () => {
+ const subscription = (overrides = {}) => ({
+ id: 'sub-1',
+ tier: RewardsTier.PRIME,
+ status: TierSubscriptionStatus.ACTIVE,
+ priceUsd: '199.00',
+ currentPeriodStart: '2026-09-10T00:00:00.000Z',
+ currentPeriodEnd: '2027-09-10T00:00:00.000Z',
+ nextChargeAt: '2027-09-10T00:00:00.000Z',
+ cancelAtPeriodEnd: false,
+ failedAttempts: 0,
+ pastDueSince: null,
+ graceEndsAt: null,
+ subscribedAt: '2026-09-10T00:00:00.000Z',
+ ...overrides,
+ });
+
+ it('is nothing at all without a membership', () => {
+ expect(membershipDateLabel(membership())).toBeNull();
+ });
+
+ it('counts down to the renewal while it is running', () => {
+ expect(membershipDateLabel(membership({ subscription: subscription() }))).toEqual({
+ label: 'Renews on',
+ date: '2027-09-10T00:00:00.000Z',
+ });
+ });
+
+ /** A cancelled membership has not ended — it has stopped renewing. */
+ it('counts down to the end once it will not renew', () => {
+ expect(
+ membershipDateLabel(
+ membership({
+ subscription: subscription({
+ status: TierSubscriptionStatus.CANCELLED,
+ cancelAtPeriodEnd: true,
+ nextChargeAt: null,
+ }),
+ }),
+ ),
+ ).toEqual({ label: 'Ends on', date: '2027-09-10T00:00:00.000Z' });
+ });
+
+ /** The grace deadline is the date that matters, and the only actionable one. */
+ it('counts down to the grace deadline while a renewal is failing', () => {
+ expect(
+ membershipDateLabel(
+ membership({
+ subscription: subscription({
+ status: TierSubscriptionStatus.PAST_DUE,
+ pastDueSince: '2027-09-10T00:00:00.000Z',
+ graceEndsAt: '2027-09-17T00:00:00.000Z',
+ }),
+ }),
+ ),
+ ).toEqual({ label: 'Payment due by', date: '2027-09-17T00:00:00.000Z' });
+ });
+
+ it('says nothing about a membership that is over', () => {
+ expect(
+ membershipDateLabel(
+ membership({ subscription: subscription({ status: TierSubscriptionStatus.EXPIRED }) }),
+ ),
+ ).toBeNull();
+ });
+});
+
+describe('formatting', () => {
+ /**
+ * Spelled out rather than localised: recent ICU writes September as "Sept",
+ * and Hermes, JSC and V8 do not ship the same ICU — so a localised date would
+ * read differently on iOS, Android and web for the same membership.
+ */
+ it('writes a date the way the pill does, on every runtime', () => {
+ expect(formatMembershipDate('2027-09-10T00:00:00.000Z')).toBe('10 Sep, 2027');
+ expect(formatMembershipDay('2026-09-14T00:00:00.000Z')).toBe('Sep 14, 2026');
+ });
+
+ it('writes nothing for a date it cannot read', () => {
+ expect(formatMembershipDate(null)).toBe('');
+ expect(formatMembershipDate('not a date')).toBe('');
+ expect(formatMembershipDay(undefined)).toBe('');
+ });
+
+ it('groups FUSE and drops the decimals', () => {
+ expect(formatFuse(50_000)).toBe('50,000');
+ expect(formatFuse(400_000.4)).toBe('400,000');
+ });
+
+ it('writes USD with cents', () => {
+ expect(formatUsd(199)).toBe('$199.00');
+ expect(formatUsd(2_400.5)).toBe('$2,400.50');
+ });
+
+ it('writes a lock term in months', () => {
+ expect(formatLockDuration(365)).toBe('12 months');
+ expect(formatLockDuration(180)).toBe('6 months');
+ expect(formatLockDuration(730)).toBe('2 years');
+ expect(formatLockDuration(0)).toBe('');
+ });
+});
diff --git a/lib/abis/SolidSubscriptionModule.ts b/lib/abis/SolidSubscriptionModule.ts
new file mode 100644
index 00000000..e005b614
--- /dev/null
+++ b/lib/abis/SolidSubscriptionModule.ts
@@ -0,0 +1,90 @@
+/**
+ * The slice of `SolidSubscriptionModule` (boring-vault `src/solid-rewards/`)
+ * the app uses.
+ *
+ * `subscribe` is the mandate: it says how much may be taken from this Safe, and
+ * how rarely. It is signed once, batched with the `enableModule` that makes the
+ * module usable, so one signature both grants the permission and bounds it.
+ *
+ * Note what the *charge* side does not take, and therefore what this signature
+ * cannot be turned into: the destination and the asset are immutable on the
+ * contract, so the only thing Solid can ever do with this permission is move up
+ * to `maxAmountPerPeriod` of USDC to the revenue treasury, no more often than
+ * `periodSeconds`. `cancel` withdraws it; `Safe.disableModule` withdraws it
+ * harder, and the Safe enforces that itself on the next block.
+ */
+export const SolidSubscriptionModule_ABI = [
+ {
+ inputs: [
+ { internalType: 'uint128', name: 'maxAmountPerPeriod', type: 'uint128' },
+ { internalType: 'uint64', name: 'periodSeconds', type: 'uint64' },
+ ],
+ name: 'subscribe',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'cancel',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'resume',
+ outputs: [],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [{ internalType: 'address', name: 'safe', type: 'address' }],
+ name: 'subscriptionOf',
+ outputs: [
+ {
+ components: [
+ { internalType: 'bool', name: 'registered', type: 'bool' },
+ { internalType: 'bool', name: 'paused', type: 'bool' },
+ { internalType: 'uint128', name: 'maxAmountPerPeriod', type: 'uint128' },
+ { internalType: 'uint64', name: 'periodSeconds', type: 'uint64' },
+ { internalType: 'uint64', name: 'subscribedAt', type: 'uint64' },
+ { internalType: 'uint64', name: 'lastChargedAt', type: 'uint64' },
+ { internalType: 'uint64', name: 'cancelledAt', type: 'uint64' },
+ ],
+ internalType: 'struct SolidSubscriptionModule.Subscription',
+ name: '',
+ type: 'tuple',
+ },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [{ internalType: 'address', name: 'safe', type: 'address' }],
+ name: 'isModuleEnabledOn',
+ outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [
+ { internalType: 'address', name: 'safe', type: 'address' },
+ { internalType: 'uint256', name: 'amount', type: 'uint256' },
+ ],
+ name: 'canCharge',
+ outputs: [
+ { internalType: 'bool', name: '', type: 'bool' },
+ { internalType: 'string', name: '', type: 'string' },
+ ],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'maxChargeAmount',
+ outputs: [{ internalType: 'uint128', name: '', type: 'uint128' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+] as const;
diff --git a/lib/abis/SolidTierLock.ts b/lib/abis/SolidTierLock.ts
new file mode 100644
index 00000000..814e906e
--- /dev/null
+++ b/lib/abis/SolidTierLock.ts
@@ -0,0 +1,60 @@
+/**
+ * The slice of `SolidTierLock` (boring-vault `src/solid-rewards/`) the app uses.
+ *
+ * `lock` is the only write, and it is made by the user's own Safe with an
+ * `approve` of the same amount batched in front of it. What the contract does
+ * with the shares afterwards is the reason this is safe to sign: there is no
+ * path that sends them anywhere but back to the account that locked them, and
+ * no admin function that can reach them at all.
+ */
+export const SolidTierLock_ABI = [
+ {
+ inputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }],
+ name: 'lock',
+ outputs: [{ internalType: 'uint256', name: 'index', type: 'uint256' }],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'withdraw',
+ outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }],
+ stateMutability: 'nonpayable',
+ type: 'function',
+ },
+ {
+ inputs: [{ internalType: 'address', name: 'account', type: 'address' }],
+ name: 'lockedSharesOf',
+ outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [{ internalType: 'address', name: 'account', type: 'address' }],
+ name: 'lockedAssetsOf',
+ outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [{ internalType: 'address', name: 'account', type: 'address' }],
+ name: 'maturedSharesOf',
+ outputs: [{ internalType: 'uint256', name: '', type: 'uint256' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'lockDuration',
+ outputs: [{ internalType: 'uint64', name: '', type: 'uint64' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+ {
+ inputs: [],
+ name: 'isPaused',
+ outputs: [{ internalType: 'bool', name: '', type: 'bool' }],
+ stateMutability: 'view',
+ type: 'function',
+ },
+] as const;
diff --git a/lib/api.ts b/lib/api.ts
index 32ac3257..bc9680a5 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -118,6 +118,7 @@ import {
ReferralSummary,
RegionInterestPayload,
ResumeRainForwardResponse,
+ RewardsTier,
RewardsUserData,
SavingsSummaryResponse,
SearchCoin,
@@ -132,6 +133,8 @@ import {
SyncActivitiesOptions,
SyncActivitiesResponse,
TierBenefits,
+ TierMembershipState,
+ TierSubscription,
ToCurrency,
TokenPriceByAddress,
TokenPriceUsd,
@@ -1994,6 +1997,126 @@ export const activateTierTrial = async (): Promise => {
return response.json();
};
+/**
+ * What each tier costs by either route, what the user has already locked or
+ * bought, and the addresses to build the transactions against.
+ *
+ * One request rather than three: the upgrade screen prices an offer against a
+ * balance, and fetching those apart is how a screen ends up showing one from a
+ * moment the other no longer belongs to.
+ */
+export const fetchTierMembership = async (): Promise => {
+ const jwt = getJWTToken();
+ const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/tier-membership`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ });
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+/**
+ * Report that a FUSE lock landed.
+ *
+ * Nothing about the lock is stored server-side — the chain has it — but this is
+ * what drops the cached position so the tier just bought shows up immediately,
+ * and what registers the user for the automatic return when the term is up.
+ */
+export const confirmTierLock = async (body: {
+ transactionHash: string;
+}): Promise => {
+ const jwt = getJWTToken();
+ const response = await fetch(
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/tier-membership/lock/confirm`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ },
+ );
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+/**
+ * Report that the subscribe transaction landed, and take the first payment.
+ *
+ * Fails when the first charge cannot be taken, with the module's own reason —
+ * "insufficient balance" rather than "something went wrong".
+ */
+export const confirmTierSubscription = async (body: {
+ tier: RewardsTier;
+ transactionHash: string;
+}): Promise => {
+ const jwt = getJWTToken();
+ const response = await fetch(
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/tier-membership/subscription/confirm`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ },
+ );
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+/** Stop a membership renewing. The tier runs to the end of the paid period. */
+export const cancelTierSubscription = async (body: {
+ reason?: string;
+}): Promise => {
+ const jwt = getJWTToken();
+ const response = await fetch(
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/tier-membership/subscription/cancel`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ body: JSON.stringify(body),
+ },
+ );
+ if (!response.ok) throw response;
+ return response.json();
+};
+
+/** Undo a cancellation while the paid period is still running. */
+export const resumeTierSubscription = async (): Promise => {
+ const jwt = getJWTToken();
+ const response = await fetch(
+ `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/tier-membership/subscription/resume`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ credentials: 'include',
+ },
+ );
+ if (!response.ok) throw response;
+ return response.json();
+};
+
export const mockFetchTierBenefits = async (): Promise => {
return Promise.resolve(MOCK_TIER_BENEFITS);
};
diff --git a/lib/tierUpgrade.ts b/lib/tierUpgrade.ts
new file mode 100644
index 00000000..8481285c
--- /dev/null
+++ b/lib/tierUpgrade.ts
@@ -0,0 +1,207 @@
+import { RewardsTier, TierMembershipState, TierOffer, TierSubscriptionStatus } from '@/lib/types';
+
+/** soFUSE shares and the accountant rate are both 18-decimal. */
+const SHARE_DECIMALS = 18n;
+const ONE_SHARE = 10n ** SHARE_DECIMALS;
+
+/** The two routes to a tier. */
+export type TierUpgradeRoute = 'cash' | 'lock';
+
+/**
+ * An amount of FUSE as wei, without going through a float multiplication.
+ *
+ * `amount * 1e18` is past `Number.MAX_SAFE_INTEGER` for anything above ~9 FUSE,
+ * and the rounding it silently does goes **down** — which is the one direction
+ * the share maths must never go. Going via the decimal string keeps it exact
+ * for the whole-FUSE thresholds the tiers are priced in.
+ */
+const toFuseWei = (amount: number): bigint => {
+ const fixed = amount.toFixed(18);
+
+ // `toFixed` gives up and returns exponential notation at 1e21, which BigInt
+ // cannot parse. No tier is priced anywhere near that, so this is a guard
+ // against a crash rather than a case to support.
+ if (fixed.includes('e') || fixed.includes('E')) return 0n;
+
+ const [whole, fraction = ''] = fixed.split('.');
+ return BigInt(whole) * ONE_SHARE + BigInt(fraction.padEnd(18, '0'));
+};
+
+/**
+ * The share count that is worth at least `fuseAmount` at `rate`.
+ *
+ * Rounded **up**, deliberately. The tier threshold is measured in FUSE and the
+ * contract values a position as `shares * rate / 1e18`, flooring it — so the
+ * exact quotient can come back a wei short and buy nothing. One extra share unit
+ * is a rounding error to the user and the difference between a tier and no tier.
+ *
+ * Returns 0 rather than throwing on a rate of 0: an unreadable rate is a screen
+ * that cannot offer the lock yet, not a crash.
+ */
+export const fuseSharesForAmount = (fuseAmount: number, rate: bigint): bigint => {
+ if (!Number.isFinite(fuseAmount) || fuseAmount <= 0 || rate <= 0n) return 0n;
+
+ const fuseWei = toFuseWei(fuseAmount);
+
+ return (fuseWei * ONE_SHARE + rate - 1n) / rate;
+};
+
+/**
+ * A share count valued in FUSE, the way the lock contract values it.
+ *
+ * Divided down to six decimal places *before* becoming a number: 5e22 is well
+ * past the precision a double carries, so converting first and dividing after
+ * reports 50,000 FUSE as 49,999.99999999999.
+ */
+export const fuseForShares = (shares: bigint, rate: bigint): number => {
+ if (shares <= 0n || rate <= 0n) return 0;
+
+ const DISPLAY_SCALE = 1_000_000n;
+ return Number((shares * rate) / (ONE_SHARE * (ONE_SHARE / DISPLAY_SCALE))) / 1e6;
+};
+
+/**
+ * The FUSE a user still has to commit to reach a tier.
+ *
+ * What is already locked counts, so topping up from Prime to Ultra asks for the
+ * difference rather than for the whole thing again.
+ */
+export const remainingFuseForTier = (offer: TierOffer, lockedFuse: number): number =>
+ Math.max(0, offer.lockFuse - Math.max(0, lockedFuse));
+
+/** The routes a tier can actually be bought by right now, cheapest intent first. */
+export const availableRoutes = (offer: TierOffer | undefined): TierUpgradeRoute[] => {
+ if (!offer) return [];
+
+ const routes: TierUpgradeRoute[] = [];
+ if (offer.cashAvailable) routes.push('cash');
+ if (offer.lockAvailable) routes.push('lock');
+
+ return routes;
+};
+
+/**
+ * Whether the user can complete the upgrade now, or has to top up first.
+ *
+ * This is what decides between the two CTAs in the design — "Top up" and
+ * "Upgrade" — so it is one function rather than a condition written twice.
+ */
+export const canAffordUpgrade = ({
+ route,
+ offer,
+ lockedFuse,
+ availableFuse,
+ availableUsdc,
+}: {
+ route: TierUpgradeRoute;
+ offer: TierOffer;
+ lockedFuse: number;
+ /** Unlocked soFUSE the Safe holds, in FUSE. */
+ availableFuse: number;
+ /** USDC the Safe holds. */
+ availableUsdc: number;
+}): boolean =>
+ route === 'cash'
+ ? offer.annualFeeUsd > 0 && availableUsdc >= offer.annualFeeUsd
+ : availableFuse >= remainingFuseForTier(offer, lockedFuse);
+
+/** The offer for one tier, or undefined when it is not sold. */
+export const findOffer = (
+ membership: TierMembershipState | undefined,
+ tier: RewardsTier,
+): TierOffer | undefined => membership?.offers.find(offer => offer.tier === tier);
+
+/**
+ * The tier the upgrade screen should open on.
+ *
+ * The cheapest tier the user does not already hold, so someone on Core is
+ * offered Prime and someone on Prime is offered Ultra. `null` once they hold
+ * everything, which is what hides the entry point rather than offering an
+ * upgrade to a tier they are already on.
+ */
+export const nextPurchasableTier = (
+ membership: TierMembershipState | undefined,
+): RewardsTier | null => {
+ const next = membership?.offers.find(
+ offer => !offer.held && (offer.lockAvailable || offer.cashAvailable),
+ );
+
+ return next?.tier ?? null;
+};
+
+/**
+ * What a membership's next date means, in the words the app uses for it.
+ *
+ * Four different dates live on one membership and they are not interchangeable:
+ * a renewal, a scheduled end, a grace deadline. Resolving them in one place is
+ * what keeps the upgrade screen's pill and the membership sheet from describing
+ * the same membership differently.
+ */
+export const membershipDateLabel = (
+ membership: TierMembershipState | undefined,
+): { label: string; date: string } | null => {
+ const subscription = membership?.subscription;
+ if (!subscription) return null;
+
+ if (subscription.status === TierSubscriptionStatus.PAST_DUE && subscription.graceEndsAt) {
+ return { label: 'Payment due by', date: subscription.graceEndsAt };
+ }
+
+ if (subscription.cancelAtPeriodEnd || subscription.status === TierSubscriptionStatus.CANCELLED) {
+ return { label: 'Ends on', date: subscription.currentPeriodEnd };
+ }
+
+ if (subscription.status === TierSubscriptionStatus.EXPIRED) return null;
+
+ return { label: 'Renews on', date: subscription.nextChargeAt ?? subscription.currentPeriodEnd };
+};
+
+/**
+ * Month names, spelled out rather than left to `toLocaleDateString`.
+ *
+ * The design says "Sep"; recent ICU says "Sept", and Hermes, JSC and V8 do not
+ * all ship the same ICU — so the same membership would be dated differently on
+ * iOS, Android and web. A table is three lines and cannot drift.
+ */
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
+
+/** A date as the design writes it in a pill: "10 Sep, 2027". */
+export const formatMembershipDate = (iso: string | null | undefined): string => {
+ if (!iso) return '';
+
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+
+ return `${date.getDate()} ${MONTHS[date.getMonth()]}, ${date.getFullYear()}`;
+};
+
+/** A date as the membership sheet writes it: "Sep 14, 2026". */
+export const formatMembershipDay = (iso: string | null | undefined): string => {
+ if (!iso) return '';
+
+ const date = new Date(iso);
+ if (Number.isNaN(date.getTime())) return '';
+
+ return `${MONTHS[date.getMonth()]} ${date.getDate()}, ${date.getFullYear()}`;
+};
+
+/** A whole number of FUSE, grouped: "50,000". */
+export const formatFuse = (amount: number): string =>
+ amount.toLocaleString('en-US', { maximumFractionDigits: 0 });
+
+/** A USD figure with cents: "$199.00". */
+export const formatUsd = (amount: number): string =>
+ `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
+
+/** A lock term in the words the design uses: "12 months". */
+export const formatLockDuration = (days: number): string => {
+ if (days <= 0) return '';
+
+ const months = Math.round(days / 30.4375);
+ if (months >= 12 && months % 12 === 0) {
+ const years = months / 12;
+ return years === 1 ? '12 months' : `${years} years`;
+ }
+
+ return months <= 1 ? `${days} days` : `${months} months`;
+};
diff --git a/lib/types.ts b/lib/types.ts
index 6bc26ead..8b08a4cc 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -3084,3 +3084,106 @@ export interface StoreReviewPromptedResponse {
reviewPromptCount: number;
lastReviewPromptedAt: string;
}
+
+// ============================================================================
+// Tier membership (rewards v3)
+// ============================================================================
+
+/**
+ * A membership's lifecycle, as the backend reports it.
+ *
+ * `past_due` is the one worth reading carefully: a renewal charge has failed but
+ * the user still holds their tier, because the usual cause is a Safe briefly
+ * short of USDC. `cancelled` likewise still grants — it means "will not renew",
+ * not "has ended". In both cases the date is what closes the membership, which
+ * is why every screen counts down to a date rather than reading the status.
+ */
+export enum TierSubscriptionStatus {
+ ACTIVE = 'active',
+ PAST_DUE = 'past_due',
+ CANCELLED = 'cancelled',
+ EXPIRED = 'expired',
+}
+
+/** A tier the user can buy, and what it costs by each route. */
+export interface TierOffer {
+ tier: RewardsTier;
+ /** FUSE that must be locked to hold this tier. */
+ lockFuse: number;
+ /** Whether the lock route can be taken right now. */
+ lockAvailable: boolean;
+ /** Annual fee in USD. 0 when this tier is not sold for cash. */
+ annualFeeUsd: number;
+ /** Whether the annual-fee route can be taken right now. */
+ cashAvailable: boolean;
+ /** Whether the user already holds this tier. */
+ held: boolean;
+}
+
+/** The user's locked FUSE position. */
+export interface TierLockState {
+ enabled: boolean;
+ lockAddress: string | null;
+ /** Term a new lock carries, in days. */
+ durationDays: number;
+ lockedFuse: number;
+ /** Raw locked soFUSE shares, as a decimal string. */
+ lockedShares: string;
+ unlockedTier: RewardsTier;
+ /** When the first lock was taken — the membership's "member since". */
+ lockedSince: string | null;
+ /** When the soonest still-running tranche comes free. */
+ nextUnlockAt: string | null;
+ nextUnlockFuse: number;
+ /** FUSE whose term is up, awaiting the automatic return. */
+ maturedFuse: number;
+}
+
+/** The user's paid membership, if they have one. */
+export interface TierSubscription {
+ id: string;
+ tier: RewardsTier;
+ status: TierSubscriptionStatus;
+ priceUsd: string;
+ currentPeriodStart: string;
+ currentPeriodEnd: string;
+ /** When the next renewal is attempted. Null once it will not renew. */
+ nextChargeAt: string | null;
+ cancelAtPeriodEnd: boolean;
+ failedAttempts: number;
+ pastDueSince: string | null;
+ /** When a past-due membership finally loses its tier. */
+ graceEndsAt: string | null;
+ subscribedAt: string;
+}
+
+/** Addresses the app builds the upgrade transactions against. */
+export interface TierMembershipContracts {
+ chainId: number;
+ lockAddress: string | null;
+ subscriptionModuleAddress: string | null;
+ /** The soFUSE share token that is locked. */
+ shareTokenAddress: string | null;
+ /** The USDC the membership is billed in. */
+ billingTokenAddress: string | null;
+}
+
+/**
+ * Everything the upgrade screens need, in one payload.
+ *
+ * One call rather than three because the screen has to show a price, a balance
+ * and what the user already holds at the same instant — fetched apart, those
+ * drift and the user is shown an offer that is no longer true.
+ */
+export interface TierMembershipState {
+ /** Whether either purchase route is available. False hides the upgrade UI. */
+ enabled: boolean;
+ /** Whether points still unlock a tier. False in v3. */
+ pointsUnlockEnabled: boolean;
+ offers: TierOffer[];
+ lock: TierLockState;
+ subscription: TierSubscription | null;
+ currentTier: RewardsTier;
+ memberSince: string | null;
+ contracts: TierMembershipContracts;
+}