diff --git a/app/(protected)/_layout.tsx b/app/(protected)/_layout.tsx index aa994380c..845449c9f 100644 --- a/app/(protected)/_layout.tsx +++ b/app/(protected)/_layout.tsx @@ -20,6 +20,7 @@ import { import CardHeroOverlay from '@/components/Card/NewCardDetails/CardHeroOverlay'; import { SidebarShell } from '@/components/Navbar/Sidebar'; import NotificationPermissionSheet from '@/components/Notifications/NotificationPermissionSheet'; +import TierUpgradeModalProvider from '@/components/Rewards/NewRewards/UpgradeTier/TierUpgradeModalProvider'; import RewardsUpgradeFeedback from '@/components/Rewards/RewardsUpgradeFeedback'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; @@ -299,6 +300,7 @@ export default function ProtectedLayout() { screens only — stays dormant/null otherwise). Mounted above the whole navigator so the card can fly across the home → card/details change. */} + + + + + diff --git a/assets/images/rewards-tiers/upgrade-card-star.svg b/assets/images/rewards-tiers/upgrade-card-star.svg new file mode 100644 index 000000000..34530fb6c --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/rewards-tiers/upgrade-card-subscription.svg b/assets/images/rewards-tiers/upgrade-card-subscription.svg new file mode 100644 index 000000000..db7832187 --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-subscription.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/rewards-tiers/upgrade-card-yield.svg b/assets/images/rewards-tiers/upgrade-card-yield.svg new file mode 100644 index 000000000..f96e35f0d --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-yield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/components/Activity/CardActivityRow.tsx b/components/Activity/CardActivityRow.tsx index 0880404fe..e297ee2e7 100644 --- a/components/Activity/CardActivityRow.tsx +++ b/components/Activity/CardActivityRow.tsx @@ -71,11 +71,8 @@ const CardActivityRow = ({ const formattedTimestamp = formatActivityTimestamp(getCardTransactionTimestamp(transaction)); - const cashbackLabel = cashbackInfo?.isEscrowed - ? 'Cashback (Escrowed)' - : cashbackInfo?.isPending - ? 'Cashback (Pending)' - : 'Cashback'; + const cashbackLabel = + cashbackInfo?.isPending && !cashbackInfo.isEscrowed ? 'Cashback (Pending)' : 'Cashback'; return ( {usdEquivalent && {usdEquivalent}} {/* Green whether or not the payout has landed: it is money coming back - either way, and the label on the left already carries the escrow - status, so the figure does not repeat it. */} + either way. The receipt shows when escrowed cashback releases. */} {cashbackInfo?.amount && ( {cashbackInfo.amount} )} diff --git a/components/DepositOption/DepositTypeSelection.tsx b/components/DepositOption/DepositTypeSelection.tsx index ff3f819f9..c984c4a90 100644 --- a/components/DepositOption/DepositTypeSelection.tsx +++ b/components/DepositOption/DepositTypeSelection.tsx @@ -6,9 +6,11 @@ import { Text } from '@/components/ui/text'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useDimension } from '@/hooks/useDimension'; +import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; import { getCryptoDepositEntry } from '@/lib/walletDepositFlow'; +import { useSwapState } from '@/store/swapStore'; import { useDepositStore } from '@/store/useDepositStore'; import { DEPOSIT_CASH_CLUSTER_ICONS, DEPOSIT_CASH_CURRENCY_COUNT } from './DepositCashOptions'; @@ -27,18 +29,25 @@ type DepositTypeSelectionProps = { }; /** - * "Deposit with" — the first step of the wallet deposit flow, and only a fork: - * crypto sent to the deposit address, or cash through a bank or local ramp. - * Each branch owns the methods underneath it (see `DepositCryptoOptions` and - * `DepositCashOptions`), which is what keeps this short enough to be a drawer on - * a phone rather than the full-height sheet the later steps use. + * "Deposit with" — the first step of the wallet deposit flow. The upgrade + * Top up entry also offers Buy FUSE, while ordinary deposits keep the two + * funding methods: crypto or cash. */ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { const { isDesktop, isScreenMedium } = useDimension(); const setModal = useDepositStore(state => state.setModal); + const resetDepositFlow = useDepositStore(state => state.resetDepositFlow); + const upgradeTopUp = useDepositStore(state => state.upgradeTopUp); + const { selectToken: selectSavingsFundToken } = useSavingsFundFlow(); + const openBuyFuse = useSwapState(state => state.actions.openBuyFuse); const handleCryptoPress = () => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'crypto' }); + if (upgradeTopUp?.depositToSavings) { + useDepositStore.getState().setSavingsFundIntent('savings'); + selectSavingsFundToken('WFUSE'); + return; + } setModal(getCryptoDepositEntry(isDesktop)); }; @@ -47,6 +56,15 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); }; + const handleBuyFusePress = () => { + if (!upgradeTopUp) return; + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'buy_fuse' }); + const { tier, depositToSavings } = upgradeTopUp; + resetDepositFlow(); + // The deposit drawer must finish leaving before the swap dialog mounts. + setTimeout(() => openBuyFuse(tier, { depositToSavings }), 200); + }; + return ( {/* The drawer carries its own header: a grab handle (small screens, where it @@ -77,6 +95,14 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { subtitle="Transfer from your bank account or with local ramps" onPress={handleCashPress} /> + {upgradeTopUp ? ( + } + title="Buy FUSE" + subtitle="Buy with USDC on Fuse" + onPress={handleBuyFusePress} + /> + ) : null} + + ); +}; + +const styles = StyleSheet.create({ + glow: { + opacity: 0.35, + position: 'absolute', + }, +}); + +export default TierMembershipSheetContent; diff --git a/components/Rewards/NewRewards/TierSparkleIcon.tsx b/components/Rewards/NewRewards/TierSparkleIcon.tsx new file mode 100644 index 000000000..9e567a1a2 --- /dev/null +++ b/components/Rewards/NewRewards/TierSparkleIcon.tsx @@ -0,0 +1,38 @@ +import { Image } from 'expo-image'; + +import { CoreTierSparkle } from '@/assets/images/rewards-tiers/core-tier-icons'; +import { RewardsTier } from '@/lib/types'; + +/** + * Prime and Ultra ship as artwork; Core is a stroked outline, so it is an SVG. + * The split is the design's, not an accident of tooling. + */ +const TIER_SPARKLE: Record = { + [RewardsTier.PRIME]: require('@/assets/images/rewards-tiers/prime-tier-sparkle.png'), + [RewardsTier.ULTRA]: require('@/assets/images/rewards-tiers/ultra-tier-sparkle.png'), +}; + +/** The size the benefits screen draws it beside a tier name. */ +const DEFAULT_SIZE = 20; + +/** + * The tier's mark, as a still. + * + * The counterpart to `TierHero/TierStar`, which plays the animated WebP. That + * one earns its weight at 235px on a screen built around it; beside a heading + * it is a 26 MB bundle and a decoder running for a 28px glyph nobody is looking + * at. Anywhere the mark is a label rather than the subject, it is this. + */ +const TierSparkleIcon = ({ tier, size = DEFAULT_SIZE }: { tier: RewardsTier; size?: number }) => + tier === RewardsTier.CORE ? ( + + ) : ( + + ); + +export default TierSparkleIcon; diff --git a/components/Rewards/NewRewards/TierStatsBand.tsx b/components/Rewards/NewRewards/TierStatsBand.tsx index 569d88fe2..efba78290 100644 --- a/components/Rewards/NewRewards/TierStatsBand.tsx +++ b/components/Rewards/NewRewards/TierStatsBand.tsx @@ -5,6 +5,8 @@ import { LinearGradient } from 'expo-linear-gradient'; import { Text } from '@/components/ui/text'; import { getAsset } from '@/lib/assets'; +import { TOP_RIGHT_WASH } from './tierGradients'; + /** Total height of the band. Its foot is tucked behind the perks card below. */ export const TIER_STATS_BAND_HEIGHT = 137; /** How much of that foot the perks card covers. */ @@ -12,6 +14,12 @@ export const TIER_STATS_BAND_TUCK = 41; /** The part that stays on screen — the stats sit here so nothing is tucked away. */ const VISIBLE_HEIGHT = TIER_STATS_BAND_HEIGHT - TIER_STATS_BAND_TUCK; +/** + * Green off the top-right corner, dissolving to the near-black screen at the + * bottom-left — the direction every v3 wash runs (see `tierGradients`). The + * band has no background of its own, so the faded end is the page showing + * through. + */ const BAND_GRADIENT = ['rgba(148, 242, 127, 0.28)', 'rgba(148, 242, 127, 0.05)'] as const; const DIVIDER_COLOR = 'rgba(255, 255, 255, 0.25)'; @@ -64,7 +72,7 @@ const TierStatsBand = ({ stats }: { stats: readonly TierStat[] }) => ( style={{ height: TIER_STATS_BAND_HEIGHT }} pointerEvents="none" > - + diff --git a/components/Rewards/NewRewards/UpgradeTier/LockTokenSelector.tsx b/components/Rewards/NewRewards/UpgradeTier/LockTokenSelector.tsx new file mode 100644 index 000000000..55618e790 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/LockTokenSelector.tsx @@ -0,0 +1,77 @@ +import { View } from 'react-native'; +import { fuse } from 'viem/chains'; + +import { Text } from '@/components/ui/text'; +import { WalletTokenList } from '@/components/WalletTokenSelector'; +import { useNativePriceUsd } from '@/hooks/useNativePriceUsd'; +import { useTierMembership, useTierUpgradeChainState } from '@/hooks/useTierMembership'; +import { lockAssetFromRow, lockTokenRows } from '@/lib/lockTokenRows'; +import { type LockPaymentBalances } from '@/lib/tierLockPayment'; +import { useTierUpgradeStore } from '@/store/useTierUpgradeStore'; + +/** + * Which token pays for the lock. + * + * The app's own token list, the one withdraw and deposit use, so a row here + * behaves like a row anywhere else: same icon, same balance on the right, same + * green outline on the one in force. + * + * Deliberately not built from the wallet's balances, which is the one thing + * that list is normally handed. This picker offers exactly what the lock can be + * paid with, and a list filtered by what the user holds would hide the two they + * do not hold yet — which are the ones they are most likely to be about to top + * up, and the reason they opened the picker. + */ +const LockTokenSelector = () => { + const { data: membership } = useTierMembership(); + const { data: chain } = useTierUpgradeChainState(membership?.contracts); + const selected = useTierUpgradeStore(state => state.lockAsset); + const setLockAsset = useTierUpgradeStore(state => state.setLockAsset); + // Shares its cache key with the savings screens, so this costs a read rather + // than a request on any session that has looked at Savings. + const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', true); + + const balances: LockPaymentBalances = { + sofuse: chain?.fuse ?? 0, + native: chain?.nativeFuse ?? 0, + wrapped: chain?.wrappedFuse ?? 0, + }; + + // Three rows off a balance poll: not worth memoising, and a stale array here + // would be a picker showing a balance the step behind it has already updated. + const tokens = lockTokenRows({ + balances, + zapAvailable: Boolean(membership?.contracts.lockZapAddress), + chainId: membership?.contracts.chainId ?? fuse.id, + addresses: { + wrappedNativeAddress: membership?.contracts.wrappedNativeAddress, + shareTokenAddress: membership?.contracts.shareTokenAddress, + }, + fusePriceUsd, + }); + + const selectedToken = tokens.find(token => token.contractTickerSymbol === selected) ?? null; + + return ( + + {/* Says the unit once, so the three rows below do not each have to. All + of them are quoted in FUSE, including soFUSE, which is shown at what + the vault would return for it. */} + + Pick what to lock. Balances are shown in FUSE. + + + { + const asset = lockAssetFromRow(token); + if (asset) setLockAsset(asset); + }} + /> + + ); +}; + +export default LockTokenSelector; diff --git a/components/Rewards/NewRewards/UpgradeTier/TierDetailRow.tsx b/components/Rewards/NewRewards/UpgradeTier/TierDetailRow.tsx new file mode 100644 index 000000000..e0ca01202 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/TierDetailRow.tsx @@ -0,0 +1,80 @@ +import { type ReactNode } from 'react'; +import { View } from 'react-native'; + +import TooltipPopover from '@/components/Tooltip'; +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +interface TierDetailRowProps { + label: string; + /** The value, as text. Omitted only when `children` renders it instead. */ + value?: string; + valueClassName?: string; + /** The smaller grey line under the value, e.g. a FUSE amount's USD worth. */ + secondaryValue?: string; + /** A short explanation opened from the question mark beside the label. */ + tooltip?: string; + tooltipAnalyticsContext?: string; + /** 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, + valueClassName, + secondaryValue, + tooltip, + tooltipAnalyticsContext, + withDivider = false, + children, +}: TierDetailRowProps) => ( + + + + {label} + {tooltip ? ( + + ) : null} + + + {children ?? ( + + + {value} + + {secondaryValue ? ( + + {secondaryValue} + + ) : null} + + )} + + + {withDivider ? : null} + +); + +export default TierDetailRow; diff --git a/components/Rewards/NewRewards/UpgradeTier/TierUpgradeModalProvider.tsx b/components/Rewards/NewRewards/UpgradeTier/TierUpgradeModalProvider.tsx new file mode 100644 index 000000000..85d756b10 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/TierUpgradeModalProvider.tsx @@ -0,0 +1,69 @@ +import { useShallow } from 'zustand/react/shallow'; + +import ResponsiveModal from '@/components/ResponsiveModal'; +import { TIER_UPGRADE_MODAL } from '@/constants/modals'; +import { isTierUpgradeOpen, useTierUpgradeStore } from '@/store/useTierUpgradeStore'; + +import LockTokenSelector from './LockTokenSelector'; +import UpgradeReviewContent from './UpgradeReviewContent'; +import UpgradeTierContent from './UpgradeTierContent'; + +/** + * The tier upgrade flow, as one modal mounted once at the app root. + * + * Both steps used to be routes, pushed from the rewards screen, the benefits + * pager and Earn. Every one of those is a page the user is in the middle of + * reading, and taking the whole screen away to price an upgrade they may not + * buy is a worse trade than a card over the top of it — which is how the rest + * of the app asks for a decision. Closing the modal puts them back exactly + * where they were, with no back stack to unwind. + * + * Mounted at the root rather than per screen, like `UnstakeModalProvider`: two + * of those screens can be mounted at once, and a modal each is two overlays. + */ +const TierUpgradeModalProvider = () => { + const { currentModal, previousModal } = useTierUpgradeStore( + useShallow(state => ({ + currentModal: state.currentModal, + previousModal: state.previousModal, + })), + ); + const isOpen = useTierUpgradeStore(isTierUpgradeOpen); + const back = useTierUpgradeStore(state => state.back); + const close = useTierUpgradeStore(state => state.close); + + const isReview = currentModal.name === TIER_UPGRADE_MODAL.OPEN_REVIEW.name; + const isTokenSelector = currentModal.name === TIER_UPGRADE_MODAL.OPEN_TOKEN_SELECTOR.name; + + return ( + { + if (!open) close(); + }} + trigger={null} + title={isTokenSelector ? 'Select token' : 'Upgrade tier'} + // Both the review and the picker are steps, not destinations: the back + // arrow returns to the offer rather than closing the flow, which is the + // one thing a user who has just read a one-year commitment — or opened + // the picker to compare balances — is most likely to want. + showBackButton={isReview || isTokenSelector} + onBackPress={back} + contentKey={currentModal.name} + contentClassName="md:max-w-[480px]" + gradientHeader={!isReview && !isTokenSelector} + > + {isReview ? ( + + ) : isTokenSelector ? ( + + ) : ( + + )} + + ); +}; + +export default TierUpgradeModalProvider; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeReviewContent.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeReviewContent.tsx new file mode 100644 index 000000000..2a770ec98 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeReviewContent.tsx @@ -0,0 +1,264 @@ +import { useState } from 'react'; +import { View } from 'react-native'; +import { KeyRound } from 'lucide-react-native'; + +import Loading from '@/components/Loading'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { + useLockFuseForTier, + useSubscribeToTier, + useTierMembership, + useTierUpgradeChainState, +} from '@/hooks/useTierMembership'; +import { track } from '@/lib/analytics'; +import { + canPayLockWith, + LOCK_PAYMENT_LABEL, + type LockPaymentBalances, + resolveLockAsset, +} from '@/lib/tierLockPayment'; +import { getTierDisplayName } from '@/lib/tierNames'; +import { + canAffordUpgrade, + findOffer, + formatFuse, + formatLockDuration, + formatUsd, + remainingFuseForTier, +} from '@/lib/tierUpgrade'; +import { useTierUpgradeStore } from '@/store/useTierUpgradeStore'; + +import TierDetailRow from './TierDetailRow'; + +/** + * The last step before the signature: exactly what is being committed, and for + * how long. + * + * Separate from the offer step 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 step where it is one of four lines rather than + * one row among a price, a balance and a toggle. + */ +const UpgradeReviewContent = () => { + 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 = useTierUpgradeStore(state => state.tier); + const route = useTierUpgradeStore(state => state.route) ?? 'lock'; + const chosenAsset = useTierUpgradeStore(state => state.lockAsset); + const back = useTierUpgradeStore(state => state.back); + const close = useTierUpgradeStore(state => state.close); + + const offer = tier ? findOffer(membership, tier) : undefined; + + if (isLoading) return ; + + // Reached with a tier that is no longer on offer — bought in another tab, or + // the route was switched off while the modal was open. Sending them back to + // the offer step 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; + + /** + * The token the user picked, re-checked against what is still on offer. + * + * Not re-decided here: the step before this named a token and priced the + * upgrade against that balance, and quietly signing a different one would + * spend a balance the user was keeping. `resolveLockAsset` only steps in when + * the choice has become impossible — the zap switched off between steps — + * where the alternative is a transaction that can only revert. + */ + const paymentAsset = resolveLockAsset(chosenAsset, Boolean(membership.contracts.lockZapAddress)); + + /** + * Re-checked here rather than trusted from the step before. + * + * The balances are polled every few seconds and the user may have been + * reading the term for a while — a concurrent spend from another tab or + * device is enough to make the offer step's answer stale, and the only thing + * downstream of a wrong answer is an on-chain revert. + * + * `chain === undefined` is "the balances have not loaded", which blocks the + * button without claiming the user is short: saying so while the balance is + * still being read would be wrong about half the time. + */ + const balances: LockPaymentBalances | undefined = chain + ? { sofuse: chain.fuse, native: chain.nativeFuse, wrapped: chain.wrappedFuse } + : undefined; + + // The cash route has the same hole and closes it the same way: the offer step + // checked the USDC balance, and that check is minutes old by the time anyone + // presses this. + const canPay = + chain !== undefined && + balances !== undefined && + (route === 'lock' + ? canPayLockWith(paymentAsset, remainingFuse, balances) + : canAffordUpgrade({ + route, + offer, + lockedFuse: membership.lock.lockedFuse, + availableFuse: chain.fuse, + availableUsdc: chain.usdcAmount, + })); + + // Only once we have actually read the balances. Nothing to say while they load. + const shortOfFunds = chain !== undefined && !canPay; + + 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 }); + + // The button is disabled while this is false, but the balances move + // under it on a poll — and the only thing downstream of a stale yes is + // a revert the user pays gas for. + if (!balances || !canPayLockWith(paymentAsset, remainingFuse, balances)) { + throw new Error( + `Your ${LOCK_PAYMENT_LABEL[paymentAsset]} balance no longer covers this upgrade.`, + ); + } + + const result = await lockFuse({ + tier, + asset: paymentAsset, + fuseAmount: remainingFuse, + // The rate read alongside the balances this step 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, + zapAddress: membership.contracts.lockZapAddress, + wrappedNativeAddress: membership.contracts.wrappedNativeAddress, + }); + + // Null is the passkey prompt being dismissed — a decision, not a + // failure, so the modal stays where the user left it. + if (result) close(); + return; + } + + if (!membership.contracts.subscriptionModuleAddress) { + throw new Error('Memberships are not available right now.'); + } + // A tier with no price is not sold for cash. The route switch should + // never have offered this, so reaching it means the offer changed under + // the user between steps — say so rather than charging them nothing. + if (offer.annualFeeUsd === null) { + throw new Error('This tier cannot be bought with an annual fee. Lock FUSE to hold it.'); + } + 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) close(); + } catch (error) { + setFailure(error instanceof Error ? error.message : 'Something went wrong. Try again.'); + } + }; + + return ( + + + + + {route === 'lock' ? ( + <> + {/* The token first, as on the step before: the last screen before + the signature has to say which balance this comes out of, and + it is the line the rest of the rows are priced against. */} + + {/* Priced in FUSE whichever token pays: the tier's threshold is a + FUSE figure, and soFUSE is quoted at the FUSE it is worth. */} + + + + + ) : ( + <> + + + + + )} + + + + {route === 'lock' + ? `${ + paymentAsset === 'soFUSE' + ? 'Your FUSE in Savings is locked.' + : `Your ${LOCK_PAYMENT_LABEL[paymentAsset]} is moved into Savings and locked, in one transaction.` + } It unlocks 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} + ) : shortOfFunds ? ( + + {route === 'lock' + ? `Your ${LOCK_PAYMENT_LABEL[paymentAsset]} balance no longer covers this upgrade. Go back to top up or pick another token.` + : 'Your balance no longer covers this upgrade. Go back to top up.'} + + ) : null} + + + + ); +}; + +export default UpgradeReviewContent; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx new file mode 100644 index 000000000..d9d06678d --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeRouteSwitch.tsx @@ -0,0 +1,71 @@ +import { Pressable, StyleSheet, View } from 'react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +import { ROUTE_LABEL, upgradeRouteLabelClass } from './upgradeRouteLabel'; + +import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; + +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 soFUSE-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', + )} + > + {/* Metrics and weight in the style, colour in both layers — see + `upgradeRouteLabelClass` for why the class is not optional. The + same shape TierSwitcher uses for the tier tabs beside this. */} + + {ROUTE_LABEL[route]} + + + ); + })} + + ); +}; + +const LABEL_BASE = { fontSize: 16, lineHeight: 20 } as const; + +const styles = StyleSheet.create({ + label: { ...LABEL_BASE, color: '#FFFFFF', fontFamily: 'MonaSans_500Medium' }, + selectedLabel: { ...LABEL_BASE, color: '#000000', fontFamily: 'MonaSans_600SemiBold' }, +}); + +export default UpgradeRouteSwitch; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierCashbackIcon.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierCashbackIcon.tsx new file mode 100644 index 000000000..e5230dc3b --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierCashbackIcon.tsx @@ -0,0 +1,22 @@ +import Svg, { G, Path } from 'react-native-svg'; + +import { IconBadge } from '@/components/Rewards/NewRewards/tierBenefitIcons'; + +const DIAMOND_PATH = + 'M2.21072 2.02656C2.76301 1.27852 3.03914 0.904502 3.44885 0.702247C3.85856 0.5 4.34009 0.5 5.30316 0.5H9.00001H12.6969C13.6599 0.5 14.1415 0.5 14.5512 0.702247C14.9609 0.904502 15.237 1.27852 15.7893 2.02656L16.3529 2.78986C17.1294 3.84178 17.5178 4.36774 17.4994 4.96068C17.481 5.55361 17.0607 6.05694 16.22 7.0637L11.2413 13.0263C10.5493 13.8551 10.2032 14.2696 9.79951 14.4485C9.29284 14.6732 8.70718 14.6732 8.2005 14.4485C7.79679 14.2696 7.45073 13.8551 6.75871 13.0263L1.77999 7.0637C0.939365 6.05694 0.519058 5.55361 0.500628 4.96068C0.482207 4.36774 0.870522 3.84178 1.64716 2.78986L2.21072 2.02656Z'; +const SLASH_PATH = + 'M9.16321 8.50004L8.69135 9.17109C8.41149 9.56911 8.44107 10.0942 8.76408 10.4617L10.5788 12.5264'; + +/** The completed cashback glyph, shown without the former two-second loop. */ +const UpgradeTierCashbackIcon = () => ( + + + + + + + + +); + +export default UpgradeTierCashbackIcon; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx new file mode 100644 index 000000000..813f30577 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx @@ -0,0 +1,372 @@ +import { useEffect, useMemo } from 'react'; +import { Linking, Pressable, View } from 'react-native'; +import { formatUnits } from 'viem'; +import { fuse } from 'viem/chains'; + +import Loading from '@/components/Loading'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { WalletTokenButton } from '@/components/WalletTokenSelector'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useMaxAPY } from '@/hooks/useAnalytics'; +import { useNativePriceUsd } from '@/hooks/useNativePriceUsd'; +import { useTierBenefits } from '@/hooks/useRewards'; +import { useTierMembership, useTierUpgradeChainState } from '@/hooks/useTierMembership'; +import { track } from '@/lib/analytics'; +import { lockTokenRow } from '@/lib/lockTokenRows'; +import { + availableLockAssets, + canPayLockWith, + lockPaymentBalance, + resolveLockAsset, +} from '@/lib/tierLockPayment'; +import { getTierDisplayName } from '@/lib/tierNames'; +import { + availableRoutes, + canAffordUpgrade, + findOffer, + formatFuse, + formatFuseHeld, + formatFuseShortfall, + formatLockDuration, + formatMembershipDate, + formatUsd, + formatUsdHeld, + membershipDateLabel, + nextPurchasableTier, + remainingFuseForTier, +} from '@/lib/tierUpgrade'; +import { RewardsTier, VaultType } from '@/lib/types'; +import { useDepositStore } from '@/store/useDepositStore'; +import { useTierUpgradeStore } from '@/store/useTierUpgradeStore'; + +import TierDetailRow from './TierDetailRow'; +import { findTierBenefits, resolveTierUpgradeBenefits } from './tierUpgradeBenefits'; +import UpgradeRouteSwitch from './UpgradeRouteSwitch'; +import UpgradeTierHeroCard from './UpgradeTierHeroCard'; + +/** Where "Learn more" and "How to earn points?" send the user. */ +export const MEMBERSHIP_HELP_URL = + 'https://support.solid.xyz/en/articles/15613716-solid-rewards-terms-and-conditions'; + +/** + * Buying a tier: what it costs by each route, what the user has, and one action. + * + * This step 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 is a separate + * step: committing soFUSE for a year is worth a second look at the term before + * the passkey prompt. + * + * Which tier is being bought is decided before this opens — by the tab on the + * benefits pager, or by the "Join … Club" card. There is deliberately no tier + * switch here: two switchers for one choice is two places to read a different + * answer from, and the benefits pager is the one with the tier's case laid out + * beside it. + */ +const UpgradeTierContent = () => { + const { data: membership, isLoading } = useTierMembership(); + const { data: tierBenefits } = useTierBenefits(); + const { data: chain } = useTierUpgradeChainState(membership?.contracts); + const { maxAPY: fuseSavingsApy } = useMaxAPY(VaultType.FUSE); + const requestedTier = useTierUpgradeStore(state => state.tier); + const route = useTierUpgradeStore(state => state.route); + const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', route === 'lock'); + const setRoute = useTierUpgradeStore(state => state.setRoute); + const review = useTierUpgradeStore(state => state.review); + const selectToken = useTierUpgradeStore(state => state.selectToken); + const chosenAsset = useTierUpgradeStore(state => state.lockAsset); + const close = useTierUpgradeStore(state => state.close); + + // The tier the opener named, else the cheapest they do not already hold — so + // an "Upgrade" press from a screen that has no idea which tier it means still + // lands somewhere useful. + const tier = requestedTier ?? nextPurchasableTier(membership); + + 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]); + + // 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 modal 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, setRoute]); + + 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 this is the wrong thing to be looking at. + if (!membership?.enabled || !tier || !offer || !route) { + return ( + + ); + } + + // Opened on a tier the user already holds — a stale entry point, or one they + // bought in another tab. Offering it would price an upgrade at nothing and + // hand the review step zero soFUSE to lock. + if (offer.held) { + return ( + + ); + } + + const benefits = resolveTierUpgradeBenefits(findTierBenefits(tierBenefits, tier)); + const dateLabel = membershipDateLabel(membership); + const remainingFuse = remainingFuseForTier(offer, membership.lock.lockedFuse); + const availableUsdc = chain?.usdcAmount ?? 0; + + /** + * Everything the lock could be paid from, all of it priced in FUSE. + * + * soFUSE goes through the vault's rate; native FUSE and WFUSE do not, because + * the wrapper holds exactly its own total supply and one WFUSE is one FUSE. + */ + const balances = { + sofuse: chain?.fuse ?? 0, + native: chain?.nativeFuse ?? 0, + wrapped: chain?.wrappedFuse ?? 0, + }; + // FUSE and WFUSE are only payable because the zap deposits and locks in one + // transaction. Without it the only thing that can be locked is Savings, and + // the picker collapses to that one row. + const zapAvailable = Boolean(membership.contracts.lockZapAddress); + const paymentAsset = resolveLockAsset(chosenAsset, zapAvailable); + const paymentBalanceFuse = lockPaymentBalance(paymentAsset, balances); + // soFUSE is a yield-bearing share token. Its FUSE value decides affordability, + // but a row named "Balance" must show the number of tokens the user holds. + const paymentTokenBalance = + paymentAsset === 'soFUSE' ? Number(formatUnits(chain?.shares ?? 0n, 18)) : paymentBalanceFuse; + // Built by the same helper the picker's rows come from, so the chip here and + // the row the user tapped there cannot disagree about a ticker or an icon. + // No price: this chip has no dollar column to fill. + const paymentToken = lockTokenRow({ + asset: paymentAsset, + balances, + chainId: membership.contracts.chainId, + addresses: membership.contracts, + }); + // Measured against the token that is actually paying, which is the one the + // row above the shortfall names. + const shortfallFuse = Math.max(0, remainingFuse - paymentBalanceFuse); + + const affordable = + route === 'cash' + ? canAffordUpgrade({ + route, + offer, + lockedFuse: membership.lock.lockedFuse, + availableFuse: balances.sofuse, + availableUsdc, + }) + : canPayLockWith(paymentAsset, remainingFuse, balances); + + const handleRoute = (next: typeof route) => { + 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. + * + * Which flow depends on which balance is short, and the two are not + * interchangeable: the savings direct deposit mints share tokens, while the + * "Add funds" sheet funds the wallet, where a token stays the token that was + * sent. Sending a user short of native FUSE to the savings flow would hand + * them more soFUSE and leave the row they were looking at unmoved, with + * nothing on screen explaining why. + * + * Closes this modal before opening the funding one. Both are dialogs, and a + * dialog opened over a dialog leaves two overlays and a back gesture that + * dismisses the wrong one. + */ + const handleTopUp = () => { + const depositToSavings = route === 'lock' && paymentAsset === 'soFUSE'; + close(); + + // The upgrade modal's native exit animation takes 180ms. Opening a second + // portal during that same render can leave Android with a black surface. + // Let the first dialog finish leaving before mounting the deposit flow. + setTimeout(() => { + const depositStore = useDepositStore.getState(); + depositStore.resetDepositFlow(); + // A cash upgrade is paid in USDC; buying FUSE would not fill that balance. + if (route === 'lock') depositStore.setUpgradeTopUp({ tier, depositToSavings }); + depositStore.setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_TYPE); + }, 200); + }; + + const handleReview = () => { + track(TRACKING_EVENTS.TIER_UPGRADE_REVIEWED, { tier, route }); + review(); + }; + + return ( + + + + {tier !== RewardsTier.ULTRA && ( + <> + + Upgrade tier with + + + + + )} + + + {route === 'cash' ? ( + <> + + + + ) : ( + <> + {/* The token comes first because it decides every number under + it — which balance the cost is checked against, the shortfall, + and the calls the Safe signs. + + Static when there is only one token on offer, which is what + this shows while the zap is not deployed: a picker with one row + is a label, and a chevron that opens nothing is a promise the + screen cannot keep. */} + + + + 0 ? formatUsd(remainingFuse * fusePriceUsd) : undefined + } + withDivider + /> + 0 ? `${fuseSavingsApy.toFixed(1)}%` : '—'} + valueClassName="text-[#94F27F]" + tooltip="The FUSE Savings APY is based on recent vault performance and can change. It does not include a tier yield boost." + tooltipAnalyticsContext="tier_upgrade_fuse_apy" + withDivider + /> + + {/* Keep the balance next to the funding action: the CTA below flips + between "Review upgrade" and "Top up" based on this amount. */} + + + )} + + + + {route === 'cash' + ? `Upgrade to the ${offer.tier === RewardsTier.ULTRA ? 'Ultra' : 'Prime'} tier with\nan annual fee. ` + : paymentAsset === 'soFUSE' + ? `Locks the FUSE already in your Savings for ${formatLockDuration(membership.lock.durationDays)} to hold the tier. It keeps earning while it is locked. ` + : `Deposits your ${paymentAsset} into Savings and locks it for ${formatLockDuration(membership.lock.durationDays)} to hold the tier, in one transaction. It keeps earning while it is locked. `} + void Linking.openURL(MEMBERSHIP_HELP_URL)} + className="text-[15px] leading-5 text-white underline" + > + Learn more + + + + + + {/* Only when it changes the decision: the user has some FUSE but not + enough, which "Top up" alone does not describe. + + `shortfallFuse > 0` is what stops the line this screen used to end on + — "0 FUSE short — add more to Savings" — which appeared whenever the + gap was under half a unit, told the user nothing, and pointed at a + top-up of nothing. Affordable hides it outright; a sub-unit gap is + rounded up to the 1 FUSE that would actually clear it. */} + {!affordable && route === 'lock' && paymentBalanceFuse > 0 && shortfallFuse > 0 ? ( + + + {formatFuseShortfall(shortfallFuse)} {paymentAsset} short — top up or pick another token + + + ) : null} + + ); +}; + +/** There is nothing to buy, and saying so beats an empty offer. */ +const UpgradeUnavailable = ({ message, onClose }: { message: string; onClose: () => void }) => ( + + {message} + + +); + +export default UpgradeTierContent; diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx new file mode 100644 index 000000000..474502545 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierHeroCard.tsx @@ -0,0 +1,143 @@ +import { StyleSheet, View } from 'react-native'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; + +import { IconBadge } from '@/components/Rewards/NewRewards/tierBenefitIcons'; +import { Text } from '@/components/ui/text'; +import { getTierIcon } from '@/constants/rewards'; +import { getTierDisplayName } from '@/lib/tierNames'; +import { RewardsTier } from '@/lib/types'; + +import UpgradeTierCashbackIcon from './UpgradeTierCashbackIcon'; + +import type { TierUpgradeBenefit } from './tierUpgradeBenefits'; + +const BENEFIT_ICON_SIZE = 33; +const cardTexture = require('@/assets/images/rewards-welcome-texture.png'); +const tierStar = require('@/assets/images/rewards-tiers/upgrade-card-star.svg'); +const yieldIcon = require('@/assets/images/rewards-tiers/upgrade-card-yield.svg'); +const subscriptionIcon = require('@/assets/images/rewards-tiers/upgrade-card-subscription.svg'); +const cashbackCapIcon = require('@/assets/images/rewards-tiers/upgrade-card-cap.svg'); + +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. */ +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/__tests__/upgradeRouteSwitch.test.ts b/components/Rewards/NewRewards/UpgradeTier/__tests__/upgradeRouteSwitch.test.ts new file mode 100644 index 000000000..0c2c20b92 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/__tests__/upgradeRouteSwitch.test.ts @@ -0,0 +1,56 @@ +import { clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +import { + ROUTE_LABEL, + upgradeRouteLabelClass, +} from '@/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel'; + +/** Exactly what `components/ui/text` puts in front of every label's own class. */ +const TEXT_BASE = 'text-foreground web:select-text'; + +/** + * The class list that actually reaches the element. + * + * `twMerge(clsx(…))` is the body of `cn` — inlined rather than imported because + * `@/lib/utils` reaches the whole wagmi/viem stack and this is a string test. + */ +const resolved = (labelClass: string) => twMerge(clsx([TEXT_BASE, labelClass])); + +describe('upgradeRouteLabelClass', () => { + it('paints the selected label black, for the white pill it sits on', () => { + expect(resolved(upgradeRouteLabelClass(true))).toContain('text-black'); + }); + + it('paints the unselected label white, for the dark track it sits on', () => { + expect(resolved(upgradeRouteLabelClass(false))).toContain('text-white'); + }); + + /** + * The bug, three times over. + * + * `text-foreground` is white and `ui/text` prepends it to every label. It + * survives the merge unless the label brings a colour of its own — and it + * beats a colour set through the `style` prop on web, because + * react-native-web inserts its stylesheet as `head.firstChild`, below the + * Tailwind sheet. A selected label that leaves it standing is white on white. + */ + it('leaves no inherited white for the selected label to lose to', () => { + expect(resolved(upgradeRouteLabelClass(true))).not.toContain('text-foreground'); + }); + + it('is what a style-only label could not do — the shape that shipped white', () => { + expect(resolved('text-[16px] leading-5 font-semibold')).toContain('text-foreground'); + }); +}); + +describe('ROUTE_LABEL', () => { + /** + * The tab names the unit, not the token. Every tier figure is a FUSE amount, + * and the tab used to say "soFUSE" back when that was the only thing the lock + * would take — which is now one of three choices made by the picker inside it. + */ + it('names the route in the unit the tier is priced in', () => { + expect(ROUTE_LABEL.lock).toBe('Locked FUSE'); + }); +}); diff --git a/components/Rewards/NewRewards/UpgradeTier/tierUpgradeBenefits.ts b/components/Rewards/NewRewards/UpgradeTier/tierUpgradeBenefits.ts new file mode 100644 index 000000000..afb304946 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/tierUpgradeBenefits.ts @@ -0,0 +1,58 @@ +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 + .replace(/\s*\n\s*/g, ' ') + .replace(/^Up to\s+/i, '') + .replace(/\bmonthly$/, 'monthly cashback cap'), + } + : 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/UpgradeTier/upgradeRouteLabel.ts b/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts new file mode 100644 index 000000000..a531d2f99 --- /dev/null +++ b/components/Rewards/NewRewards/UpgradeTier/upgradeRouteLabel.ts @@ -0,0 +1,31 @@ +import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; + +/** What each payment route is called on the switch. */ +export const ROUTE_LABEL: Record = { + cash: 'Cash', + // FUSE, not soFUSE. The tab used to name the share token because that was the + // only thing the lock accepted; the zap now takes native FUSE and WFUSE too, + // and all three are priced in FUSE — so the token belongs in the picker + // inside the tab, not in the tab's own name. + lock: 'Locked FUSE', +}; + +/** + * The colour class for a segment label — its own module because this one class + * is the whole bug the switch has now been fixed for three times, and because a + * test for it should not have to mount a component to read it. + * + * `ui/text` renders `cn('text-foreground web:select-text', …, className, …)`, + * and `text-foreground` is white. tailwind-merge drops it only when the label + * brings a text colour of its own, so a label styled purely through `style` + * leaves the class list as literally `text-foreground web:select-text`. On web + * that class then wins outright, because react-native-web inserts its own + * stylesheet as `head.firstChild` — below the Tailwind sheet — so a Tailwind + * colour beats the atomic class generated from the `style` prop. Black on white + * came out white on white: an empty pill. + * + * The colour is therefore declared in both layers, saying the same thing. + * Whichever one the platform resolves last, the label is legible. + */ +export const upgradeRouteLabelClass = (isSelected: boolean) => + isSelected ? 'text-black' : 'text-white'; diff --git a/components/Rewards/NewRewards/__tests__/tierUpgradeCta.test.ts b/components/Rewards/NewRewards/__tests__/tierUpgradeCta.test.ts new file mode 100644 index 000000000..29d68854b --- /dev/null +++ b/components/Rewards/NewRewards/__tests__/tierUpgradeCta.test.ts @@ -0,0 +1,176 @@ +import { tierUpgradeCta } from '@/components/Rewards/NewRewards/tierUpgradeCta'; +import { RewardsTier } from '@/lib/types'; + +const { CORE, PRIME, ULTRA } = RewardsTier; + +const cta = (overrides: Partial[0]> = {}) => + tierUpgradeCta({ + selectedTier: PRIME, + currentTier: CORE, + unavailable: false, + pending: false, + routes: [], + ...overrides, + }); + +describe('tierUpgradeCta', () => { + /** + * The bug. v3 sells Prime for a lock or an annual fee, and the footer only + * ever asked the v2 skip-the-line config — which is off — so the one tier the + * user could actually buy read "Tier unavailable". + */ + it('offers a tier that v3 is selling, with v2 switched off', () => { + expect( + cta({ + routes: ['cash', 'lock'], + annualFeeUsd: 250, + lockFuse: 50_000, + remainingFuse: undefined, + }), + ).toEqual({ + label: 'Upgrade', + subtitle: '250$/Year or 50k FUSE to upgrade', + enabled: true, + held: false, + }); + }); + + it('names the only route when a tier is sold just one way', () => { + expect(cta({ selectedTier: ULTRA, routes: ['lock'], lockFuse: 400_000 }).subtitle).toBe( + 'Deposit 400k FUSE to upgrade', + ); + expect(cta({ routes: ['cash'] }).subtitle).toBe('Pay the annual fee to hold the tier'); + }); + + it('reads the routes as a set, not in the order they arrived', () => { + expect(cta({ routes: ['lock', 'cash'] }).subtitle).toBe( + cta({ routes: ['cash', 'lock'] }).subtitle, + ); + }); + + it('still offers a v2 unlock when that is the program running', () => { + expect(cta({ routes: [], remainingFuse: 50_000 })).toEqual({ + label: 'Upgrade', + subtitle: 'Deposit 50,000 FUSE to Savings to upgrade', + enabled: true, + held: false, + }); + }); + + /** + * Says why, rather than borrowing the held cases' "Your membership benefits" + * — which told a user looking at a tier they do not have that they were + * looking at their own. And `held` stays false: the footer has something to + * say here, so it stays on screen. + */ + it('is unavailable, and says so, when neither program sells the tier', () => { + expect(cta({ routes: [], remainingFuse: undefined })).toEqual({ + label: 'Tier unavailable', + subtitle: 'Not on sale right now', + enabled: false, + held: false, + }); + }); + + it('never offers the tier the user is already on, however it is sold', () => { + expect(cta({ selectedTier: CORE, routes: ['lock'] }).enabled).toBe(false); + expect(cta({ selectedTier: CORE, routes: ['lock'] }).label).toBe('Current tier'); + }); + + it('never offers a tier below the one held', () => { + expect(cta({ selectedTier: PRIME, currentTier: ULTRA, routes: ['lock'] })).toEqual({ + label: 'Included in your tier', + subtitle: 'Your membership benefits', + enabled: false, + held: true, + }); + }); + + /** An upgrade already signed for, waiting on the backend to agree. */ + it('holds the button while a purchase is being reconciled', () => { + expect(cta({ pending: true, routes: ['cash', 'lock'] })).toEqual({ + label: 'Confirming tier…', + subtitle: 'Savings changed. Waiting for rewards confirmation.', + enabled: false, + held: false, + }); + }); + + /** + * What the footer hides on. The old rule was `selectedTier !== currentTier`, + * which hid it on exactly one tab — so a user on Ultra swiping to Prime, or + * to Core, got a full-width dead button over benefits they already had. + */ + describe('held', () => { + it('covers the tier the user is on and every tier under it', () => { + expect(cta({ selectedTier: ULTRA, currentTier: ULTRA }).held).toBe(true); + expect(cta({ selectedTier: PRIME, currentTier: ULTRA }).held).toBe(true); + expect(cta({ selectedTier: CORE, currentTier: ULTRA }).held).toBe(true); + expect(cta({ selectedTier: CORE, currentTier: PRIME }).held).toBe(true); + }); + + /** A tier still to be bought keeps its footer, sellable or not. */ + it('is false for a tier above the one held', () => { + expect(cta({ selectedTier: ULTRA, currentTier: PRIME, routes: ['lock'] }).held).toBe(false); + expect(cta({ selectedTier: ULTRA, currentTier: PRIME, routes: [] }).held).toBe(false); + expect(cta({ selectedTier: PRIME, currentTier: CORE, routes: ['cash'] }).held).toBe(false); + }); + + /** + * Not knowing is not the same as having it. Hiding the footer on a + * membership that has not loaded would pull it out from under the user's + * thumb and put it back a moment later. + */ + it('is false while the membership is unknown or reconciling', () => { + expect(cta({ selectedTier: ULTRA, currentTier: ULTRA, unavailable: true }).held).toBe(false); + expect(cta({ selectedTier: ULTRA, currentTier: undefined }).held).toBe(false); + expect(cta({ selectedTier: PRIME, currentTier: CORE, pending: true }).held).toBe(false); + }); + + /** + * `currentTier` and `offerHeld` come from two endpoints with two caches, so + * one can be a beat behind the other. Offering a tier someone already holds + * is the worse mistake, so either source is enough to stop. + */ + it('takes the membership offer at its word when the rewards tier lags', () => { + expect(cta({ selectedTier: ULTRA, currentTier: CORE, offerHeld: true }).held).toBe(true); + expect(cta({ selectedTier: ULTRA, currentTier: undefined, offerHeld: true }).held).toBe(true); + expect(cta({ selectedTier: ULTRA, unavailable: true, offerHeld: true }).held).toBe(true); + }); + + /** ...and the other way round, when the membership is the stale one. */ + it('still hides on the rewards tier when the offer has not caught up', () => { + expect(cta({ selectedTier: PRIME, currentTier: ULTRA, offerHeld: false }).held).toBe(true); + }); + + /** + * A reconciliation that has landed is over. Holding "Confirming tier…" on + * screen after the membership agrees is a slower way of saying nothing. + */ + it('beats a pending reconciliation once the tier has actually landed', () => { + const result = cta({ selectedTier: PRIME, currentTier: PRIME, pending: true }); + + expect(result.held).toBe(true); + expect(result.label).not.toBe('Confirming tier…'); + }); + }); + + it('fails closed while the current tier is unknown', () => { + expect(cta({ unavailable: true, routes: ['cash', 'lock'] })).toEqual({ + label: 'Tier unavailable', + subtitle: 'Checking your current membership', + enabled: false, + held: false, + }); + expect(cta({ currentTier: undefined, routes: ['cash', 'lock'] }).enabled).toBe(false); + }); + + it('offers a retry when loading the current membership failed', () => { + expect(cta({ unavailable: true, loadFailed: true, routes: ['cash', 'lock'] })).toEqual({ + label: 'Try again', + subtitle: 'Unable to load your membership', + enabled: true, + held: false, + }); + }); +}); diff --git a/components/Rewards/NewRewards/skipTheLine.ts b/components/Rewards/NewRewards/skipTheLine.ts index f8903fdc9..908125881 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 000000000..f77c9d541 --- /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/components/Rewards/NewRewards/tierGradients.ts b/components/Rewards/NewRewards/tierGradients.ts new file mode 100644 index 000000000..31a9bee48 --- /dev/null +++ b/components/Rewards/NewRewards/tierGradients.ts @@ -0,0 +1,26 @@ +/** + * Which way a rewards v3 wash runs. + * + * One rule, stated once, because three cards had drifted onto three diagonals: + * a v3 gradient starts at the **top-right** corner, where the tint is, and ends + * **bottom-left**, where it goes black. + * + * The "Join … Club" banner is the deliberate exception and runs the other + * diagonal — bottom-right to top-left — which puts its heading on the dark + * corner and the tint under the benefit chips. + * + * Spread these into `LinearGradient` rather than writing the points inline: + * `start={{ x: 1, y: 0 }}` and `start={{ x: 0, y: 0 }}` differ by one character + * and by ninety degrees, which is exactly how the upgrade card ended up lit + * from the wrong side. + */ +export const TOP_RIGHT_WASH = { + start: { x: 1, y: 0 }, + end: { x: 0, y: 1 }, +} as const; + +/** The "Join … Club" banner's exception: bottom-right to top-left. */ +export const BOTTOM_RIGHT_WASH = { + start: { x: 1, y: 1 }, + end: { x: 0, y: 0 }, +} as const; diff --git a/components/Rewards/NewRewards/tierUpgradeCta.ts b/components/Rewards/NewRewards/tierUpgradeCta.ts new file mode 100644 index 000000000..2eba21957 --- /dev/null +++ b/components/Rewards/NewRewards/tierUpgradeCta.ts @@ -0,0 +1,176 @@ +import { getTierAction } from '@/lib/rewardsUpgrade'; + +import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; +import type { RewardsTier } from '@/lib/types'; + +/** The button and the line above it, on the benefits pager's footer. */ +export interface TierUpgradeCta { + label: string; + subtitle: string; + /** Whether pressing it does anything. */ + enabled: boolean; + /** + * The user already has this tier — either it is the one they hold, or it is + * below it and comes with theirs. + * + * The footer hides outright on this rather than disabling itself. A greyed-out + * "Current tier" button is a call to action that says there is no action, put + * where the eye goes last on the page, and it costs the benefits list the + * bottom of the screen to say nothing. + */ + held: boolean; +} + +/** What the routes on offer are worth saying, above the button. */ +const ROUTE_SUBTITLE: Record = { + 'cash,lock': 'Lock FUSE or pay the annual fee', + lock: 'Lock FUSE to hold the tier', + cash: 'Pay the annual fee to hold the tier', +}; + +/** + * The benefits pager's upgrade CTA, resolved in one place. + * + * It reads two programs at once and has to pick the live one. Under v3 a tier + * is *sold* — a lock or an annual fee, priced by the membership endpoint — and + * the button opens the upgrade flow. Under v2 it is *unlocked* by a FUSE + * balance, and the button opens the deposit sheet. The screen ran only the + * second test, so with v3 switched on and the v2 skip-the-line config off, the + * only tier anyone could buy read "Tier unavailable" with the offer sitting one + * endpoint away. + * + * Purchasability is therefore checked against whichever program is answering: + * `routes` for v3, `remainingFuse` for v2. Neither is a fallback for the other + * — they are different products — but a tier that either one can sell is a tier + * the user can buy, and the button says so. + */ +export const tierUpgradeCta = ({ + selectedTier, + currentTier, + unavailable, + loadFailed = false, + pending, + routes, + annualFeeUsd, + lockFuse, + remainingFuse, + offerHeld = false, +}: { + selectedTier: RewardsTier; + currentTier?: RewardsTier; + /** No confirmed tier to compare against. */ + unavailable: boolean; + /** The rewards request settled unsuccessfully, so the CTA should offer recovery. */ + loadFailed?: boolean; + /** An upgrade is already in flight and being reconciled. */ + pending: boolean; + /** v3: the ways this tier is on sale right now. Empty when it is not. */ + routes: TierUpgradeRoute[]; + /** The membership offer's cash price, used in the combined-route subtitle. */ + annualFeeUsd?: number | null; + /** The membership offer's full lock requirement. */ + lockFuse?: number; + /** v2: the FUSE still needed to unlock it, or undefined when that is off. */ + remainingFuse?: number; + /** + * The membership endpoint's own verdict on whether this tier is already the + * user's (`offer.held`). + * + * A second opinion on purpose. `currentTier` comes from the rewards endpoint + * and this from the membership one; both are the backend's single answer, but + * they are two requests with two caches, so one can be a beat behind the + * other. Either saying "they have it" is enough to stop offering it, because + * offering a tier someone already holds is the worse of the two mistakes. + */ + offerHeld?: boolean; +}): TierUpgradeCta => { + const action = getTierAction(selectedTier, currentTier, unavailable); + const held = offerHeld || action === 'current' || action === 'included'; + + // Checked before `pending`: a reconciliation that has landed is over, and + // holding "Confirming tier…" on screen after the membership has agreed is + // just a slower way of saying nothing. + if (held) { + return { + // The footer hides on `held` rather than rendering either of these. They + // keep their copy because the flag is what the caller acts on, not the + // text, and so the labels are still right if it ever renders them again. + label: action === 'included' ? 'Included in your tier' : 'Current tier', + subtitle: 'Your membership benefits', + enabled: false, + held: true, + }; + } + + if (pending) { + return { + label: 'Confirming tier…', + subtitle: 'Savings changed. Waiting for rewards confirmation.', + enabled: false, + held: false, + }; + } + + if (loadFailed) { + return { + label: 'Try again', + subtitle: 'Unable to load your membership', + enabled: true, + held: false, + }; + } + + // Not "you have it" — we do not yet know what they have. The membership is + // still loading. The caller keeps the footer out of the layout during this + // state, so this is a fail-closed fallback rather than user-facing copy. + if (action === 'unavailable') { + return { + label: 'Tier unavailable', + subtitle: 'Checking your current membership', + enabled: false, + held: false, + }; + } + + if (routes.length > 0) { + const routeKey = [...routes].sort().join(','); + const fuseAmount = lockFuse != null && lockFuse > 0 ? `${lockFuse / 1000}k` : undefined; + const pricedSubtitle = + routeKey === 'cash,lock' && annualFeeUsd != null && annualFeeUsd > 0 && fuseAmount + ? `${annualFeeUsd}$/Year or ${fuseAmount} FUSE to upgrade` + : routeKey === 'lock' && fuseAmount + ? `Deposit ${fuseAmount} FUSE to upgrade` + : undefined; + + return { + label: 'Upgrade', + // Sorted so the key does not depend on the order the offer listed them in. + subtitle: pricedSubtitle ?? ROUTE_SUBTITLE[routeKey] ?? 'Upgrade to hold the tier', + enabled: true, + held: false, + }; + } + + if (remainingFuse !== undefined) { + return { + label: 'Upgrade', + subtitle: `Deposit ${remainingFuse.toLocaleString('en-US')} FUSE to Savings to upgrade`, + enabled: true, + held: false, + }; + } + + // A higher tier that neither program is selling: both routes are switched + // off, or this user is not eligible for it. + // + // The subtitle used to read "Your membership benefits", borrowed from the two + // held cases above — which told a user looking at a tier they do NOT have + // that they were looking at their own benefits. This is the one case where + // "Tier unavailable" is literally true, so it says why. + return { + label: 'Tier unavailable', + subtitle: 'Not on sale right now', + enabled: false, + held: false, + }; +}; diff --git a/components/Rewards/RewardsUpgradeFeedback.tsx b/components/Rewards/RewardsUpgradeFeedback.tsx index de8e45680..d5d38a76a 100644 --- a/components/Rewards/RewardsUpgradeFeedback.tsx +++ b/components/Rewards/RewardsUpgradeFeedback.tsx @@ -11,6 +11,7 @@ import { path } from '@/constants/path'; import { useRewardsUserData, useTierBenefits } from '@/hooks/useRewards'; import { REWARDS_RECONCILIATION_INTERVAL_MS } from '@/lib/rewardsUpgrade'; import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; +import { isTierUpgradeOpen, useTierUpgradeStore } from '@/store/useTierUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; /** @@ -26,8 +27,14 @@ import { useUserStore } from '@/store/useUserStore'; * * The words change with the route (see `upgradeCelebrationCopy`); the card does * not (Figma 25480:2355). + * + * The one place it does not draw is over the upgrade flow itself. Both are + * dialogs, so a celebration opening while the flow is up is two stacked + * overlays — and it announced a tier over a review step where the user had not + * bought anything yet. */ export default function RewardsUpgradeFeedback() { + const upgradeFlowOpen = useTierUpgradeStore(isTierUpgradeOpen); const userId = useUserStore(state => state.users.find(user => user.selected)?.userId); const state = useRewardsUpgradeStore(); const active = userId === state.userId; @@ -52,7 +59,9 @@ export default function RewardsUpgradeFeedback() { if (success) setShown(success); }, [success]); - if (!shown) return null; + // Held, not dropped: `success` survives in the store, so the card appears the + // moment the flow closes — which a completed purchase does itself. + if (!shown || upgradeFlowOpen) return null; const copy = upgradeCelebrationCopy(shown); diff --git a/components/Swap/BuyFuseScreen.tsx b/components/Swap/BuyFuseScreen.tsx index 3dee4c047..41794f489 100644 --- a/components/Swap/BuyFuseScreen.tsx +++ b/components/Swap/BuyFuseScreen.tsx @@ -10,6 +10,7 @@ import Max from '@/components/Max'; import RenderTokenIcon from '@/components/RenderTokenIcon'; import BuyFuseSavingsReview from '@/components/Swap/BuyFuseSavingsReview'; import BuyFuseTierCard from '@/components/Swap/BuyFuseTierCard'; +import BuyFuseUpgradeReview from '@/components/Swap/BuyFuseUpgradeReview'; import SwapButton from '@/components/Swap/SwapButton'; import SwapParams from '@/components/Swap/SwapParams'; import { Text } from '@/components/ui/text'; @@ -31,7 +32,12 @@ import { isHigherTier } from '@/lib/rewardsUpgrade'; import { RewardsTier } from '@/lib/types'; import { SwapField } from '@/lib/types/swap-field'; import { formatUSD } from '@/lib/utils'; -import { useDerivedSwapInfo, useSwapActionHandlers, useSwapState } from '@/store/swapStore'; +import { + type BuyFuseUpgradeContext, + useDerivedSwapInfo, + useSwapActionHandlers, + useSwapState, +} from '@/store/swapStore'; import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; import { openSupportDrawer } from '@/store/useSupportDrawerStore'; import { useUserStore } from '@/store/useUserStore'; @@ -50,6 +56,7 @@ const sanitizeAmount = (value: string) => { interface BuyFuseScreenProps { requestedTier?: RewardsTier; + upgradeContext?: BuyFuseUpgradeContext; } export default function BuyFuseScreen(props: BuyFuseScreenProps) { @@ -57,9 +64,9 @@ export default function BuyFuseScreen(props: BuyFuseScreenProps) { return ; } -function BuyFuseForAccount({ requestedTier }: BuyFuseScreenProps) { +function BuyFuseForAccount({ requestedTier, upgradeContext }: BuyFuseScreenProps) { const insets = useSafeAreaInsets(); - const { data: rewardsData, isError } = useRewardsUserData(); + const { data: rewardsData, isError } = useRewardsUserData({ enabled: !upgradeContext }); const confirmed = useRewardsUpgradeStore(state => state.confirmed); const pending = useRewardsUpgradeStore(state => !!state.pendingUntil && state.savingsConfirmed); const [purchased, setPurchased] = useState(false); @@ -110,7 +117,11 @@ function BuyFuseForAccount({ requestedTier }: BuyFuseScreenProps) { targets.find(target => target.tier === selectedTier) ?? targets[0] ?? undefined; const staleRequest = !!requestedTier && !!currentTier && !isHigherTier(requestedTier, currentTier); - const canBuy = !!currentTier && !isError && !pending && !staleRequest && !!selectedTarget; + // The new lock upgrade is priced by the membership flow, not the older + // Savings-based rewards targets. Its purchase must not wait for those targets. + const canBuy = upgradeContext + ? true + : !!currentTier && !isError && !pending && !staleRequest && !!selectedTarget; const selectedTrade = isVoltageTrade ? voltageTrade.trade : trade; const outputAmount = independentField === SwapField.OUTPUT ? parsedAmount : selectedTrade?.outputAmount; @@ -144,9 +155,11 @@ function BuyFuseForAccount({ requestedTier }: BuyFuseScreenProps) { (value: string) => { const sanitizedAmount = sanitizeAmount(value); onUserInput(SwapField.OUTPUT, sanitizedAmount); - setSelectedTier(getBuyFuseTierForAmount(targets, Number(sanitizedAmount) || 0)); + if (!upgradeContext) { + setSelectedTier(getBuyFuseTierForAmount(targets, Number(sanitizedAmount) || 0)); + } }, - [onUserInput, targets], + [onUserInput, targets, upgradeContext], ); const handleMax = useCallback(() => { @@ -224,7 +237,13 @@ function BuyFuseForAccount({ requestedTier }: BuyFuseScreenProps) { ); - if (purchased) return ; + if (purchased) { + return upgradeContext ? ( + + ) : ( + + ); + } return ( - {currentTier && !isError && (selectedTarget || currentTier === RewardsTier.ULTRA) ? ( - + {upgradeContext ? ( + <> + + {upgradeContext.depositToSavings + ? 'FUSE will arrive in your wallet. Deposit it to Savings before reviewing your tier upgrade.' + : 'FUSE will arrive in your wallet. Once the purchase confirms, return to your tier upgrade to review the lock.'} + + {upgradeContext.depositToSavings && MINIMUM_FUSE_SAVINGS_DEPOSIT > 0 ? ( + + Savings minimum deposit: {MINIMUM_FUSE_SAVINGS_DEPOSIT.toLocaleString('en-US')}{' '} + FUSE. + + ) : null} + ) : ( - - {isError - ? 'Unable to confirm your tier. Please try again.' - : 'Checking available tier upgrades…'} - + <> + {currentTier && !isError && (selectedTarget || currentTier === RewardsTier.ULTRA) ? ( + + ) : ( + + {isError + ? 'Unable to confirm your tier. Please try again.' + : 'Checking available tier upgrades…'} + + )} + + {pending + ? 'Your Savings deposit is confirmed. Waiting for rewards to confirm your tier.' + : staleRequest + ? 'Your tier has changed. Reopen Buy FUSE to choose a higher tier.' + : 'Step 1: Buy FUSE for your wallet. Step 2: Review and confirm a separate Savings deposit. Progress shown is an estimate until rewards confirms your tier.'} + + )} - - {pending - ? 'Your Savings deposit is confirmed. Waiting for rewards to confirm your tier.' - : staleRequest - ? 'Your tier has changed. Reopen Buy FUSE to choose a higher tier.' - : 'Step 1: Buy FUSE for your wallet. Step 2: Review and confirm a separate Savings deposit. Progress shown is an estimate until rewards confirms your tier.'} - - {MINIMUM_FUSE_SAVINGS_DEPOSIT > 0 && ( + {!upgradeContext && MINIMUM_FUSE_SAVINGS_DEPOSIT > 0 && ( Savings minimum deposit: {MINIMUM_FUSE_SAVINGS_DEPOSIT.toLocaleString('en-US')} FUSE. diff --git a/components/Swap/BuyFuseUpgradeReview.tsx b/components/Swap/BuyFuseUpgradeReview.tsx new file mode 100644 index 000000000..76d567245 --- /dev/null +++ b/components/Swap/BuyFuseUpgradeReview.tsx @@ -0,0 +1,69 @@ +import { ScrollView, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL, SWAP_MODAL } from '@/constants/modals'; +import { VAULTS } from '@/constants/vaults'; +import { RewardsTier } from '@/lib/types'; +import { type BuyFuseUpgradeContext, useSwapState } from '@/store/swapStore'; +import { useDepositStore } from '@/store/useDepositStore'; +import { useSavingStore } from '@/store/useSavingStore'; +import { useTierUpgradeStore } from '@/store/useTierUpgradeStore'; + +export default function BuyFuseUpgradeReview({ context }: { context: BuyFuseUpgradeContext }) { + const close = () => useSwapState.getState().actions.setModal(SWAP_MODAL.CLOSE); + + const continueUpgrade = () => { + const { tier } = context; + if (tier === RewardsTier.CORE) return; + close(); + setTimeout(() => { + const upgrade = useTierUpgradeStore.getState(); + upgrade.open(tier); + upgrade.setRoute('lock'); + // The purchase supplies native FUSE. The zap can deposit and lock it in + // the upgrade transaction, even if another token was selected earlier. + upgrade.setLockAsset('FUSE'); + }, 200); + }; + + const depositToSavings = () => { + const vaultIndex = VAULTS.findIndex(vault => vault.name === 'FUSE'); + if (vaultIndex < 0) return; + close(); + setTimeout(() => { + const deposit = useDepositStore.getState(); + deposit.resetDepositFlow(); + useSavingStore.getState().selectVaultForDeposit(vaultIndex); + deposit.setSrcChainId(122); + deposit.setPrincipalToken('FUSE'); + deposit.setDepositFromSolid(true); + deposit.setModal(DEPOSIT_MODAL.OPEN_FORM); + }, 200); + }; + + return ( + + + FUSE purchase confirmed + + Your FUSE is in your Solid wallet. Your tier upgrade is not complete yet. + + + {context.depositToSavings + ? 'Deposit the FUSE to Savings, then return to your upgrade to review and confirm the lock.' + : 'Return to your upgrade to review the amount and lock duration before confirming.'} + + + + + + ); +} diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index 4650028a5..c2e6b95bc 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -40,15 +40,17 @@ const SwapModalProvider = () => { })), ); - const { currentModal, previousModal, transaction, buyFuseTier, setModal } = useSwapState( - useShallow(state => ({ - currentModal: state.currentModal ?? SWAP_MODAL.CLOSE, - previousModal: state.previousModal ?? SWAP_MODAL.CLOSE, - transaction: state.transaction, - buyFuseTier: state.buyFuseTier, - setModal: state.actions.setModal, - })), - ); + const { currentModal, previousModal, transaction, buyFuseTier, buyFuseUpgrade, setModal } = + useSwapState( + useShallow(state => ({ + currentModal: state.currentModal ?? SWAP_MODAL.CLOSE, + previousModal: state.previousModal ?? SWAP_MODAL.CLOSE, + transaction: state.transaction, + buyFuseTier: state.buyFuseTier, + buyFuseUpgrade: state.buyFuseUpgrade, + setModal: state.actions.setModal, + })), + ); const isTransactionStatus = currentModal.name === SWAP_MODAL.OPEN_TRANSACTION_STATUS.name; const isBuyFuse = currentModal.name === SWAP_MODAL.OPEN_BUY_FUSE.name; @@ -102,7 +104,7 @@ const SwapModalProvider = () => { } if (isBuyFuse) { - return ; + return ; } if (isTransactionStatus) { @@ -141,13 +143,15 @@ const SwapModalProvider = () => { handleAcceptSwapDisclaimer, isBuyFuse, buyFuseTier, + buyFuseUpgrade, isTransactionStatus, transaction, handleTransactionStatusPress, ]); - // Swap is not available on iOS — never render the swap modal there. - if (Platform.OS === 'ios') { + // Regular Swap remains unavailable on iOS. Buy FUSE can open through its + // dedicated entry points and still passes the geo and disclaimer gates. + if (Platform.OS === 'ios' && !isBuyFuse) { return null; } diff --git a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx new file mode 100644 index 000000000..0ea617125 --- /dev/null +++ b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; + +import BuyFuseScreen from '@/components/Swap/BuyFuseScreen'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@cryptoalgebra/fuse-sdk', () => ({ + ADDRESS_ZERO: '0x0000000000000000000000000000000000000000', +})); +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +jest.mock('@/assets/images/messages', () => 'MessageCircle'); +jest.mock('@/components/Max', () => 'Max'); +jest.mock('@/components/RenderTokenIcon', () => 'RenderTokenIcon'); +jest.mock('@/components/Swap/BuyFuseSavingsReview', () => 'BuyFuseSavingsReview'); +jest.mock('@/components/Swap/BuyFuseTierCard', () => 'BuyFuseTierCard'); +jest.mock('@/components/Swap/BuyFuseUpgradeReview', () => 'BuyFuseUpgradeReview'); +jest.mock('@/components/Swap/SwapButton', () => 'SwapButton'); +jest.mock('@/components/Swap/SwapParams', () => 'SwapParams'); +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/constants/tokens', () => ({ STABLECOINS_TOKENS: { USDC: { address: '0x1234' } } })); +jest.mock('@/constants/vaults', () => ({ VAULTS: [{ name: 'FUSE', minimumAmount: '5000' }] })); +jest.mock('@/hooks/useRewards', () => ({ + useRewardsUserData: (options: unknown) => mockUseRewardsUserData(options), +})); +jest.mock('@/hooks/useUSDCValue', () => ({ useUSDCValue: () => ({ formatted: 0 }) })); +jest.mock('@/lib/buyFuseFunding', () => ({ formatBuyFuseFundingBalance: () => '0' })); +jest.mock('@/lib/buyFuseTiers', () => ({ + getBuyFuseProgress: jest.fn(), + getBuyFuseTierForAmount: jest.fn(), + getBuyFuseTierTargets: jest.fn(() => []), + getNextBuyFuseTier: jest.fn(), + hasReachedFuseTarget: jest.fn(), +})); +jest.mock('@/lib/getTokenIcon', () => ({ + __esModule: true, + default: () => ({ type: 'image', source: 'fuse' }), +})); +jest.mock('@/lib/utils', () => ({ formatUSD: () => '$0' })); +jest.mock('@/store/swapStore', () => ({ + useSwapState: (selector: (state: any) => unknown) => selector(mockSwapState), + useSwapActionHandlers: () => ({ onUserInput: jest.fn() }), + useDerivedSwapInfo: () => ({ + currencyBalances: { INPUT: undefined }, + parsedAmount: undefined, + toggledTrade: undefined, + voltageTrade: { trade: undefined }, + isVoltageTrade: false, + isVoltageTradeLoading: false, + tradeState: { state: 'NO_ROUTE_FOUND' }, + }), +})); +jest.mock('@/store/useRewardsUpgradeStore', () => ({ + useRewardsUpgradeStore: (selector: (state: any) => unknown) => + selector({ confirmed: undefined, pendingUntil: undefined, savingsConfirmed: false }), +})); +jest.mock('@/store/useSupportDrawerStore', () => ({ openSupportDrawer: jest.fn() })); +jest.mock('@/store/useUserStore', () => ({ + useUserStore: (selector: (state: any) => unknown) => + selector({ users: [{ selected: true, userId: 'test-user' }] }), +})); + +const mockUseRewardsUserData = jest.fn((_options?: unknown) => ({ + data: undefined, + isError: false, +})); +const mockSwapState = { + independentField: 'OUTPUT', + typedValue: '', + actions: { + selectCurrency: jest.fn(), + typeInput: jest.fn(), + resetForm: jest.fn(), + setModal: jest.fn(), + }, +}; + +it('shows the upgrade purchase without legacy tier loading or Savings instructions', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(mockUseRewardsUserData).toHaveBeenCalledWith({ enabled: false }); + expect(root.root.findAllByType('BuyFuseTierCard')).toHaveLength(0); + expect(root.root.findByType('SwapButton').props.disabled).toBe(false); + expect(JSON.stringify(root.toJSON())).not.toContain('Checking available tier upgrades'); + expect(JSON.stringify(root.toJSON())).not.toContain('Savings minimum deposit'); + + act(() => root.root.findByType('SwapButton').props.onConfirmed()); + expect(root.root.findAllByType('BuyFuseUpgradeReview')).toHaveLength(1); + act(() => root.unmount()); +}); + +it('keeps the legacy tier guidance for non-upgrade Buy FUSE entry points', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + mockUseRewardsUserData.mockClear(); + let root: any; + act(() => { + root = create(); + }); + + expect(mockUseRewardsUserData).toHaveBeenCalledWith({ enabled: true }); + expect(JSON.stringify(root.toJSON())).toContain('Checking available tier upgrades'); + expect(JSON.stringify(root.toJSON())).toContain('Savings minimum deposit'); + expect(root.root.findByType('SwapButton').props.disabled).toBe(true); + act(() => root.unmount()); +}); + +it('shows the Savings minimum only when the upgrade needs soFUSE', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(JSON.stringify(root.toJSON())).toContain('Savings minimum deposit'); + expect(JSON.stringify(root.toJSON())).not.toContain('Checking available tier upgrades'); + act(() => root.unmount()); +}); diff --git a/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx b/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx new file mode 100644 index 000000000..3e90cfca2 --- /dev/null +++ b/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import BuyFuseUpgradeReview from '@/components/Swap/BuyFuseUpgradeReview'; +import { DEPOSIT_MODAL, SWAP_MODAL } from '@/constants/modals'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@/components/ui/button', () => ({ Button: 'Button' })); +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/constants/vaults', () => ({ VAULTS: [{ name: 'USDC' }, { name: 'FUSE' }] })); +jest.mock('@/store/swapStore', () => ({ + useSwapState: { getState: () => ({ actions: { setModal: mockCloseSwap } }) }, +})); +jest.mock('@/store/useDepositStore', () => ({ useDepositStore: { getState: () => mockDeposit } })); +jest.mock('@/store/useSavingStore', () => ({ + useSavingStore: { getState: () => ({ selectVaultForDeposit: mockSelectVault }) }, +})); +jest.mock('@/store/useTierUpgradeStore', () => ({ + useTierUpgradeStore: { getState: () => mockUpgrade }, +})); + +const mockCloseSwap = jest.fn(); +const mockSelectVault = jest.fn(); +const mockDeposit = { + resetDepositFlow: jest.fn(), + setSrcChainId: jest.fn(), + setPrincipalToken: jest.fn(), + setDepositFromSolid: jest.fn(), + setModal: jest.fn(), +}; +const mockUpgrade = { + open: jest.fn(), + setRoute: jest.fn(), + setLockAsset: jest.fn(), +}; + +const render = (depositToSavings: boolean) => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => jest.useRealTimers()); + +it('returns a native FUSE purchase to the lock upgrade', () => { + const root = render(false); + act(() => root.root.findAllByType('Button')[0].props.onPress()); + expect(mockCloseSwap).toHaveBeenCalledWith(SWAP_MODAL.CLOSE); + expect(mockUpgrade.open).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(200)); + expect(mockUpgrade.open).toHaveBeenCalledWith(RewardsTier.ULTRA); + expect(mockUpgrade.setRoute).toHaveBeenCalledWith('lock'); + expect(mockUpgrade.setLockAsset).toHaveBeenCalledWith('FUSE'); + expect(mockDeposit.setModal).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); + +it('opens Savings funding only when the chosen upgrade asset was soFUSE', () => { + const root = render(true); + act(() => root.root.findAllByType('Button')[0].props.onPress()); + expect(mockCloseSwap).toHaveBeenCalledWith(SWAP_MODAL.CLOSE); + act(() => jest.advanceTimersByTime(200)); + expect(mockSelectVault).toHaveBeenCalledWith(1); + expect(mockDeposit.setPrincipalToken).toHaveBeenCalledWith('FUSE'); + expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_FORM); + expect(mockUpgrade.open).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); diff --git a/components/Swap/__tests__/SwapModalProvider.ios.test.tsx b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx new file mode 100644 index 000000000..f6ce11ad3 --- /dev/null +++ b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Platform } from 'react-native'; + +import SwapModalProvider from '@/components/Swap/SwapModalProvider'; +import { SWAP_MODAL } from '@/constants/modals'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('expo-router', () => ({ useRouter: () => ({ push: jest.fn() }) })); +jest.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })); +jest.mock('@/components/Compliance/ExchangeDisclaimer', () => 'ExchangeDisclaimer'); +jest.mock('@/components/Compliance/GeoRestrictionNotice', () => 'GeoRestrictionNotice'); +jest.mock('@/components/NeedHelp', () => 'NeedHelp'); +jest.mock('@/components/ResponsiveModal', () => 'ResponsiveModal'); +jest.mock('@/components/Swap/BuyFuseScreen', () => 'BuyFuseScreen'); +jest.mock('@/components/Swap/SwapButton', () => 'SwapButton'); +jest.mock('@/components/Swap/SwapPair', () => 'SwapPair'); +jest.mock('@/components/Swap/SwapParams', () => 'SwapParams'); +jest.mock('@/components/TransactionStatus', () => 'TransactionStatus'); +jest.mock('@/hooks/useGeoCompliance', () => ({ + __esModule: true, + default: () => ({ isSwapAvailable: mockSwapAvailable }), +})); +jest.mock('@/lib/analytics', () => ({ track: jest.fn() })); +jest.mock('@/lib/getTokenIcon', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('@/store/useComplianceStore', () => ({ + useComplianceStore: (selector: (state: any) => unknown) => + selector({ hasAcceptedDisclaimer: () => true, acceptDisclaimer: jest.fn() }), +})); +jest.mock('@/store/swapStore', () => ({ + useSwapState: (selector: (state: any) => unknown) => + selector({ + currentModal: mockCurrentModal, + previousModal: { name: 'close', number: 0 }, + transaction: {}, + buyFuseTier: undefined, + buyFuseUpgrade: mockBuyFuseUpgrade, + actions: { setModal: jest.fn() }, + }), +})); + +let mockCurrentModal = SWAP_MODAL.CLOSE; +let mockSwapAvailable = true; +let mockBuyFuseUpgrade: { tier: RewardsTier; depositToSavings: boolean } | undefined; +const originalPlatform = Platform.OS; + +const render = () => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + Platform.OS = 'ios'; + mockSwapAvailable = true; + mockCurrentModal = SWAP_MODAL.CLOSE; + mockBuyFuseUpgrade = undefined; +}); + +afterEach(() => { + Platform.OS = originalPlatform; +}); + +it('renders Buy FUSE on iOS', () => { + mockCurrentModal = SWAP_MODAL.OPEN_BUY_FUSE; + mockBuyFuseUpgrade = { tier: RewardsTier.ULTRA, depositToSavings: false }; + const root = render(); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(1); + expect(root.root.findByType('BuyFuseScreen').props.upgradeContext).toEqual(mockBuyFuseUpgrade); + act(() => root.unmount()); +}); + +it('keeps the regular Swap modal hidden on iOS', () => { + mockCurrentModal = SWAP_MODAL.OPEN_FORM; + const root = render(); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(0); + act(() => root.unmount()); +}); + +it('shows the restriction notice when Buy FUSE is unavailable in the current country', () => { + mockCurrentModal = SWAP_MODAL.OPEN_BUY_FUSE; + mockSwapAvailable = false; + const root = render(); + expect(root.root.findAllByType('GeoRestrictionNotice')).toHaveLength(1); + expect(root.root.findAllByType('BuyFuseScreen')).toHaveLength(0); + act(() => root.unmount()); +}); diff --git a/components/Tooltip.tsx b/components/Tooltip.tsx index 4c906a393..ca8c9aad1 100644 --- a/components/Tooltip.tsx +++ b/components/Tooltip.tsx @@ -21,6 +21,7 @@ interface TooltipProps { side?: 'top' | 'right' | 'bottom' | 'left'; sideOffset?: number; analyticsContext?: string; + accessibilityLabel?: string; classNames?: TooltipClassNames; } @@ -31,6 +32,7 @@ const TooltipPopover = ({ side = 'bottom', sideOffset = 4, analyticsContext, + accessibilityLabel, classNames, }: TooltipProps) => { const insets = useSafeAreaInsets(); @@ -44,6 +46,9 @@ const TooltipPopover = ({ const getTrigger = () => { return ( { track(TRACKING_EVENTS.TOOLTIP_OPENED, { context: analyticsContext || 'unknown', diff --git a/components/WalletTokenSelector/WalletTokenButton.tsx b/components/WalletTokenSelector/WalletTokenButton.tsx index 7ba048364..47bb40429 100644 --- a/components/WalletTokenSelector/WalletTokenButton.tsx +++ b/components/WalletTokenSelector/WalletTokenButton.tsx @@ -13,6 +13,9 @@ interface WalletTokenButtonProps { onPress: () => void; /** When true the button is static (no chevron, not pressable). */ disabled?: boolean; + /** Hide the chain name under the token ticker. */ + showChainName?: boolean; + tickerFontSize?: 16 | 18; } /** @@ -25,6 +28,8 @@ const WalletTokenButton = ({ selectedToken, onPress, disabled = false, + showChainName = true, + tickerFontSize = 18, }: WalletTokenButtonProps) => { return ( - {selectedToken.contractTickerSymbol} - - {getBridgeChain(selectedToken.chainId)?.name} + + {selectedToken.contractTickerSymbol} + {showChainName && ( + + {getBridgeChain(selectedToken.chainId)?.name} + + )} {!disabled && } diff --git a/components/WalletTokenSelector/WalletTokenList.tsx b/components/WalletTokenSelector/WalletTokenList.tsx index a4374e6f2..e5f99e472 100644 --- a/components/WalletTokenSelector/WalletTokenList.tsx +++ b/components/WalletTokenSelector/WalletTokenList.tsx @@ -15,6 +15,7 @@ interface WalletTokenListProps { onSelect: (token: TokenBalance) => void; emptyMessage?: string; emptyDescription?: string; + showChainLabel?: boolean; } /** @@ -27,6 +28,7 @@ const WalletTokenList: React.FC = ({ onSelect, emptyMessage = 'No tokens found', emptyDescription, + showChainLabel = true, }) => { // Sort by USD value descending const sortedTokens = useMemo(() => { @@ -54,14 +56,14 @@ const WalletTokenList: React.FC = ({ {sortedTokens.map(token => { - const balance = Number( - formatUnits(BigInt(token.balance || '0'), token.contractDecimals), - ); + const balance = Number(formatUnits(BigInt(token.balance || '0'), token.contractDecimals)); const balanceUSD = balance * (token.quoteRate || 0); const isSelected = selectedToken?.contractAddress === token.contractAddress && selectedToken?.chainId === token.chainId; - const chainName = getBridgeChain(token.chainId)?.name || `Chain ${token.chainId}`; + const chainName = showChainLabel + ? getBridgeChain(token.chainId)?.name || `Chain ${token.chainId}` + : null; return ( = ({ /> {token.contractTickerSymbol} - - {token.contractTickerSymbol} on {chainName} - + {showChainLabel && ( + + {token.contractTickerSymbol} on {chainName} + + )} ${formatNumber(balanceUSD, 2)} - - {formatNumber(balance, 2)} - + {formatNumber(balance, 2)} ); diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 4e36ac2e0..b30339e6d 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -290,7 +290,12 @@ const DialogContent = React.forwardRef< closeOnPress={isNativeBottomSheet} /> = sign: TransactionDirection.IN, category: TransactionCategory.GOODDOLLAR_UBI, }, + // Both leave the Safe. The lock comes back after its term and the annual fee + // does not, but the sign describes the movement, not whether it is recoverable + // — and a lock shown as an inbound would read as the tier paying the user. + [TransactionType.TIER_LOCK]: { + sign: TransactionDirection.OUT, + category: TransactionCategory.TIER_MEMBERSHIP, + }, + [TransactionType.TIER_SUBSCRIPTION]: { + sign: TransactionDirection.OUT, + category: TransactionCategory.TIER_MEMBERSHIP, + }, }; /** diff --git a/hooks/__tests__/useRewards.test.tsx b/hooks/__tests__/useRewards.test.tsx index a55a85d54..59309f69b 100644 --- a/hooks/__tests__/useRewards.test.tsx +++ b/hooks/__tests__/useRewards.test.tsx @@ -5,7 +5,10 @@ import RewardsUpgradeFeedback from '@/components/Rewards/RewardsUpgradeFeedback' import { useRewardsUserData } from '@/hooks/useRewards'; import { fetchRewardsUserData } from '@/lib/api'; import { RewardsTier, RewardsUserData } from '@/lib/types'; -import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; +import { + REWARDS_UPGRADE_CLEARED_STATE, + useRewardsUpgradeStore, +} from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; // react-test-renderer is supplied by jest-expo without bundled declarations. // eslint-disable-next-line @typescript-eslint/no-require-imports @@ -48,10 +51,7 @@ beforeEach(() => { useRewardsUpgradeStore.setState({ userId: 'a', session: 0, - confirmed: undefined, - success: undefined, - pendingUntil: undefined, - timedOut: false, + ...REWARDS_UPGRADE_CLEARED_STATE, }); client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); }); diff --git a/hooks/useRewards.ts b/hooks/useRewards.ts index f67e33f52..1d812664a 100644 --- a/hooks/useRewards.ts +++ b/hooks/useRewards.ts @@ -47,7 +47,10 @@ export const useReferralSummary = (options?: { refetchInterval?: number | false }); }; -export const useRewardsUserData = (options?: { refetchInterval?: number | false }) => { +export const useRewardsUserData = (options?: { + refetchInterval?: number | false; + enabled?: boolean; +}) => { const userId = useSelectedUserId(); const hasConfirmedTier = useRewardsUpgradeStore( state => state.userId === userId && !!state.confirmed, @@ -73,7 +76,7 @@ export const useRewardsUserData = (options?: { refetchInterval?: number | false useRewardsUpgradeStore.getState().observe(userId!, session, data); return data; }, - enabled: !!userId, + enabled: !!userId && options?.enabled !== false, refetchInterval: options?.refetchInterval ?? false, // A cached response from an earlier account session cannot establish this // session's baseline. Switching back must fetch even within staleTime. diff --git a/hooks/useTierMembership.ts b/hooks/useTierMembership.ts new file mode 100644 index 000000000..e0caa4011 --- /dev/null +++ b/hooks/useTierMembership.ts @@ -0,0 +1,503 @@ +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 { 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 { buildLockTransactions } from '@/lib/tierLockTransactions'; +import { RewardsTier, TierMembershipState } from '@/lib/types'; +import { publicClient } from '@/lib/wagmi'; +import { selectedRewardsUserId, useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; +import { useUserStore } from '@/store/useUserStore'; + +import type { LockPaymentAsset } from '@/lib/tierLockPayment'; + +export const TIER_MEMBERSHIP_QUERY_KEY = 'tierMembership'; +export /** + * How often the upgrade screen re-reads the Safe's balances. + * + * Five seconds: a Fuse block is ~5s, so this is roughly one read per block — + * fast enough that a deposit landing while the user watches flips the CTA + * within a block or two, and slow enough not to hammer the node from a screen + * someone may leave open. + */ +const BALANCE_POLL_MS = 5_000; + +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; + /** Native FUSE the Safe holds. */ + nativeFuse: number; + /** WFUSE the Safe holds. Par with native FUSE, so it reads as the same unit. */ + wrappedFuse: 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; + wrappedNativeAddress: 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; + const wrappedNative = contracts?.wrappedNativeAddress as Address | undefined; + + return useQuery({ + queryKey: [ + TIER_UPGRADE_BALANCES_QUERY_KEY, + safeAddress, + shareToken, + billingToken, + moduleAddress, + wrappedNative, + ], + enabled: Boolean(safeAddress), + // Polled, not cached-and-forgotten. This drives the difference between + // "Top up" and "Review upgrade", and the top-up it sends the user to + // settles somewhere else entirely — a savings deposit or the deposit + // drawer — so nothing invalidates this on the way back. Without a poll the + // screen kept showing the pre-deposit balance, and the CTA stayed "Top up" + // with the FUSE already sitting in the Safe. + // + // `staleTime` is under the interval so a remount mid-flow refetches rather + // than serving the balance the user is standing there watching for. + staleTime: BALANCE_POLL_MS / 2, + refetchInterval: BALANCE_POLL_MS, + // The interesting case is the user coming back from topping up, which is + // exactly a remount or a refocus. + refetchOnMount: true, + refetchOnWindowFocus: true, + queryFn: async () => { + const client = publicClient(fuse.id); + + // All of them 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, native, wrapped] = 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), + // Both read whether or not the zap is deployed. They cost one multicall + // slot each, and a balance the screen does not know about is a "Top up" + // button in front of a user who is holding the FUSE. + client.getBalance({ address: safeAddress! }), + wrappedNative + ? client.readContract({ + address: wrappedNative, + abi: erc20Abi, + functionName: 'balanceOf', + args: [safeAddress!], + }) + : Promise.resolve(0n), + ]); + + 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)), + // Native FUSE and WFUSE are the same unit as the FUSE a tier is priced + // in — the wrapper holds exactly its own total supply — so neither + // needs the rate. + nativeFuse: Number(formatUnits(native, SHARE_DECIMALS)), + wrappedFuse: Number(formatUnits(wrapped, 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'] }); + + // And open the reconciliation window, which is what actually gets the + // "You're on Prime now!" card shown. + // + // `RewardsUpgradeFeedback` celebrates a tier it sees *rise* between two + // reads of the rewards payload. One invalidation gives it a single read, + // taken the instant the transaction lands — before the backend has + // re-derived the tier from a lock it has not indexed yet, or a + // subscription row written in the same breath. That read returns the old + // tier, nothing appears to have risen, and the upgrade the user just paid + // for is never acknowledged. + // + // Arming the window makes it poll until the new tier arrives, exactly as a + // savings deposit does. Same mechanism, so all four routes into a tier — + // points, savings, a lock, an annual fee — get the identical celebration. + const userId = selectedRewardsUserId(); + if (userId) useRewardsUpgradeStore.getState().savingsChanged(userId); + }, [queryClient]); +}; + +/** + * Lock to hold a tier, paying with whatever the user has. + * + * soFUSE, native FUSE or WFUSE — all three priced in FUSE, because that is the + * unit a tier threshold is measured in. Which one is chosen is decided by + * `chooseLockPayment`; what each one costs in calls is decided by + * `buildLockTransactions`. + * + * Always one user operation. For soFUSE that is approve-then-lock, batched 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. + * For FUSE and WFUSE it is the zap, which deposits and locks in one call so the + * user no longer has to fund Savings, wait for the shares, and come back. + */ +export const useLockFuseForTier = () => { + const { user, safeAA } = useUser(); + const invalidate = useInvalidateAfterUpgrade(); + const [error, setError] = useState(null); + + const mutation = useMutation({ + mutationFn: async ({ + tier, + asset, + fuseAmount, + rate, + lockAddress, + shareTokenAddress, + zapAddress, + wrappedNativeAddress, + }: { + tier: RewardsTier; + /** What is paying for the lock. */ + asset: LockPaymentAsset; + /** FUSE the user is committing. */ + fuseAmount: number; + /** soFUSE→FUSE rate, raw. */ + rate: bigint; + lockAddress: string; + shareTokenAddress: string; + /** `SolidTierLockZap`. Required for anything that is not already soFUSE. */ + zapAddress?: string | null; + /** WFUSE. Required to pay with WFUSE. */ + wrappedNativeAddress?: string | null; + }) => { + if (!user?.suborgId || !user?.signWith) { + throw new Error('Your wallet is still setting up. Please try again shortly.'); + } + + const transactions = buildLockTransactions({ + asset, + fuseAmount, + rate, + lockAddress: lockAddress as Address, + shareTokenAddress: shareTokenAddress as Address, + zapAddress: (zapAddress as Address) || undefined, + wrappedNativeAddress: (wrappedNativeAddress as Address) || undefined, + }); + + const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); + + const result = await executeTransactions( + smartAccountClient, + transactions, + 'Failed to lock your FUSE', + fuse, + ); + + if (result === USER_CANCELLED_TRANSACTION) { + track(TRACKING_EVENTS.TIER_LOCK_CANCELLED, { tier, fuse_amount: fuseAmount, asset }); + 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, asset }; + }, + onSuccess: result => { + if (!result) return; + invalidate(); + track(TRACKING_EVENTS.TIER_LOCK_COMPLETED, { + tier: result.tier, + fuse_amount: result.fuseAmount, + asset: result.asset, + 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__/lockTokenRows.test.ts b/lib/__tests__/lockTokenRows.test.ts new file mode 100644 index 000000000..ff56d56e9 --- /dev/null +++ b/lib/__tests__/lockTokenRows.test.ts @@ -0,0 +1,99 @@ +import { ZAP_NATIVE_ASSET } from '@/lib/abis/SolidTierLockZap'; +import { lockAssetFromRow, lockTokenRow, lockTokenRows } from '@/lib/lockTokenRows'; +import { type LockPaymentBalances } from '@/lib/tierLockPayment'; +import { TokenType } from '@/lib/types'; + +const FUSE_CHAIN = 122; +const WFUSE = '0x0BE9e53fd7EDaC9F859882AfdDa116645287C629'; +const SOFUSE = '0xE71Cbb1eF0B0e0b0d5a0a4A0A0B0c0D0e0F00001'; + +const balances = (over: Partial = {}): LockPaymentBalances => ({ + sofuse: 0, + native: 0, + wrapped: 0, + ...over, +}); + +const rows = (over: Partial[0]> = {}) => + lockTokenRows({ + balances: balances(), + zapAvailable: true, + chainId: FUSE_CHAIN, + addresses: { wrappedNativeAddress: WFUSE, shareTokenAddress: SOFUSE }, + ...over, + }); + +describe('lockTokenRows', () => { + it('offers the three the lock can be paid with, in the picker order', () => { + expect(rows().map(row => row.contractTickerSymbol)).toEqual(['FUSE', 'WFUSE', 'soFUSE']); + }); + + it('collapses to Savings when the zap is not deployed', () => { + expect(rows({ zapAvailable: false }).map(row => row.contractTickerSymbol)).toEqual(['soFUSE']); + }); + + it('points each row at the contract it spends', () => { + const [fuse, wfuse, sofuse] = rows(); + + expect(fuse.contractAddress).toBe(ZAP_NATIVE_ASSET); + expect(fuse.type).toBe(TokenType.NATIVE); + expect(wfuse.contractAddress).toBe(WFUSE); + expect(wfuse.type).toBe(TokenType.ERC20); + expect(sofuse.contractAddress).toBe(SOFUSE); + }); + + /** + * `WalletTokenList` keys and highlights on address + chain, so two rows + * sharing a blank address would render as one and light up together — which + * is what an environment with the contracts unset would have produced. + */ + it('keeps the rows distinct when the backend names no addresses', () => { + const addresses = rows({ addresses: {} }).map(row => row.contractAddress); + + expect(new Set(addresses).size).toBe(addresses.length); + expect(addresses.every(Boolean)).toBe(true); + }); + + /** + * The whole point of the picker: three balances the user can read against + * one threshold. soFUSE is quoted at the FUSE the vault would return for it, + * not at its share count. + */ + it('quotes every balance in FUSE', () => { + const [fuse, wfuse, sofuse] = rows({ + balances: balances({ native: 1, wrapped: 2, sofuse: 3 }), + }); + + expect(fuse.balance).toBe((10n ** 18n).toString()); + expect(wfuse.balance).toBe((2n * 10n ** 18n).toString()); + expect(sofuse.balance).toBe((3n * 10n ** 18n).toString()); + expect(new Set([fuse, wfuse, sofuse].map(row => row.contractDecimals))).toEqual(new Set([18])); + }); + + /** One rate across all three, so the dollar column ranks them as FUSE does. */ + it('prices all three off the FUSE spot', () => { + expect(rows({ fusePriceUsd: 0.042 }).every(row => row.quoteRate === 0.042)).toBe(true); + }); + + /** The chip on the upgrade step has no dollar column to fill. */ + it('defaults to no price for a row built without one', () => { + expect( + lockTokenRow({ + asset: 'FUSE', + balances: balances({ native: 5 }), + chainId: FUSE_CHAIN, + addresses: {}, + }).quoteRate, + ).toBe(0); + }); +}); + +describe('lockAssetFromRow', () => { + it('reads back the asset a row was built from', () => { + expect(rows().map(lockAssetFromRow)).toEqual(['FUSE', 'WFUSE', 'soFUSE']); + }); + + it('does not claim a token that is not one of the three', () => { + expect(lockAssetFromRow({ ...rows()[0], contractTickerSymbol: 'USDC' })).toBeUndefined(); + }); +}); diff --git a/lib/__tests__/tierLockPayment.test.ts b/lib/__tests__/tierLockPayment.test.ts new file mode 100644 index 000000000..66729c9ce --- /dev/null +++ b/lib/__tests__/tierLockPayment.test.ts @@ -0,0 +1,95 @@ +import { + availableLockAssets, + canPayLockWith, + DEFAULT_LOCK_ASSET, + lockPaymentBalance, + resolveLockAsset, + type LockPaymentBalances, +} from '@/lib/tierLockPayment'; + +const balances = (over: Partial = {}): LockPaymentBalances => ({ + sofuse: 0, + native: 0, + wrapped: 0, + ...over, +}); + +describe('availableLockAssets', () => { + it('offers all three once the zap can deposit and lock in one transaction', () => { + expect(availableLockAssets(true)).toEqual(['FUSE', 'WFUSE', 'soFUSE']); + }); + + /** + * FUSE and WFUSE are payable only through the zap. Without it the picker has + * to collapse rather than offer two choices that would revert. + */ + it('collapses to Savings when the zap is not deployed', () => { + expect(availableLockAssets(false)).toEqual(['soFUSE']); + }); + + it('opens on native FUSE, which is what a user holding FUSE has', () => { + expect(DEFAULT_LOCK_ASSET).toBe('FUSE'); + expect(availableLockAssets(true)[0]).toBe(DEFAULT_LOCK_ASSET); + }); +}); + +describe('resolveLockAsset', () => { + it('keeps the choice the user made', () => { + expect(resolveLockAsset('WFUSE', true)).toBe('WFUSE'); + expect(resolveLockAsset('soFUSE', true)).toBe('soFUSE'); + }); + + /** + * A stored choice outlives the thing that made it possible — the zap can be + * switched off between sessions — and a Safe still remembering "FUSE" would + * build a transaction against a contract the backend no longer names. + */ + it('falls back to Savings when the zap is switched off under it', () => { + expect(resolveLockAsset('FUSE', false)).toBe('soFUSE'); + expect(resolveLockAsset('WFUSE', false)).toBe('soFUSE'); + }); +}); + +describe('canPayLockWith', () => { + it('reads the balance the chosen asset is denominated by', () => { + const held = balances({ sofuse: 90_000, native: 0, wrapped: 0 }); + + expect(canPayLockWith('soFUSE', 90_000, held)).toBe(true); + expect(canPayLockWith('FUSE', 90_000, held)).toBe(false); + expect(canPayLockWith('WFUSE', 90_000, held)).toBe(false); + }); + + /** + * The zap takes one deposit. Two balances that only cover the tier together + * are not an upgrade anyone can make in one press. + */ + it('does not pool two balances that only cover it together', () => { + const held = balances({ native: 50_000, wrapped: 50_000 }); + + expect(canPayLockWith('FUSE', 90_000, held)).toBe(false); + expect(canPayLockWith('WFUSE', 90_000, held)).toBe(false); + }); + + /** + * Both sides come out of on-chain bigints through a double, so a balance + * worth exactly the threshold can land a few ulps under it — the same + * tolerance `canAffordUpgrade` uses, and for the same reason. + */ + it('treats a balance a hair under the threshold as covering it', () => { + expect(canPayLockWith('FUSE', 90_000, balances({ native: 90_000 - 1e-12 }))).toBe(true); + }); + + it('is not fooled by a balance that is genuinely short', () => { + expect(canPayLockWith('FUSE', 90_000, balances({ native: 89_999 }))).toBe(false); + }); +}); + +describe('lockPaymentBalance', () => { + it('maps each asset to the balance it is held as', () => { + const held = balances({ sofuse: 1, native: 2, wrapped: 3 }); + + expect(lockPaymentBalance('soFUSE', held)).toBe(1); + expect(lockPaymentBalance('FUSE', held)).toBe(2); + expect(lockPaymentBalance('WFUSE', held)).toBe(3); + }); +}); diff --git a/lib/__tests__/tierLockTransactions.test.ts b/lib/__tests__/tierLockTransactions.test.ts new file mode 100644 index 000000000..6b8f7f523 --- /dev/null +++ b/lib/__tests__/tierLockTransactions.test.ts @@ -0,0 +1,148 @@ +import { type Address, decodeFunctionData, erc20Abi } from 'viem'; + +import { SolidTierLock_ABI } from '@/lib/abis/SolidTierLock'; +import { SolidTierLockZap_ABI, ZAP_NATIVE_ASSET } from '@/lib/abis/SolidTierLockZap'; +import { buildLockTransactions } from '@/lib/tierLockTransactions'; +import { fuseSharesForAmount, fuseSharesMintedFor } from '@/lib/tierUpgrade'; + +const LOCK = '0x1111111111111111111111111111111111111111' as Address; +const SHARE = '0x2222222222222222222222222222222222222222' as Address; +const ZAP = '0x3333333333333333333333333333333333333333' as Address; +const WFUSE = '0x4444444444444444444444444444444444444444' as Address; + +/** A rate that does not divide evenly, so the two roundings differ by a wei. */ +const RATE = 1_200_000_000_000_000_001n; +const AMOUNT = 90_000; + +const build = (over: Partial[0]> = {}) => + buildLockTransactions({ + asset: 'soFUSE', + fuseAmount: AMOUNT, + rate: RATE, + lockAddress: LOCK, + shareTokenAddress: SHARE, + zapAddress: ZAP, + wrappedNativeAddress: WFUSE, + ...over, + }); + +describe('paying with soFUSE', () => { + it('approves the lock and locks, in that order, with no value', () => { + const calls = build(); + + expect(calls).toHaveLength(2); + expect(calls[0].to).toBe(SHARE); + expect(calls[1].to).toBe(LOCK); + expect(calls.every(call => call.value === 0n)).toBe(true); + }); + + /** A share too few leaves the position a wei short and buys nothing. */ + it('locks the share count rounded up', () => { + const calls = build(); + const expected = fuseSharesForAmount(AMOUNT, RATE); + + const approve = decodeFunctionData({ abi: erc20Abi, data: calls[0].data }); + const lock = decodeFunctionData({ abi: SolidTierLock_ABI, data: calls[1].data }); + + expect(approve.functionName).toBe('approve'); + expect(approve.args?.[1]).toBe(expected); + expect(lock.functionName).toBe('lock'); + expect(lock.args?.[0]).toBe(expected); + }); + + /** The only route that does not need the zap — which is why it is preferred. */ + it('needs no zap', () => { + expect(() => build({ zapAddress: undefined })).not.toThrow(); + }); +}); + +describe('paying with native FUSE', () => { + it('is one call to the zap, carrying the deposit as value', () => { + const calls = build({ asset: 'FUSE' }); + + expect(calls).toHaveLength(1); + expect(calls[0].to).toBe(ZAP); + expect(calls[0].value).toBe(90_000n * 10n ** 18n); + }); + + it('names the native sentinel and an amount matching the value', () => { + const [call] = build({ asset: 'FUSE' }); + const zap = decodeFunctionData({ abi: SolidTierLockZap_ABI, data: call.data }); + + expect(zap.functionName).toBe('zapAndLock'); + expect(zap.args?.[0]).toBe(ZAP_NATIVE_ASSET); + // The zap reverts when these disagree, so they are checked here too. + expect(zap.args?.[1]).toBe(call.value); + }); + + /** + * The Teller rounds down, so a floor quoted as a ceiling would be one wei + * above anything a deposit that does not divide exactly can mint — and every + * such upgrade would revert. + */ + it('bounds the mint with the share count rounded down', () => { + const [call] = build({ asset: 'FUSE' }); + const zap = decodeFunctionData({ abi: SolidTierLockZap_ABI, data: call.data }); + + expect(zap.args?.[2]).toBe(fuseSharesMintedFor(AMOUNT, RATE)); + expect(fuseSharesMintedFor(AMOUNT, RATE)).toBeLessThan(fuseSharesForAmount(AMOUNT, RATE)); + }); + + /** It is still a bound: a rate that has risen mints fewer than this. */ + it('bounds the mint at the quoted rate, not at zero', () => { + const [call] = build({ asset: 'FUSE' }); + const zap = decodeFunctionData({ abi: SolidTierLockZap_ABI, data: call.data }); + + expect(zap.args?.[2]).toBeGreaterThan(0n); + }); + + it('refuses to build anything when the zap is not deployed', () => { + expect(() => build({ asset: 'FUSE', zapAddress: undefined })).toThrow(/not available/); + }); +}); + +describe('paying with WFUSE', () => { + it('approves the zap and zaps, in that order, with no value', () => { + const calls = build({ asset: 'WFUSE' }); + + expect(calls).toHaveLength(2); + expect(calls[0].to).toBe(WFUSE); + expect(calls[1].to).toBe(ZAP); + expect(calls.every(call => call.value === 0n)).toBe(true); + }); + + it('approves the zap for exactly what it will deposit', () => { + const calls = build({ asset: 'WFUSE' }); + const approve = decodeFunctionData({ abi: erc20Abi, data: calls[0].data }); + const zap = decodeFunctionData({ abi: SolidTierLockZap_ABI, data: calls[1].data }); + + expect(approve.args?.[0]).toBe(ZAP); + expect(approve.args?.[1]).toBe(zap.args?.[1]); + }); + + it('names WFUSE as the deposit asset, not the native sentinel', () => { + const calls = build({ asset: 'WFUSE' }); + const zap = decodeFunctionData({ abi: SolidTierLockZap_ABI, data: calls[1].data }); + + expect(zap.args?.[0]).toBe(WFUSE); + }); + + it('refuses to build anything without the WFUSE address', () => { + expect(() => build({ asset: 'WFUSE', wrappedNativeAddress: undefined })).toThrow( + /not available/, + ); + }); +}); + +describe('amounts that are not amounts', () => { + it.each([0, -1, Number.NaN])('refuses %s', amount => { + expect(() => build({ fuseAmount: amount })).toThrow(/amount to lock/); + expect(() => build({ asset: 'FUSE', fuseAmount: amount })).toThrow(/amount to lock/); + }); + + /** An unreadable rate is a screen that cannot price the lock yet. */ + it('refuses a rate of zero', () => { + expect(() => build({ rate: 0n })).toThrow(/amount to lock/); + expect(() => build({ asset: 'FUSE', rate: 0n })).toThrow(/amount to lock/); + }); +}); diff --git a/lib/__tests__/tierUpgrade.test.ts b/lib/__tests__/tierUpgrade.test.ts new file mode 100644 index 000000000..06e02b573 --- /dev/null +++ b/lib/__tests__/tierUpgrade.test.ts @@ -0,0 +1,389 @@ +import { + availableRoutes, + canAffordUpgrade, + formatFuse, + formatFuseHeld, + formatFuseShortfall, + formatLockDuration, + formatMembershipDate, + formatMembershipDay, + formatUsd, + formatUsdHeld, + 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: null })], + 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', + lockZapAddress: '0xzap', + subscriptionModuleAddress: '0xmodule', + shareTokenAddress: '0xshare', + wrappedNativeAddress: '0xwfuse', + 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 }; + + /** + * The bug behind "0 FUSE short — add more to Savings". + * + * Both sides of this come out of on-chain bigints through a decimal string + * and a double, so a position worth exactly the threshold can land a few ulps + * under it. Strict >= then said "Top up" to someone holding precisely enough, + * and the shortfall — far too small to render — printed as zero. + */ + it('treats a position a few ulps under the threshold as enough', () => { + const offerAt = offer({ lockFuse: 50_000 }); + + expect( + canAffordUpgrade({ + ...base, + offer: offerAt, + route: 'lock', + availableFuse: 50_000 - 1e-12, + }), + ).toBe(true); + }); + + /** + * And the other side of it, which matters more: locking short of the + * threshold succeeds on-chain and grants no tier, because the backend + * measures the locked position against the threshold. A user waved through + * here commits their FUSE for a year and gets nothing, so the tolerance has + * to stay far below anything anyone could actually be short by. + */ + it('does not wave through a real shortfall, however small', () => { + const offerAt = offer({ lockFuse: 50_000 }); + + for (const availableFuse of [49_999.9, 49_999.99, 49_999.999]) { + expect(canAffordUpgrade({ ...base, offer: offerAt, route: 'lock', availableFuse })).toBe( + false, + ); + } + }); + + 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: null }), + 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: null }), + ], + }), + ), + ).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('renders nothing for a tier that is not sold for cash', () => { + // Null, not 0 — "$0.00" is the one output a user would read as a price. + expect(formatUsd(null)).toBe(''); + expect(formatUsd(undefined)).toBe(''); + }); + + /** + * The display half of the same bug. The screen compares to the sixth decimal + * and shows whole FUSE, so rounding a held balance to nearest let it print + * "15,000" beside a requirement of "15,000" and still offer "Top up" — the + * screen contradicting its own numbers. + */ + it('rounds a held balance down, so it never claims enough', () => { + expect(formatFuseHeld(14_999.6)).toBe('14,999'); + expect(formatFuseHeld(15_000)).toBe('15,000'); + expect(formatFuseHeld(15_000.9)).toBe('15,000'); + }); + + it('rounds a shortfall up, so topping it up always clears it', () => { + expect(formatFuseShortfall(0.4)).toBe('1'); + expect(formatFuseShortfall(1)).toBe('1'); + expect(formatFuseShortfall(1.1)).toBe('2'); + }); + + it('never writes a held balance above what is held, or a shortfall below it', () => { + for (const amount of [0.1, 0.9, 1.5, 14_999.6, 50_000.4]) { + expect(Number(formatFuseHeld(amount).replace(/,/g, ''))).toBeLessThanOrEqual(amount); + expect(Number(formatFuseShortfall(amount).replace(/,/g, ''))).toBeGreaterThanOrEqual(amount); + } + }); + + it('rounds a USDC balance down to the cent', () => { + expect(formatUsdHeld(198.999)).toBe('$198.99'); + expect(formatUsdHeld(199)).toBe('$199.00'); + }); + + 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 000000000..e005b6145 --- /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 000000000..814e906eb --- /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/abis/SolidTierLockZap.ts b/lib/abis/SolidTierLockZap.ts new file mode 100644 index 000000000..6e4611eb3 --- /dev/null +++ b/lib/abis/SolidTierLockZap.ts @@ -0,0 +1,29 @@ +/** + * `SolidTierLockZap` — deposit and lock in one transaction. + * + * Only `zapAndLock` is here. Everything else the contract exposes is either an + * owner operation or an immutable the app is told about by the membership + * payload, so an ABI carrying them would be a second place for those addresses + * to be read from. + */ +export const SolidTierLockZap_ABI = [ + { + inputs: [ + { internalType: 'address', name: 'asset', type: 'address' }, + { internalType: 'uint256', name: 'amount', type: 'uint256' }, + { internalType: 'uint256', name: 'minShares', type: 'uint256' }, + ], + name: 'zapAndLock', + outputs: [{ internalType: 'uint256', name: 'shares', type: 'uint256' }], + stateMutability: 'payable', + type: 'function', + }, +] as const; + +/** + * The Teller's sentinel for "this deposit is the native token". + * + * Passed as the asset with the amount sent as value. Same constant the Teller + * and the zap both use; it is not an address anyone holds. + */ +export const ZAP_NATIVE_ASSET = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE' as const; diff --git a/lib/api.ts b/lib/api.ts index cd5c17add..5987dcf74 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -119,6 +119,7 @@ import { ReferralSummary, RegionInterestPayload, ResumeRainForwardResponse, + RewardsTier, RewardsUserData, SavingsSummaryResponse, SearchCoin, @@ -133,6 +134,8 @@ import { SyncActivitiesOptions, SyncActivitiesResponse, TierBenefits, + TierMembershipState, + TierSubscription, ToCurrency, TokenPriceByAddress, TokenPriceUsd, @@ -1995,6 +1998,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/lockTokenRows.ts b/lib/lockTokenRows.ts new file mode 100644 index 000000000..93a36e1be --- /dev/null +++ b/lib/lockTokenRows.ts @@ -0,0 +1,106 @@ +import { ZAP_NATIVE_ASSET } from '@/lib/abis/SolidTierLockZap'; +import { + availableLockAssets, + LOCK_PAYMENT_ASSETS, + type LockPaymentAsset, + lockPaymentBalance, + type LockPaymentBalances, +} from '@/lib/tierLockPayment'; +import { fuseToWei } from '@/lib/tierUpgrade'; +import { type TokenBalance, TokenType } from '@/lib/types'; + +/** The full name under each ticker, as the shared token list renders it. */ +const LOCK_TOKEN_NAME: Record = { + FUSE: 'Fuse', + WFUSE: 'Wrapped Fuse', + soFUSE: 'Solid Savings Fuse', +}; + +/** Where each token's address comes from, when the backend has named one. */ +export interface LockTokenAddresses { + /** WFUSE. */ + wrappedNativeAddress?: string | null; + /** soFUSE, the vault share token. */ + shareTokenAddress?: string | null; +} + +/** + * A stable identity for a token the backend has not named an address for. + * + * `WalletTokenList` keys and selects on `contractAddress` + `chainId`, so two + * rows sharing a blank address would collapse into one key and highlight + * together. Contracts are configuration and can be missing in an environment + * that has not been wired up yet, which is exactly when a picker rendering one + * row instead of three is hardest to diagnose. + */ +const placeholderAddress = (asset: LockPaymentAsset) => `lock-token:${asset}`; + +/** The on-chain address a row stands for, when there is one. */ +export const lockTokenAddress = ( + asset: LockPaymentAsset, + addresses: LockTokenAddresses, +): string | null => { + if (asset === 'FUSE') return ZAP_NATIVE_ASSET; + if (asset === 'WFUSE') return addresses.wrappedNativeAddress ?? null; + return addresses.shareTokenAddress ?? null; +}; + +interface LockTokenRowInput { + asset: LockPaymentAsset; + /** Every balance the lock could be paid from, in FUSE. */ + balances: LockPaymentBalances; + addresses: LockTokenAddresses; + chainId: number; + /** FUSE spot. 0 where the row has no dollar column to fill. */ + fusePriceUsd?: number; +} + +/** + * One payment option, as a row for the app's shared token components. + * + * The balance is converted to FUSE first. That is the unit the tier threshold + * is set in, and it is the only way the options can be read against each other: + * soFUSE shares and FUSE are different numbers for the same money, and a row + * quoting shares would ask the user to apply the vault's exchange rate in their + * head to find out whether their balance covers the upgrade. + * + * Priced with the FUSE spot price for the same reason — one rate across all + * three, so the dollar column ranks them the way the FUSE column does. + */ +export const lockTokenRow = ({ + asset, + balances, + addresses, + chainId, + fusePriceUsd = 0, +}: LockTokenRowInput): TokenBalance => ({ + contractTickerSymbol: asset, + contractName: LOCK_TOKEN_NAME[asset], + contractAddress: lockTokenAddress(asset, addresses) ?? placeholderAddress(asset), + // In FUSE, not in the token's own units — see above. + balance: fuseToWei(lockPaymentBalance(asset, balances)).toString(), + contractDecimals: 18, + quoteRate: fusePriceUsd, + type: asset === 'FUSE' ? TokenType.NATIVE : TokenType.ERC20, + chainId, +}); + +/** Every payment option the lock can be offered with right now. */ +export const lockTokenRows = ({ + zapAvailable, + ...row +}: Omit & { + /** Whether the zap can deposit and lock in one transaction. */ + zapAvailable: boolean; +}): TokenBalance[] => + availableLockAssets(zapAvailable).map(asset => lockTokenRow({ asset, ...row })); + +/** + * Which asset a selected row is. + * + * Matched on the ticker rather than the address: the rows are built here, so + * the ticker is exactly one of the three, and an address read back from a + * config that has since changed would resolve to nothing. + */ +export const lockAssetFromRow = (token: TokenBalance): LockPaymentAsset | undefined => + LOCK_PAYMENT_ASSETS.find(asset => asset === token.contractTickerSymbol); diff --git a/lib/tierLockPayment.ts b/lib/tierLockPayment.ts new file mode 100644 index 000000000..c839bb059 --- /dev/null +++ b/lib/tierLockPayment.ts @@ -0,0 +1,92 @@ +import { covers } from '@/lib/tierUpgrade'; + +/** + * What the lock can be paid with. + * + * All three are FUSE as far as the tier is concerned — the threshold is + * measured in FUSE, native and wrapped are par with it, and soFUSE is priced + * through the vault's rate — which is why the screens quote every amount in + * FUSE and only this picker mentions the token. + */ +export type LockPaymentAsset = 'FUSE' | 'WFUSE' | 'soFUSE'; + +/** In the order the picker lists them. */ +export const LOCK_PAYMENT_ASSETS: readonly LockPaymentAsset[] = ['FUSE', 'WFUSE', 'soFUSE']; + +/** + * What the picker opens on. + * + * Native FUSE: it is what a user holding FUSE has, it needs no prior deposit, + * and it is the one the zap was built for. soFUSE is the specialist choice — + * it is already in Savings and locks without a deposit at all — so it is + * offered rather than assumed. + */ +export const DEFAULT_LOCK_ASSET: LockPaymentAsset = 'FUSE'; + +/** The Safe's holdings, every one of them denominated in FUSE. */ +export interface LockPaymentBalances { + /** Unlocked soFUSE, valued at the vault's rate. */ + sofuse: number; + /** Native FUSE. */ + native: number; + /** WFUSE, which is native FUSE at par — the wrapper holds exactly its supply. */ + wrapped: number; +} + +/** The ticker, as the picker and the rows show it. */ +export const LOCK_PAYMENT_LABEL: Record = { + FUSE: 'FUSE', + WFUSE: 'WFUSE', + soFUSE: 'soFUSE', +}; + +/** The line under the ticker in the picker, saying where the balance lives. */ +export const LOCK_PAYMENT_DESCRIPTION: Record = { + FUSE: 'Native FUSE in your wallet', + WFUSE: 'Wrapped FUSE in your wallet', + soFUSE: 'Already in Savings, locked as-is', +}; + +/** That asset's balance, in FUSE. */ +export const lockPaymentBalance = ( + asset: LockPaymentAsset, + balances: LockPaymentBalances, +): number => + asset === 'soFUSE' ? balances.sofuse : asset === 'FUSE' ? balances.native : balances.wrapped; + +/** + * The assets the picker can offer. + * + * FUSE and WFUSE are payable only because the zap deposits and locks in one + * transaction. Without it the only thing that can be locked is what is already + * in Savings, so the picker collapses to a single row rather than offering two + * choices that would revert. + */ +export const availableLockAssets = (zapAvailable: boolean): readonly LockPaymentAsset[] => + zapAvailable ? LOCK_PAYMENT_ASSETS : ['soFUSE']; + +/** + * The asset actually in force, given what the user picked. + * + * A stored choice outlives the thing that made it possible: the zap can be + * switched off between one session and the next, and a Safe that still + * remembers "FUSE" would otherwise build a transaction against a contract the + * backend no longer names. + */ +export const resolveLockAsset = ( + selected: LockPaymentAsset, + zapAvailable: boolean, +): LockPaymentAsset => (availableLockAssets(zapAvailable).includes(selected) ? selected : 'soFUSE'); + +/** + * Whether that asset's balance covers the lock. + * + * One asset, not a combination: the zap takes a single deposit, so an account + * that could only afford the tier by pooling two of these has to pick one and + * top it up. + */ +export const canPayLockWith = ( + asset: LockPaymentAsset, + requiredFuse: number, + balances: LockPaymentBalances, +): boolean => covers(lockPaymentBalance(asset, balances), requiredFuse); diff --git a/lib/tierLockTransactions.ts b/lib/tierLockTransactions.ts new file mode 100644 index 000000000..516be1d58 --- /dev/null +++ b/lib/tierLockTransactions.ts @@ -0,0 +1,145 @@ +import { type Address, encodeFunctionData, erc20Abi, type Hex } from 'viem'; + +import { SolidTierLock_ABI } from '@/lib/abis/SolidTierLock'; +import { SolidTierLockZap_ABI, ZAP_NATIVE_ASSET } from '@/lib/abis/SolidTierLockZap'; +import { fuseSharesForAmount, fuseSharesMintedFor, fuseToWei } from '@/lib/tierUpgrade'; + +import type { LockPaymentAsset } from '@/lib/tierLockPayment'; + +/** One call in the user operation the Safe signs. */ +export interface LockTransaction { + to: Address; + data: Hex; + value: bigint; +} + +export interface LockTransactionParams { + asset: LockPaymentAsset; + /** FUSE the user is committing. */ + fuseAmount: number; + /** soFUSE→FUSE rate, raw. */ + rate: bigint; + lockAddress: Address; + shareTokenAddress: Address; + /** `SolidTierLockZap`. Required for anything that is not already soFUSE. */ + zapAddress?: Address; + /** WFUSE. Required to pay with WFUSE. */ + wrappedNativeAddress?: Address; +} + +/** + * The calls that buy a tier by locking, for whichever asset is paying. + * + * Three shapes, one user operation each, and the difference between them is + * where the shares come from: + * + * - **soFUSE** is already shares. Approve them to the lock and lock them. This + * is the only route that does not need the zap, which is why it stays the + * preferred one. + * - **FUSE** goes to the zap as value. The zap deposits it, reads what the + * Teller actually minted, and locks that — which is the whole reason it + * exists, because a batch cannot read a balance it created. + * - **WFUSE** is the same, with an approval in front of it. + * + * ## Why the two share counts round opposite ways + * + * `shares` (soFUSE) is rounded **up**: the lock takes exactly this many from a + * balance that already exists, and a share too few leaves the position a wei + * short of the threshold and buys nothing. + * + * `minShares` (the zap) is rounded **down**, because it is a floor on what the + * Teller will mint and the Teller itself rounds down. Quoted rounded up it + * would be one wei above what any deposit that does not divide exactly can + * produce, and every such upgrade would revert. The wei of difference is worth + * a wei of FUSE against a threshold in the tens of thousands. + * + * It is still a real bound: a rate that rises between this quote and the + * transaction landing mints fewer shares than this, and the zap reverts rather + * than locking the user in for a year against a position that does not reach + * the tier. The screen re-reads the rate every few seconds, so the retry is + * already priced. + */ +export const buildLockTransactions = ({ + asset, + fuseAmount, + rate, + lockAddress, + shareTokenAddress, + zapAddress, + wrappedNativeAddress, +}: LockTransactionParams): LockTransaction[] => { + if (asset === 'soFUSE') { + const shares = fuseSharesForAmount(fuseAmount, rate); + if (shares <= 0n) throw new Error('Enter an amount to lock.'); + + return [ + { + to: shareTokenAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [lockAddress, shares], + }), + value: 0n, + }, + { + to: lockAddress, + data: encodeFunctionData({ + abi: SolidTierLock_ABI, + functionName: 'lock', + args: [shares], + }), + value: 0n, + }, + ]; + } + + if (!zapAddress) { + throw new Error('Paying with FUSE is not available right now. Add it to Savings instead.'); + } + + const amountWei = fuseToWei(fuseAmount); + const minShares = fuseSharesMintedFor(fuseAmount, rate); + if (amountWei <= 0n || minShares <= 0n) throw new Error('Enter an amount to lock.'); + + if (asset === 'FUSE') { + return [ + { + to: zapAddress, + data: encodeFunctionData({ + abi: SolidTierLockZap_ABI, + functionName: 'zapAndLock', + args: [ZAP_NATIVE_ASSET, amountWei, minShares], + }), + // The zap checks this against the amount, so the two cannot disagree + // about what is being deposited. + value: amountWei, + }, + ]; + } + + if (!wrappedNativeAddress) { + throw new Error('Paying with WFUSE is not available right now. Add it to Savings instead.'); + } + + return [ + { + to: wrappedNativeAddress, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [zapAddress, amountWei], + }), + value: 0n, + }, + { + to: zapAddress, + data: encodeFunctionData({ + abi: SolidTierLockZap_ABI, + functionName: 'zapAndLock', + args: [wrappedNativeAddress, amountWei, minShares], + }), + value: 0n, + }, + ]; +}; diff --git a/lib/tierUpgrade.ts b/lib/tierUpgrade.ts new file mode 100644 index 000000000..6402e70f0 --- /dev/null +++ b/lib/tierUpgrade.ts @@ -0,0 +1,283 @@ +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. + */ +export const fuseToWei = (amount: number): bigint => { + // Zero rather than a crash for anything that is not a positive amount. This + // is called directly now — a NaN reaching `BigInt()` threw "Cannot convert + // NaN to a BigInt" at the caller, which is not an error anyone can act on — + // and every caller already treats 0 as "there is nothing to lock". + if (!Number.isFinite(amount) || amount <= 0) return 0n; + + 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 = fuseToWei(fuseAmount); + + return (fuseWei * ONE_SHARE + rate - 1n) / rate; +}; + +/** + * The shares a deposit of `fuseAmount` actually mints. + * + * Rounded **down**, because that is what the Teller does — and this number is + * the zap's floor on what it will accept, so quoting it rounded up would revert + * every deposit that does not divide exactly. + * + * The difference from `fuseSharesForAmount` is one wei of shares, worth a wei + * of FUSE against a threshold measured in tens of thousands: the lock still + * clears it. What the two must never do is swap places — a floor where the + * ceiling belongs locks a share too few and buys nothing. + */ +export const fuseSharesMintedFor = (fuseAmount: number, rate: bigint): bigint => { + if (!Number.isFinite(fuseAmount) || fuseAmount <= 0 || rate <= 0n) return 0n; + + return (fuseToWei(fuseAmount) * ONE_SHARE) / 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; +}; + +/** + * Below this, a shortfall is not a shortfall. + * + * Both sides come out of on-chain bigints through a decimal string and a + * double, so a position worth exactly the threshold can land a few ulps under + * it. A billionth of a FUSE is nine orders of magnitude below anything anyone + * could deposit to fix, so treating it as a shortfall only ever strands a user + * who does have enough. + * + * Kept this small on purpose: a loose tolerance would wave through someone who + * is genuinely short, and locking short of the threshold succeeds on-chain + * while granting no tier. + */ +const AMOUNT_EPSILON = 1e-9; + +/** Whether `available` covers `required`, ignoring representation noise. */ +export const covers = (available: number, required: number): boolean => + available + AMOUNT_EPSILON >= required; + +/** + * 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 !== null && + offer.annualFeeUsd > 0 && + covers(availableUsdc, offer.annualFeeUsd) + : covers(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 balance the user holds, rounded DOWN. + * + * The screen shows whole FUSE while it compares to the sixth decimal, and + * rounding a balance to nearest is how those two disagree in public: a Safe + * holding 14,999.6 FUSE displayed "15,000", sat beside a requirement of + * "15,000", and still offered "Top up" — the screen contradicting its own + * numbers. Rounding held amounts down and shortfalls up means the displayed + * numbers can never claim the user has enough when they do not. + * + * Deliberately not solved by loosening the comparison instead. Locking 14,999.6 + * FUSE succeeds on-chain and grants no tier — the backend measures the locked + * position against the threshold — so a user waved through on a rounded balance + * commits their FUSE for a year and gets nothing for it. + */ +export const formatFuseHeld = (amount: number): string => formatFuse(Math.floor(amount)); + +/** A shortfall, rounded UP — so topping it up always clears it. */ +export const formatFuseShortfall = (amount: number): string => formatFuse(Math.ceil(amount)); + +/** + * A USD figure with cents: "$199.00". + * + * Renders nothing at all for a tier that is not sold for cash, which arrives as + * null. The alternative — "$0.00" — is the one output that would be read as a + * price, and it would be the wrong one. + */ +export const formatUsd = (amount: number | null | undefined): string => + amount === null || amount === undefined + ? '' + : `$${amount.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +/** A USDC balance, rounded down to the cent — see `formatFuseHeld`. */ +export const formatUsdHeld = (amount: number): string => formatUsd(Math.floor(amount * 100) / 100); + +/** 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 ac12a9337..5f97d6bde 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1233,6 +1233,16 @@ export enum TransactionType { AGENT_WALLET_DEPOSIT = 'agent_wallet_deposit', GOODDOLLAR_CLAIM = 'gooddollar_claim', GOODDOLLAR_SWEEP = 'gooddollar_sweep', + /** + * The two ways a rewards v3 membership tier is bought. + * + * `TIER_LOCK` is soFUSE committed to the lock for a fixed term — the user + * still owns it and gets it back, so it reads as a movement out of the wallet + * rather than a charge. `TIER_SUBSCRIPTION` is the annual USDC the + * subscription module draws, which is spent. + */ + TIER_LOCK = 'tier_lock', + TIER_SUBSCRIPTION = 'tier_subscription', } export enum TransactionDirection { @@ -1260,6 +1270,7 @@ export enum TransactionCategory { CARD_WELCOME_BONUS = 'Card welcome bonus', DEPOSIT_BONUS = 'Deposit bonus', GOODDOLLAR_UBI = 'GoodDollar UBI', + TIER_MEMBERSHIP = 'Tier membership', RECEIVE = 'Receive', } @@ -3109,3 +3120,122 @@ 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, or null when this tier is not sold for cash. + * + * Null rather than 0 so a missing price can never be rendered as "free". + * `cashAvailable` is the flag to branch on; this is only ever the amount. + */ + annualFeeUsd: number | null; + /** 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; + /** + * `SolidTierLockZap`, which deposits and locks in one transaction. + * + * Null means the one-press upgrade is not available and the user has to fund + * Savings first and come back once the shares have landed. It is a backend + * switch rather than a deployment fact — the zap also has to hold the lock's + * `lockFor` role — so the app treats null as "offer the two-step flow". + */ + lockZapAddress: string | null; + subscriptionModuleAddress: string | null; + /** The soFUSE share token that is locked. */ + shareTokenAddress: string | null; + /** WFUSE. Par with native FUSE, so the two are one choice to the user. */ + wrappedNativeAddress: 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; +} diff --git a/lib/utils/__tests__/savings-activity-filter.test.ts b/lib/utils/__tests__/savings-activity-filter.test.ts index a7802785e..20dccc7d8 100644 --- a/lib/utils/__tests__/savings-activity-filter.test.ts +++ b/lib/utils/__tests__/savings-activity-filter.test.ts @@ -141,6 +141,11 @@ describe('isSavingsVaultActivity — other surfaces stay out', () => { [TransactionType.AGENT_WALLET_DEPOSIT, 'Agent wallet deposit'], [TransactionType.GOODDOLLAR_CLAIM, 'GoodDollar claim'], [TransactionType.RESCUE_TOKEN, 'Recovered 5 USDC'], + // Buying a tier moves soFUSE out of Savings and into the lock, which is a + // membership purchase rather than a withdrawal — the savings balance is not + // what changed. + [TransactionType.TIER_LOCK, 'Locked 50000 FUSE for a membership'], + [TransactionType.TIER_SUBSCRIPTION, 'Prime membership'], ])('excludes %s activity', (type, title) => { expect(isSavingsVaultActivity(makeActivity({ type, title }))).toBe(false); }); diff --git a/store/__tests__/useRewardsUpgradeStore.test.ts b/store/__tests__/useRewardsUpgradeStore.test.ts index 217a21bc0..76a5b7963 100644 --- a/store/__tests__/useRewardsUpgradeStore.test.ts +++ b/store/__tests__/useRewardsUpgradeStore.test.ts @@ -1,6 +1,9 @@ import { REWARDS_RECONCILIATION_MS } from '@/lib/rewardsUpgrade'; import { RewardsTier, RewardsUserData } from '@/lib/types'; -import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; +import { + REWARDS_UPGRADE_CLEARED_STATE, + useRewardsUpgradeStore, +} from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; jest.mock('@/store/useUserStore', () => { @@ -18,14 +21,7 @@ const observe = (tier: RewardsTier) => beforeEach(() => { jest.useFakeTimers(); - store.setState({ - userId: 'a', - session: 0, - confirmed: undefined, - success: undefined, - pendingUntil: undefined, - timedOut: false, - }); + store.setState({ userId: 'a', session: 0, ...REWARDS_UPGRADE_CLEARED_STATE }); }); afterEach(() => jest.useRealTimers()); @@ -90,6 +86,29 @@ it('does not celebrate a downgrade', () => { expect(store.getState().success).toBeUndefined(); }); +/** + * The popup on the review screen, over a purchase nobody had made. + * + * The backend re-derives the tier from a lock, a subscription row and a soFUSE + * balance it caches for a minute, so a read taken mid-reconciliation can come + * back a tier low and the next one put it back. Measured against the last tier + * seen, that recovery is indistinguishable from an upgrade. + */ +it('does not celebrate a tier that merely comes back after a dip', () => { + observe(RewardsTier.PRIME); + observe(RewardsTier.CORE); + observe(RewardsTier.PRIME); + expect(store.getState().success).toBeUndefined(); +}); + +it('still celebrates a real upgrade taken after such a dip', () => { + observe(RewardsTier.PRIME); + observe(RewardsTier.CORE); + observe(RewardsTier.PRIME); + observe(RewardsTier.ULTRA); + expect(store.getState().success?.currentTier).toBe(RewardsTier.ULTRA); +}); + it('polls an ambiguous balance event quietly without blocking a wallet-funded upgrade', () => { store.setState({ savingsConfirmed: false }); store.getState().savingsChanged('a', false); diff --git a/store/__tests__/useTierUpgradeStore.test.ts b/store/__tests__/useTierUpgradeStore.test.ts new file mode 100644 index 000000000..022260735 --- /dev/null +++ b/store/__tests__/useTierUpgradeStore.test.ts @@ -0,0 +1,100 @@ +import { TIER_UPGRADE_MODAL } from '@/constants/modals'; +import { DEFAULT_LOCK_ASSET } from '@/lib/tierLockPayment'; +import { RewardsTier } from '@/lib/types'; +import { isTierUpgradeOpen, useTierUpgradeStore } from '@/store/useTierUpgradeStore'; + +const store = useTierUpgradeStore; + +beforeEach(() => { + store.setState({ + currentModal: TIER_UPGRADE_MODAL.CLOSE, + previousModal: TIER_UPGRADE_MODAL.CLOSE, + tier: null, + route: null, + lockAsset: DEFAULT_LOCK_ASSET, + }); +}); + +it('opens on the tier it was given and closes back down', () => { + store.getState().open(RewardsTier.ULTRA); + expect(store.getState().tier).toBe(RewardsTier.ULTRA); + expect(isTierUpgradeOpen(store.getState())).toBe(true); + + store.getState().close(); + expect(isTierUpgradeOpen(store.getState())).toBe(false); +}); + +/** The Earn screen's CTA: "upgrade", with no idea which tier that buys. */ +it('opens with no tier at all, for a caller that does not know one', () => { + store.getState().open(); + expect(store.getState().tier).toBeNull(); + expect(isTierUpgradeOpen(store.getState())).toBe(true); +}); + +/** + * A route is only offered because a particular tier sells it. Ultra is + * soFUSE-only, so a "Cash" tab carried over from Prime would be a payment + * method the review step cannot complete. + */ +it('drops the payment route when a different tier is opened', () => { + store.getState().open(RewardsTier.PRIME); + store.getState().setRoute('cash'); + store.getState().open(RewardsTier.ULTRA); + + expect(store.getState().route).toBeNull(); +}); + +it('steps forward to review and back again without closing', () => { + store.getState().open(RewardsTier.PRIME); + store.getState().review(); + expect(store.getState().currentModal).toBe(TIER_UPGRADE_MODAL.OPEN_REVIEW); + + store.getState().back(); + expect(store.getState().currentModal).toBe(TIER_UPGRADE_MODAL.OPEN_UPGRADE); + expect(isTierUpgradeOpen(store.getState())).toBe(true); +}); + +/** What drives the slide direction in `ResponsiveModal`. */ +it('records where it came from, so the step can animate the right way', () => { + store.getState().open(RewardsTier.PRIME); + store.getState().review(); + + expect(store.getState().previousModal.number).toBeLessThan(store.getState().currentModal.number); +}); + +describe('the lock token', () => { + it('opens the picker and comes back with the choice made', () => { + store.getState().open(RewardsTier.PRIME); + store.getState().selectToken(); + expect(store.getState().currentModal).toBe(TIER_UPGRADE_MODAL.OPEN_TOKEN_SELECTOR); + + store.getState().setLockAsset('WFUSE'); + expect(store.getState().lockAsset).toBe('WFUSE'); + // Picking one is the whole step, so it closes itself rather than leaving + // the user to press back on a decision they have already made. + expect(store.getState().currentModal).toBe(TIER_UPGRADE_MODAL.OPEN_UPGRADE); + }); + + /** + * A token picked for one upgrade is not a standing preference — the balances + * behind it have moved, and the tier being bought may be a different one. + */ + it('goes back to the default when the flow is reopened', () => { + store.getState().open(RewardsTier.PRIME); + store.getState().setLockAsset('soFUSE'); + store.getState().open(RewardsTier.ULTRA); + + expect(store.getState().lockAsset).toBe(DEFAULT_LOCK_ASSET); + }); + + /** Backing out of the picker leaves the choice alone. */ + it('keeps the current token when the picker is dismissed', () => { + store.getState().open(RewardsTier.PRIME); + store.getState().setLockAsset('soFUSE'); + store.getState().selectToken(); + store.getState().back(); + + expect(store.getState().lockAsset).toBe('soFUSE'); + expect(store.getState().currentModal).toBe(TIER_UPGRADE_MODAL.OPEN_UPGRADE); + }); +}); diff --git a/store/swapStore.ts b/store/swapStore.ts index f65dbf1a8..e042d4319 100644 --- a/store/swapStore.ts +++ b/store/swapStore.ts @@ -46,6 +46,7 @@ interface SwapState { readonly currentModal: SwapModal; readonly previousModal: SwapModal; readonly buyFuseTier: RewardsTier | undefined; + readonly buyFuseUpgrade: BuyFuseUpgradeContext | undefined; readonly transaction: TransactionStatusModal & { inputCurrencySymbol?: string; outputCurrencySymbol?: string; @@ -58,11 +59,16 @@ interface SwapState { typeInput: (field: SwapFieldType, typedValue: string) => void; resetForm: () => void; setModal: (modal: SwapModal) => void; - openBuyFuse: (tier?: RewardsTier) => void; + openBuyFuse: (tier?: RewardsTier, upgrade?: { depositToSavings: boolean }) => void; setTransaction: (transaction: SwapState['transaction']) => void; }; } +export interface BuyFuseUpgradeContext { + tier: RewardsTier; + depositToSavings: boolean; +} + export const useSwapState = create((set, get) => ({ independentField: SwapField.INPUT, typedValue: '', @@ -77,6 +83,7 @@ export const useSwapState = create((set, get) => ({ currentModal: SWAP_MODAL.CLOSE, previousModal: SWAP_MODAL.CLOSE, buyFuseTier: undefined, + buyFuseUpgrade: undefined, transaction: {}, actions: { selectCurrency: (field, currencyId) => { @@ -126,12 +133,14 @@ export const useSwapState = create((set, get) => ({ set({ previousModal: get().currentModal, currentModal: modal, + ...(modal.name === SWAP_MODAL.CLOSE.name ? { buyFuseUpgrade: undefined } : {}), }), - openBuyFuse: tier => + openBuyFuse: (tier, upgrade) => set({ previousModal: get().currentModal, currentModal: SWAP_MODAL.OPEN_BUY_FUSE, buyFuseTier: tier, + buyFuseUpgrade: tier && upgrade ? { tier, ...upgrade } : undefined, }), setTransaction: transaction => set({ transaction }), }, diff --git a/store/useDepositStore.ts b/store/useDepositStore.ts index fe242b977..be1685e95 100644 --- a/store/useDepositStore.ts +++ b/store/useDepositStore.ts @@ -11,6 +11,7 @@ import { USER } from '@/lib/config'; import mmkvStorage from '@/lib/mmvkStorage'; import { DepositModal, + RewardsTier, SavingsFundIntent, SourceDepositInstructions, TransactionStatusModal, @@ -119,6 +120,8 @@ interface DepositState { * one flow and is cleared by `resetDepositFlow`. */ savingsFundIntent: SavingsFundIntent; + /** Only set while the deposit chooser was opened by an upgrade Top up action. */ + upgradeTopUp?: { tier: RewardsTier; depositToSavings: boolean }; /** * Chain and currency chosen for the wallet deposit address ("Show deposit * address" -> "Select chain" -> the address). @@ -134,6 +137,7 @@ interface DepositState { setExternalWallet: (data: ExternalWalletState) => void; setDepositFromSolid: (v: boolean) => void; setSavingsFundIntent: (intent: SavingsFundIntent) => void; + setUpgradeTopUp: (context: DepositState['upgradeTopUp']) => void; setModal: (modal: DepositModal) => void; setTransaction: (transaction: TransactionStatusModal) => void; setBankTransferData: (data: Partial) => void; @@ -164,6 +168,7 @@ export const useDepositStore = create()( sessionStartTime: undefined, depositFromSolid: false, savingsFundIntent: 'savings', + upgradeTopUp: undefined, walletDeposit: {}, externalWallet: { address: undefined, @@ -175,6 +180,7 @@ export const useDepositStore = create()( setExternalWallet: data => set({ externalWallet: data }), setDepositFromSolid: (v: boolean) => set({ depositFromSolid: v }), setSavingsFundIntent: (intent: SavingsFundIntent) => set({ savingsFundIntent: intent }), + setUpgradeTopUp: context => set({ upgradeTopUp: context }), setModal: modal => { const isClose = modal.name === DEPOSIT_MODAL.CLOSE.name; @@ -209,6 +215,7 @@ export const useDepositStore = create()( sessionStartTime: undefined, depositFromSolid: false, savingsFundIntent: 'savings', + upgradeTopUp: undefined, walletDeposit: {}, }), }), diff --git a/store/useRewardsUpgradeStore.ts b/store/useRewardsUpgradeStore.ts index eac4f74d7..29cd1136e 100644 --- a/store/useRewardsUpgradeStore.ts +++ b/store/useRewardsUpgradeStore.ts @@ -1,7 +1,7 @@ import { create } from 'zustand'; import { isHigherTier, REWARDS_RECONCILIATION_MS } from '@/lib/rewardsUpgrade'; -import { RewardsUserData } from '@/lib/types'; +import { RewardsTier, RewardsUserData } from '@/lib/types'; import { useUserStore } from '@/store/useUserStore'; export const selectedRewardsUserId = () => @@ -11,6 +11,19 @@ interface RewardsUpgradeState { userId?: string; session: number; confirmed?: RewardsUserData; + /** + * The highest tier seen this session, which is what a promotion is measured + * against. + * + * Not `confirmed.currentTier`. The backend re-derives the tier from a lock, a + * subscription row and a soFUSE balance it caches for a minute, so a read + * taken mid-reconciliation can come back a tier low and the next one put it + * back — and against the *last* tier that recovery reads as a promotion. That + * is the "You're on Prime now!" card appearing over a screen where nothing + * was bought. Against the *highest* tier, it reads as what it is: nothing + * happened. + */ + peak?: RewardsTier; success?: RewardsUserData; pendingUntil?: number; savingsConfirmed: boolean; @@ -22,6 +35,22 @@ interface RewardsUpgradeState { dismiss: () => void; } +/** + * Everything an account switch discards, in one place. + * + * Exported so a test setting up a fresh account can spread it rather than list + * the fields: a field added here and missed there leaks between tests, and a + * leaked `peak` is a promotion that silently stops being observed. + */ +export const REWARDS_UPGRADE_CLEARED_STATE = { + confirmed: undefined, + peak: undefined, + success: undefined, + pendingUntil: undefined, + timedOut: false, + savingsConfirmed: false, +} as const; + // Transient and global: one popup per observed promotion, even when several // screens consume the rewards query. Account switches discard the baseline. export const useRewardsUpgradeStore = create((set, get) => ({ @@ -31,22 +60,15 @@ export const useRewardsUpgradeStore = create((set, get) => savingsConfirmed: false, selectAccount: userId => { if (userId === get().userId) return; - set({ - userId, - session: get().session + 1, - confirmed: undefined, - success: undefined, - pendingUntil: undefined, - timedOut: false, - savingsConfirmed: false, - }); + set({ userId, session: get().session + 1, ...REWARDS_UPGRADE_CLEARED_STATE }); }, observe: (userId, session, data) => { const state = get(); if (state.userId !== userId || state.session !== session) return; - const promoted = isHigherTier(data.currentTier, state.confirmed?.currentTier); + const promoted = isHigherTier(data.currentTier, state.peak); set({ confirmed: data, + peak: promoted ? data.currentTier : (state.peak ?? data.currentTier), success: promoted ? data : state.success?.currentTier === data.currentTier diff --git a/store/useTierUpgradeStore.ts b/store/useTierUpgradeStore.ts new file mode 100644 index 000000000..d327b5f17 --- /dev/null +++ b/store/useTierUpgradeStore.ts @@ -0,0 +1,117 @@ +import { create } from 'zustand'; + +import { TIER_UPGRADE_MODAL } from '@/constants/modals'; +import { DEFAULT_LOCK_ASSET, type LockPaymentAsset } from '@/lib/tierLockPayment'; +import { RewardsTier } from '@/lib/types'; + +import type { ModalState } from '@/components/ResponsiveModal'; +import type { TierUpgradeRoute } from '@/lib/tierUpgrade'; + +/** The two tiers that are for sale. Core is what you have before you buy one. */ +export type PurchasableTier = RewardsTier.PRIME | RewardsTier.ULTRA; + +interface TierUpgradeState { + currentModal: ModalState; + previousModal: ModalState; + /** + * The tier being bought, or null for "whichever one is next". + * + * Null is what the Earn screen's CTA opens with: it knows the user wants to + * upgrade and has no idea which tier that means, and resolving it there would + * mean loading the membership on a screen that otherwise never asks for it. + */ + tier: PurchasableTier | null; + /** + * How they are paying, once they have chosen. + * + * Lives here rather than in the upgrade step so the review step can read it: + * the review is a sibling, not a child, and passing it down through the modal + * provider would make the provider know what a payment route is. + */ + route: TierUpgradeRoute | null; + /** + * The token the lock is paid with. + * + * Deliberately the user's choice rather than a balance the app picks for + * them: all three are FUSE to the tier, but they are not interchangeable to + * the person holding them — someone keeping FUSE liquid on purpose should not + * have their Savings spent instead, and vice versa. + */ + lockAsset: LockPaymentAsset; + open: (tier?: PurchasableTier | null) => void; + review: () => void; + selectToken: () => void; + back: () => void; + close: () => void; + setRoute: (route: TierUpgradeRoute) => void; + setLockAsset: (asset: LockPaymentAsset) => void; +} + +/** + * The tier upgrade flow, as one modal opened from anywhere. + * + * Global for the same reason `useUnstakeStore` is: the entry points are on + * three different screens, and a modal per entry point is a stack of overlays + * the moment two of those screens are mounted at once. + */ +export const useTierUpgradeStore = create()((set, get) => ({ + currentModal: TIER_UPGRADE_MODAL.CLOSE, + previousModal: TIER_UPGRADE_MODAL.CLOSE, + tier: null, + route: null, + lockAsset: DEFAULT_LOCK_ASSET, + + open: (tier = null) => + set({ + previousModal: get().currentModal, + currentModal: TIER_UPGRADE_MODAL.OPEN_UPGRADE, + tier, + // Cleared, not kept: the route on offer depends on the tier, and a "Cash" + // tab held over from a previous tier is a payment method this one may not + // sell. The upgrade step settles it again from the offer. + route: null, + // Back to the default too. A token picked for one upgrade is not a + // standing preference, and the balances behind it have moved since. + lockAsset: DEFAULT_LOCK_ASSET, + }), + + review: () => + set({ + previousModal: get().currentModal, + currentModal: TIER_UPGRADE_MODAL.OPEN_REVIEW, + }), + + selectToken: () => + set({ + previousModal: get().currentModal, + currentModal: TIER_UPGRADE_MODAL.OPEN_TOKEN_SELECTOR, + }), + + back: () => + set({ + previousModal: get().currentModal, + currentModal: TIER_UPGRADE_MODAL.OPEN_UPGRADE, + }), + + close: () => + set({ + previousModal: get().currentModal, + currentModal: TIER_UPGRADE_MODAL.CLOSE, + }), + + setRoute: route => set({ route }), + + // Picking one closes the picker: it is a one-tap choice, and making the user + // press back afterwards is a step that exists only because the screen is a + // separate one. + setLockAsset: asset => + set({ + lockAsset: asset, + previousModal: get().currentModal, + currentModal: TIER_UPGRADE_MODAL.OPEN_UPGRADE, + }), +})); + +/** Whether the upgrade flow is on screen. */ +export const isTierUpgradeOpen = (state: TierUpgradeState) => + state.currentModal.name !== TIER_UPGRADE_MODAL.CLOSE.name;