diff --git a/.env.example b/.env.example index be07ff9a2..cb397d876 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,9 @@ EXPO_PUBLIC_ALGEBRA_INFO_GRAPH=https://api.studio.thegraph.com/query/78455/algeb EXPO_PUBLIC_ENVIRONMENT=preview # Sandbox: skip the TransFi buy-crypto KYC gate on the client (pair with backend TRANSFI_SKIP_KYC). Never set in prod. EXPO_PUBLIC_TRANSFI_SKIP_KYC=false +# Comma-separated ISO alpha-2 codes TransFi prohibits; the cash deposit rows are hidden there. +# https://docs.transfi.com/docs/prohibited-countries +EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES=UA,RU,BY EXPO_PUBLIC_THIRDWEB_CLIENT_ID= EXPO_PUBLIC_PASSKEY_CERTIFICATES_HOST=solid.xyz diff --git a/assets/images/rewards-tiers/v4/airline.svg b/assets/images/rewards-tiers/v4/airline.svg new file mode 100644 index 000000000..80d68b525 --- /dev/null +++ b/assets/images/rewards-tiers/v4/airline.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/rewards-tiers/v4/airlines.png b/assets/images/rewards-tiers/v4/airlines.png new file mode 100644 index 000000000..e785e8c68 Binary files /dev/null and b/assets/images/rewards-tiers/v4/airlines.png differ diff --git a/assets/images/rewards-tiers/v4/background.png b/assets/images/rewards-tiers/v4/background.png new file mode 100644 index 000000000..1bdf1364e Binary files /dev/null and b/assets/images/rewards-tiers/v4/background.png differ diff --git a/assets/images/rewards-tiers/v4/cap.svg b/assets/images/rewards-tiers/v4/cap.svg new file mode 100644 index 000000000..392e41b48 --- /dev/null +++ b/assets/images/rewards-tiers/v4/cap.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/rewards-tiers/v4/card-core-still.png b/assets/images/rewards-tiers/v4/card-core-still.png new file mode 100644 index 000000000..f6952e06f Binary files /dev/null and b/assets/images/rewards-tiers/v4/card-core-still.png differ diff --git a/assets/images/rewards-tiers/v4/card-core.png b/assets/images/rewards-tiers/v4/card-core.png new file mode 100644 index 000000000..5818ad466 Binary files /dev/null and b/assets/images/rewards-tiers/v4/card-core.png differ diff --git a/assets/images/rewards-tiers/v4/card-prime-still.png b/assets/images/rewards-tiers/v4/card-prime-still.png new file mode 100644 index 000000000..ce4307634 Binary files /dev/null and b/assets/images/rewards-tiers/v4/card-prime-still.png differ diff --git a/assets/images/rewards-tiers/v4/card-prime.png b/assets/images/rewards-tiers/v4/card-prime.png new file mode 100644 index 000000000..b1fe7a2cc Binary files /dev/null and b/assets/images/rewards-tiers/v4/card-prime.png differ diff --git a/assets/images/rewards-tiers/v4/card-ultra-still.png b/assets/images/rewards-tiers/v4/card-ultra-still.png new file mode 100644 index 000000000..513f8edcd Binary files /dev/null and b/assets/images/rewards-tiers/v4/card-ultra-still.png differ diff --git a/assets/images/rewards-tiers/v4/card-ultra.png b/assets/images/rewards-tiers/v4/card-ultra.png new file mode 100644 index 000000000..f95821599 Binary files /dev/null and b/assets/images/rewards-tiers/v4/card-ultra.png differ diff --git a/assets/images/rewards-tiers/v4/lock.svg b/assets/images/rewards-tiers/v4/lock.svg new file mode 100644 index 000000000..4d489283e --- /dev/null +++ b/assets/images/rewards-tiers/v4/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/rewards-tiers/v4/rides.png b/assets/images/rewards-tiers/v4/rides.png new file mode 100644 index 000000000..576d56e59 Binary files /dev/null and b/assets/images/rewards-tiers/v4/rides.png differ diff --git a/assets/images/rewards-tiers/v4/stats-core-glass.png b/assets/images/rewards-tiers/v4/stats-core-glass.png new file mode 100644 index 000000000..248071ee0 Binary files /dev/null and b/assets/images/rewards-tiers/v4/stats-core-glass.png differ diff --git a/assets/images/rewards-tiers/v4/stats-prime-glass.png b/assets/images/rewards-tiers/v4/stats-prime-glass.png new file mode 100644 index 000000000..3bc53f076 Binary files /dev/null and b/assets/images/rewards-tiers/v4/stats-prime-glass.png differ diff --git a/assets/images/rewards-tiers/v4/stats-ultra-glass.png b/assets/images/rewards-tiers/v4/stats-ultra-glass.png new file mode 100644 index 000000000..bb350f79e Binary files /dev/null and b/assets/images/rewards-tiers/v4/stats-ultra-glass.png differ diff --git a/assets/images/rewards-tiers/v4/yield.svg b/assets/images/rewards-tiers/v4/yield.svg new file mode 100644 index 000000000..20797c525 --- /dev/null +++ b/assets/images/rewards-tiers/v4/yield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/airline-ring.svg b/assets/images/subscription-cashback/airline-ring.svg new file mode 100644 index 000000000..265eb3f1e --- /dev/null +++ b/assets/images/subscription-cashback/airline-ring.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/subscription-cashback/amazon.svg b/assets/images/subscription-cashback/amazon.svg new file mode 100644 index 000000000..c3e43e9ed --- /dev/null +++ b/assets/images/subscription-cashback/amazon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/subscription-cashback/apple-music.svg b/assets/images/subscription-cashback/apple-music.svg new file mode 100644 index 000000000..f9bf5c38d --- /dev/null +++ b/assets/images/subscription-cashback/apple-music.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/apple-tv.svg b/assets/images/subscription-cashback/apple-tv.svg new file mode 100644 index 000000000..278a005d8 --- /dev/null +++ b/assets/images/subscription-cashback/apple-tv.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/bolt.svg b/assets/images/subscription-cashback/bolt.svg new file mode 100644 index 000000000..f3b831358 --- /dev/null +++ b/assets/images/subscription-cashback/bolt.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/subscription-cashback/claude.svg b/assets/images/subscription-cashback/claude.svg new file mode 100644 index 000000000..1fef2c18c --- /dev/null +++ b/assets/images/subscription-cashback/claude.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/images/subscription-cashback/disney-plus.svg b/assets/images/subscription-cashback/disney-plus.svg new file mode 100644 index 000000000..b652792d7 --- /dev/null +++ b/assets/images/subscription-cashback/disney-plus.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/subscription-cashback/disney.svg b/assets/images/subscription-cashback/disney.svg new file mode 100644 index 000000000..908aa7d2c --- /dev/null +++ b/assets/images/subscription-cashback/disney.svg @@ -0,0 +1,91 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/subscription-cashback/emirates.svg b/assets/images/subscription-cashback/emirates.svg new file mode 100644 index 000000000..7fe1661f6 --- /dev/null +++ b/assets/images/subscription-cashback/emirates.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/gemini.svg b/assets/images/subscription-cashback/gemini.svg new file mode 100644 index 000000000..1c279ff36 --- /dev/null +++ b/assets/images/subscription-cashback/gemini.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/images/subscription-cashback/gojek.svg b/assets/images/subscription-cashback/gojek.svg new file mode 100644 index 000000000..add3cd0e4 --- /dev/null +++ b/assets/images/subscription-cashback/gojek.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/subscription-cashback/grab.svg b/assets/images/subscription-cashback/grab.svg new file mode 100644 index 000000000..3a1394f9e --- /dev/null +++ b/assets/images/subscription-cashback/grab.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/subscription-cashback/hbo.svg b/assets/images/subscription-cashback/hbo.svg new file mode 100644 index 000000000..841cafe4b --- /dev/null +++ b/assets/images/subscription-cashback/hbo.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/images/subscription-cashback/lock.svg b/assets/images/subscription-cashback/lock.svg new file mode 100644 index 000000000..4d489283e --- /dev/null +++ b/assets/images/subscription-cashback/lock.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/netflix.svg b/assets/images/subscription-cashback/netflix.svg new file mode 100644 index 000000000..fa0b9953b --- /dev/null +++ b/assets/images/subscription-cashback/netflix.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + diff --git a/assets/images/subscription-cashback/openai.svg b/assets/images/subscription-cashback/openai.svg new file mode 100644 index 000000000..b41c5f0e0 --- /dev/null +++ b/assets/images/subscription-cashback/openai.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/qatar.svg b/assets/images/subscription-cashback/qatar.svg new file mode 100644 index 000000000..84a6fcba2 --- /dev/null +++ b/assets/images/subscription-cashback/qatar.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/subscription-cashback/ride-ring.svg b/assets/images/subscription-cashback/ride-ring.svg new file mode 100644 index 000000000..265eb3f1e --- /dev/null +++ b/assets/images/subscription-cashback/ride-ring.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/subscription-cashback/singapore.svg b/assets/images/subscription-cashback/singapore.svg new file mode 100644 index 000000000..bd77c18c2 --- /dev/null +++ b/assets/images/subscription-cashback/singapore.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/subscription-cashback/spotify.svg b/assets/images/subscription-cashback/spotify.svg new file mode 100644 index 000000000..c3e83d59a --- /dev/null +++ b/assets/images/subscription-cashback/spotify.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/images/subscription-cashback/turkish.svg b/assets/images/subscription-cashback/turkish.svg new file mode 100644 index 000000000..8c6e17c0e --- /dev/null +++ b/assets/images/subscription-cashback/turkish.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/subscription-cashback/uber.svg b/assets/images/subscription-cashback/uber.svg new file mode 100644 index 000000000..114c0d86c --- /dev/null +++ b/assets/images/subscription-cashback/uber.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/subscription-cashback/youtube.svg b/assets/images/subscription-cashback/youtube.svg new file mode 100644 index 000000000..299a82779 --- /dev/null +++ b/assets/images/subscription-cashback/youtube.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx index c86290832..8f9cd1850 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx @@ -29,8 +29,8 @@ const APP_SCHEME = 'solid://'; * `mediaCapturePermissionGrantType` answers the WebView's own camera prompt, * which is asked separately from the OS permission. */ -export const OnramperWidget = ({ onOutcome }: OnramperWidgetProps) => { - const { data: session, isPending, isError, refetch } = useOnramperWidget(); +export const OnramperWidget = ({ destination = 'wallet', onOutcome }: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(destination); const [isOpeningBrowser, setIsOpeningBrowser] = useState(false); /** diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx index e4560c5d4..0f7fbdcc6 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx @@ -16,8 +16,8 @@ import { * missing entry raises no error — the step just quietly does nothing, deep * inside a provider's flow where we have no visibility at all. */ -export const OnramperWidget = (_props: OnramperWidgetProps) => { - const { data: session, isPending, isError, refetch } = useOnramperWidget(); +export const OnramperWidget = ({ destination = 'wallet' }: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(destination); if (isPending) return ; if (isError || !session) { diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx index 114974e55..dc74f610b 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx @@ -3,7 +3,15 @@ import { ActivityIndicator, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import type { OnramperDestination } from '@/lib/api'; + export interface OnramperWidgetProps { + /** + * What the purchase funds. The card funding screens pass `card`, which the + * backend resolves to the user's card deposit address; everything else funds + * the wallet. + */ + destination?: OnramperDestination; /** * Called when a provider redirects back to us, on the platforms that redirect * at all. Advisory only — not every provider honours a redirect, so the diff --git a/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx b/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx index 334f7ebeb..ad797a377 100644 --- a/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx +++ b/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx @@ -44,9 +44,10 @@ export const BuyCryptoFlowContent = ({ // Onramper's hosted widget. It lives here rather than in a switch of its // own because this is the generic embed point: the card funding modals // render whatever DepositModal they are handed, so one case reaches all - // three of them. + // three of them. Being opened from a card funding modal is also why it + // delivers to the card deposit address rather than the wallet. case DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name: - return ; + return ; default: return null; } diff --git a/components/BuyCrypto/Transfi/TransfiError.tsx b/components/BuyCrypto/Transfi/TransfiError.tsx index 0217c687f..9fcf01021 100644 --- a/components/BuyCrypto/Transfi/TransfiError.tsx +++ b/components/BuyCrypto/Transfi/TransfiError.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; -import { View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Platform, View } from 'react-native'; import { useRouter } from 'expo-router'; +import { openBrowserAsync } from 'expo-web-browser'; import { AlertTriangle, Clock, LifeBuoy, ShieldAlert, XCircle } from 'lucide-react-native'; import { useBuyCryptoNavigation } from '@/components/BuyCrypto/Transfi/BuyCryptoNavigation'; @@ -10,16 +11,23 @@ import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoKycRoute } from '@/hooks/useBuyCryptoKycRoute'; +import { useUpgradeTransfiKyc } from '@/hooks/useTransfi'; import { track } from '@/lib/analytics'; import { + asTransfiError, canCompleteProfile, + kycUpgradeLevel, type TransfiError as TransfiErrorType, transfiErrorTitle, } from '@/lib/transfiErrors'; +import { TransfiKycLevel } from '@/lib/types'; import { useTransfiStore } from '@/store/useTransfiStore'; const SUPPORT_EMAIL = 'support@solid.xyz'; +const UPGRADE_UNAVAILABLE_MESSAGE = + 'We couldn’t open the verification page. Please try again in a moment.'; + const ICON_BY_ACTION = { retry: AlertTriangle, adjust_amount: AlertTriangle, @@ -35,6 +43,13 @@ const formatFiat = (value: number | undefined, currency: string) => ? undefined : `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`; +/** + * Where a KYC upgrade stands once the user has asked for it: TransFi's page + * handed to them (or stopped by a popup blocker, so they have to open it + * themselves), or TransFi already reviewing a submission for that level. + */ +type UpgradeState = { state: 'opened' | 'blocked'; url: string } | { state: 'in_review' }; + /** * Terminal screen for a buy-crypto failure. * @@ -50,8 +65,12 @@ export const TransfiError = () => { const setModal = useBuyCryptoNavigation(); const error = useTransfiStore(state => state.error); const errorOrigin = useTransfiStore(state => state.errorOrigin); + const setError = useTransfiStore(state => state.setError); const reset = useTransfiStore(state => state.reset); const routeToKyc = useBuyCryptoKycRoute(); + const { mutateAsync: upgradeKyc, isPending: isUpgrading } = useUpgradeTransfiKyc(); + const [upgrade, setUpgrade] = useState(); + const [upgradeError, setUpgradeError] = useState(); useEffect(() => { if (!error) return; @@ -83,8 +102,67 @@ export const TransfiError = () => { ); } - const Icon = ICON_BY_ACTION[error.action]; - const primary = resolvePrimaryAction(error); + const upgradeLevel = kycUpgradeLevel(error); + const inReview = upgrade?.state === 'in_review'; + const Icon = inReview ? Clock : ICON_BY_ACTION[error.action]; + const primary = resolvePrimaryAction(error, upgrade); + + /** + * Hand TransFi's verification page to the browser rather than framing it — as + * with the hosted retry, the flow uses the camera and its own redirects. + */ + const openVerificationPage = async (url: string) => { + let opened = false; + if (Platform.OS === 'web') { + // A null handle means a popup blocker stopped it; the screen then leads + // with a button so the user can open it themselves. + opened = Boolean(window.open(url, '_blank', 'noopener,noreferrer')); + } else { + try { + await openBrowserAsync(url); + opened = true; + } catch (openError) { + console.error('Failed to open the TransFi verification page:', openError); + } + } + setUpgrade({ state: opened ? 'opened' : 'blocked', url }); + if (opened) track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_PAGE_OPENED, { code: error.code }); + }; + + /** + * Ask TransFi for its page for the level this refusal names. A refusal with a + * verdict of its own — an account TransFi won't serve, no profile to upgrade — + * replaces this screen's error; anything transient stays inline so the button + * can be pressed again. + */ + const startUpgrade = async (level: TransfiKycLevel) => { + setUpgradeError(undefined); + try { + const result = await upgradeKyc(level); + if (result.status === 'pending') { + track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_IN_REVIEW, { level: result.level }); + setUpgrade({ state: 'in_review' }); + return; + } + if (!result.kycUrl) { + setUpgradeError(UPGRADE_UNAVAILABLE_MESSAGE); + return; + } + await openVerificationPage(result.kycUrl); + } catch (upgradeFailure) { + const failure = asTransfiError(upgradeFailure); + track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_FAILED, { + code: failure.code, + action: failure.action, + }); + if (failure.action === 'retry') { + setUpgradeError(UPGRADE_UNAVAILABLE_MESSAGE); + return; + } + setUpgrade(undefined); + setError(failure, errorOrigin ?? undefined); + } + }; const handlePrimary = () => { if (!primary) return; @@ -111,22 +189,42 @@ export const TransfiError = () => { case 'kyc': void routeToKyc(); break; + case 'upgrade': + if (upgradeLevel) void startUpgrade(upgradeLevel); + break; + case 'reopen_upgrade': + if (upgrade && upgrade.state !== 'in_review') void openVerificationPage(upgrade.url); + break; } }; + // The limits only cap this purchase, so a smaller one still goes through + // while the next KYC level is outstanding. + const buySmallerAmount = () => { + track(TRACKING_EVENTS.BUY_CRYPTO_ERROR_ACTION_PRESSED, { + code: error.code, + choice: 'smaller_amount', + }); + setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT); + }; + const limits = describeLimits(error); + const title = inReview ? 'Verification in review' : transfiErrorTitle(error); + const message = upgradeMessage(upgrade) ?? error.message; + const hasSecondary = Boolean(upgradeLevel); return ( - + - - {transfiErrorTitle(error)} - - {error.message} + {title} + {message} + {upgradeError ? ( + {upgradeError} + ) : null} @@ -155,14 +253,44 @@ export const TransfiError = () => { {primary ? ( - + ) : null} + {hasSecondary ? ( + ) : null} - ); diff --git a/components/Rewards/NewRewards/SubscriptionCashbackSheet.native.tsx b/components/Rewards/NewRewards/SubscriptionCashbackSheet.native.tsx index f74ecdaeb..918f84b41 100644 --- a/components/Rewards/NewRewards/SubscriptionCashbackSheet.native.tsx +++ b/components/Rewards/NewRewards/SubscriptionCashbackSheet.native.tsx @@ -1,8 +1,10 @@ import React, { useCallback, useMemo, useRef, useState } from 'react'; -import { View } from 'react-native'; +import { useWindowDimensions, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { BottomSheetBackdrop, BottomSheetModal, BottomSheetScrollView } from '@gorhom/bottom-sheet'; +import { RewardsTier } from '@/lib/types'; + import SubscriptionCashbackContent from './SubscriptionCashbackContent'; import type { SubscriptionCashbackSheetProps } from './SubscriptionCashbackSheet.types'; @@ -10,14 +12,15 @@ import type { SubscriptionCashbackSheetProps } from './SubscriptionCashbackSheet const SubscriptionCashbackSheet = ({ trigger, onGetMoreCashback, + onUpgradeTier, triggerContainerClassName = 'flex-1', ...subscriptionData }: SubscriptionCashbackSheetProps) => { const insets = useSafeAreaInsets(); + const { height } = useWindowDimensions(); const sheetRef = useRef(null); - // Eleven merchant rows across three cards — this one runs nearly full height - // and scrolls, unlike the shorter cashback and yield boost sheets. - const snapPoints = useMemo(() => ['90%'], []); + // Categories with five merchants still scroll on smaller phones. + const snapPoints = useMemo(() => [Math.min(784, height * 0.9)], [height]); const [animationSession, setAnimationSession] = useState(0); const present = useCallback(() => { @@ -27,8 +30,14 @@ const SubscriptionCashbackSheet = ({ const dismiss = useCallback(() => sheetRef.current?.dismiss(), []); const handleGetMoreCashback = useCallback(() => { dismiss(); - onGetMoreCashback(); - }, [dismiss, onGetMoreCashback]); + if (onUpgradeTier) { + onUpgradeTier( + subscriptionData.currentTier === RewardsTier.CORE ? RewardsTier.PRIME : RewardsTier.ULTRA, + ); + } else { + onGetMoreCashback(); + } + }, [dismiss, onGetMoreCashback, onUpgradeTier, subscriptionData.currentTier]); const renderBackdrop = useCallback( (props: any) => ( @@ -66,12 +75,15 @@ const SubscriptionCashbackSheet = ({ > diff --git a/components/Rewards/NewRewards/SubscriptionCashbackSheet.tsx b/components/Rewards/NewRewards/SubscriptionCashbackSheet.tsx index 6f78c3705..c7f257f81 100644 --- a/components/Rewards/NewRewards/SubscriptionCashbackSheet.tsx +++ b/components/Rewards/NewRewards/SubscriptionCashbackSheet.tsx @@ -2,8 +2,15 @@ import { useState } from 'react'; import { ScrollView, View } from 'react-native'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; -import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog'; import { useDimension } from '@/hooks/useDimension'; +import { RewardsTier } from '@/lib/types'; import SubscriptionCashbackContent from './SubscriptionCashbackContent'; @@ -19,6 +26,7 @@ const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; const SubscriptionCashbackSheet = ({ trigger, onGetMoreCashback, + onUpgradeTier, triggerContainerClassName = 'flex-1', ...subscriptionData }: SubscriptionCashbackSheetProps) => { @@ -28,7 +36,13 @@ const SubscriptionCashbackSheet = ({ const handleGetMoreCashback = () => { setOpen(false); - onGetMoreCashback(); + if (onUpgradeTier) { + onUpgradeTier( + subscriptionData.currentTier === RewardsTier.CORE ? RewardsTier.PRIME : RewardsTier.ULTRA, + ); + } else { + onGetMoreCashback(); + } }; const handleOpenChange = (nextOpen: boolean) => { if (nextOpen) { @@ -38,12 +52,20 @@ const SubscriptionCashbackSheet = ({ }; const content = ( - + <> + Category cashback + + Cashback rates and eligible merchants by category. + + setOpen(false)} + /> + ); if (isScreenMedium) { @@ -57,9 +79,7 @@ const SubscriptionCashbackSheet = ({ trigger={trigger} contentKey="subscription-cashback" shouldAnimate={false} - // Eleven merchant rows overflow the viewport on short desktop - // windows; cap the card so the close button stays put and only the - // body scrolls. + // Longer categories scroll within the modal on short windows. fillViewportHeight > {content} @@ -74,10 +94,14 @@ const SubscriptionCashbackSheet = ({ {trigger} - {content} + + {content} + diff --git a/components/Rewards/NewRewards/SubscriptionCashbackSheet.types.ts b/components/Rewards/NewRewards/SubscriptionCashbackSheet.types.ts index c7dc98da9..a5430b1f5 100644 --- a/components/Rewards/NewRewards/SubscriptionCashbackSheet.types.ts +++ b/components/Rewards/NewRewards/SubscriptionCashbackSheet.types.ts @@ -1,6 +1,8 @@ +import type { RewardsTier } from '@/lib/types'; import type { ReactElement } from 'react'; export interface SubscriptionCashbackData { + currentTier: RewardsTier; /** Cashback % the tier earns back on eligible subscriptions. */ subscriptionDiscountRate: number; } @@ -8,5 +10,6 @@ export interface SubscriptionCashbackData { export interface SubscriptionCashbackSheetProps extends SubscriptionCashbackData { trigger: ReactElement<{ onPress?: () => void }>; onGetMoreCashback: () => void; + onUpgradeTier?: (tier: RewardsTier.PRIME | RewardsTier.ULTRA) => void; triggerContainerClassName?: string; } diff --git a/components/Rewards/NewRewards/TierBenefitsGrid.tsx b/components/Rewards/NewRewards/TierBenefitsGrid.tsx index b6b47fdf5..6a54de5af 100644 --- a/components/Rewards/NewRewards/TierBenefitsGrid.tsx +++ b/components/Rewards/NewRewards/TierBenefitsGrid.tsx @@ -3,6 +3,7 @@ import { Platform, Pressable, View } from 'react-native'; import { useIsSidebarShell } from '@/components/Navbar/Sidebar'; import { Text } from '@/components/ui/text'; +import { RewardsTier } from '@/lib/types'; import { cn, formatNumber } from '@/lib/utils'; import CashbackDetailsSheet from './CashbackDetailsSheet'; @@ -55,10 +56,14 @@ const BenefitCard = ({ title, description, icon, onPress }: BenefitCardProps) => }; interface TierBenefitsGridProps extends CashbackDetailsData, YieldBoostData { + currentTier: RewardsTier; + /** The unsampled API rate, so a Core preview stays locked in the sheet. */ + actualSubscriptionDiscountRate: number; /** Cashback % the tier earns back on subscriptions; 0 hides that card. */ subscriptionDiscountRate: number; onGetMoreCashback: () => void; onReferralsPress: () => void; + onUpgradeSubscriptionTier: (tier: RewardsTier.PRIME | RewardsTier.ULTRA) => void; } /** @@ -73,12 +78,16 @@ interface TierBenefitsGridProps extends CashbackDetailsData, YieldBoostData { * width rather than stretching it across the row. */ const TierBenefitsGrid = ({ + currentTier, + actualSubscriptionDiscountRate, subscriptionDiscountRate, yieldBoostPercentage, yieldBoostCap, yieldBoostEarned, + yieldBoostBalanceCap, onGetMoreCashback, onReferralsPress, + onUpgradeSubscriptionTier, ...cashbackData }: TierBenefitsGridProps) => { const subscriptionRate = `${formatNumber(subscriptionDiscountRate || 0, 2, 0)}%`; @@ -121,6 +130,7 @@ const TierBenefitsGrid = ({ yieldBoostPercentage={yieldBoostPercentage} yieldBoostCap={yieldBoostCap} yieldBoostEarned={yieldBoostEarned} + yieldBoostBalanceCap={yieldBoostBalanceCap} /> ), subscription: ( @@ -133,8 +143,10 @@ const TierBenefitsGrid = ({ icon={} /> } - subscriptionDiscountRate={subscriptionDiscountRate} + currentTier={currentTier} + subscriptionDiscountRate={actualSubscriptionDiscountRate} onGetMoreCashback={onGetMoreCashback} + onUpgradeTier={onUpgradeSubscriptionTier} /> ), }; diff --git a/components/Rewards/NewRewards/TierBenefitsPage.tsx b/components/Rewards/NewRewards/TierBenefitsPage.tsx new file mode 100644 index 000000000..16046db84 --- /dev/null +++ b/components/Rewards/NewRewards/TierBenefitsPage.tsx @@ -0,0 +1,812 @@ +import { type ReactNode, useEffect, useState } from 'react'; +import { StyleSheet, View } from 'react-native'; +import Animated, { + cancelAnimation, + Easing, + type SharedValue, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useIsFocused } from '@react-navigation/native'; + +import { + CoreCardPerkIcon, + CoreGlobePerkIcon, + CoreRocketPerkIcon, +} from '@/assets/images/rewards-tiers/core-tier-icons'; +import { Text } from '@/components/ui/text'; +import { getAsset } from '@/lib/assets'; +import { formatTierCashbackRate } from '@/lib/tierCashback'; +import { RewardsTier, type TierBenefits, type TierFees, type TierOffer } from '@/lib/types'; + +import SubscriptionBrandBadge from './SubscriptionBrandBadge'; +import { SUBSCRIPTION_CATEGORIES } from './subscriptionBrands'; +import { + TIER_LABELS, + TIER_PRESENTATION, + tierOfferRows, + tierPresentationContent, + tierPresentationFees, +} from './tierBenefitsPresentation'; +import TierStar from './TierHero/TierStar'; + +const ART = { + [RewardsTier.CORE]: { source: 'images/rewards-tiers/v4/card-core.png', width: 350, height: 254 }, + [RewardsTier.PRIME]: { + source: 'images/rewards-tiers/v4/card-prime.png', + width: 309, + height: 213, + }, + [RewardsTier.ULTRA]: { + source: 'images/rewards-tiers/v4/card-ultra.png', + width: 309, + height: 213, + }, +} as const; +const STATS_GLASS = { + [RewardsTier.CORE]: { source: 'images/rewards-tiers/v4/stats-core-glass.png', height: 93 }, + [RewardsTier.PRIME]: { source: 'images/rewards-tiers/v4/stats-prime-glass.png', height: 94 }, + [RewardsTier.ULTRA]: { source: 'images/rewards-tiers/v4/stats-ultra-glass.png', height: 95 }, +} as const; +// Measured from the Figma stars' transforms relative to each card's PNG bounds. +// The existing transparent animations include padding around their visible shape. +const CARD_STARS = { + [RewardsTier.CORE]: { + x: 170.78, + y: 124.87, + size: (118.25 * 470) / 412, + rotation: '-98.4225deg', + opacity: 0.9, + still: 'images/rewards-tiers/v4/card-core-still.png', + }, + [RewardsTier.PRIME]: { + // Compensate for the exported star's padding and the hero's Prime nudge. + x: 148.8, + y: 112.5, + size: (114.554 * 0.951 * 470) / 394, + rotation: '-8.4225deg', + opacity: 1, + still: 'images/rewards-tiers/v4/card-prime-still.png', + }, + [RewardsTier.ULTRA]: { + x: 147.98, + y: 106.98, + size: (113.029 * 470) / 388, + rotation: '-8.4225deg', + opacity: 1, + still: 'images/rewards-tiers/v4/card-ultra-still.png', + }, +} as const; +const scaleFor = (width: number) => Math.min(width / 420, 1); +const medium = (s: number) => ({ + fontFamily: 'MonaSans_500Medium', + fontSize: 16 * s, + lineHeight: 18 * s, + color: '#FFFFFF', +}); +const regular = (s: number) => ({ + fontFamily: 'MonaSans_400Regular', + fontSize: 14 * s, + lineHeight: 16 * s, + color: 'rgba(255,255,255,0.7)', +}); + +/** One overscanned backdrop, driven by the same UI-thread position as the pager. */ +export function TierBenefitsBackground({ + position, + width, + topInset, +}: { + position: SharedValue; + width: number; + topInset: number; +}) { + const s = scaleFor(width); + const reduceMotion = useReducedMotion(); + const motion = useAnimatedStyle(() => ({ + transform: [ + { + translateX: reduceMotion + ? 104 * s + : Math.max(0, Math.min(2, -position.value / width)) * 104 * s, + }, + ], + })); + return ( + + + + + + + ); +} + +function TierCardArtwork({ + tier, + s, + active, + position, + width, +}: { + tier: RewardsTier; + s: number; + active: boolean; + position: SharedValue; + width: number; +}) { + const reduceMotion = useReducedMotion(); + const [starReady, setStarReady] = useState(false); + const [baseReady, setBaseReady] = useState(false); + const [posterReady, setPosterReady] = useState(false); + const focused = useIsFocused(); + const entrance = useSharedValue(reduceMotion ? 1 : 0); + const artworkReady = posterReady || (baseReady && starReady); + useEffect(() => { + if (reduceMotion) { + entrance.value = 1; + } else if (!focused) { + cancelAnimation(entrance); + entrance.value = 0; + } else if (artworkReady) { + entrance.value = withTiming(1, { duration: 560, easing: Easing.out(Easing.cubic) }); + } + return () => cancelAnimation(entrance); + }, [artworkReady, entrance, focused, reduceMotion]); + const index = [RewardsTier.CORE, RewardsTier.PRIME, RewardsTier.ULTRA].indexOf(tier); + const cardMotion = useAnimatedStyle(() => { + const distance = reduceMotion ? 0 : Math.max(-1, Math.min(1, -position.value / width - index)); + // A reversible, gentle exit/entrance that follows the finger and the pager's snap. + const progress = Math.min(1, Math.abs(distance) / 0.9); + const fade = progress * progress * (3 - 2 * progress); + return { + opacity: (1 - fade) * entrance.value, + transform: [ + { translateY: (Math.abs(distance) * 8 + (1 - entrance.value) * 12) * s }, + { rotate: `${distance * 24 - (1 - entrance.value) * 10}deg` }, + { scale: (0.82 + entrance.value * 0.18) * (1 - fade * 0.18) }, + ], + }; + }); + const art = ART[tier]; + const star = CARD_STARS[tier]; + const showStill = !starReady || !baseReady || reduceMotion; + return ( + + setBaseReady(true)} + style={{ width: art.width * s, height: art.height * s }} + /> + + setStarReady(true)} + /> + + {showStill && ( + setPosterReady(true)} + style={StyleSheet.absoluteFill} + /> + )} + + ); +} + +function Panel({ title, s, children }: { title?: string; s: number; children: ReactNode }) { + return ( + + {title && ( + <> + + {title} + + + + )} + {children} + + ); +} + +function ValuePill({ value, s, locked = false }: { value: string; s: number; locked?: boolean }) { + return ( + + {locked && ( + + )} + + {value} + + + ); +} + +function StatsBand({ + tier, + s, + benefits, +}: { + tier: RewardsTier; + s: number; + benefits?: TierBenefits; +}) { + const content = tierPresentationContent(tier, benefits); + return ( + + + + {content.stats.map(stat => { + const percent = stat.value.endsWith('%'); + const dollar = stat.value.startsWith('$'); + return ( + + + {dollar && ( + + $ + + )} + + {stat.value.replace(/[%$]/g, '')} + + {percent && ( + + % + + )} + + {stat.label} + + ); + })} + + + ); +} + +function PerkIcon({ + tier, + index, + s, + subscriptionRate, +}: { + tier: RewardsTier; + index: number; + s: number; + subscriptionRate: string | null; +}) { + if (tier !== RewardsTier.CORE && index === 0) + return ( + + ); + return ( + + {tier === RewardsTier.CORE ? ( + + {index === 0 ? ( + + ) : index === 1 ? ( + + ) : ( + + )} + + ) : tier === RewardsTier.ULTRA && index === 2 ? ( + + ) : index === 1 ? ( + + + {subscriptionRate?.replace('%', '')} + + + % + + + ) : ( + + )} + + ); +} + +function PerksPanel({ + tier, + s, + benefits, +}: { + tier: RewardsTier; + s: number; + benefits?: TierBenefits; +}) { + return ( + + + {tierPresentationContent(tier, benefits).perks.map((perk, index) => ( + + {index > 0 && ( + + )} + + + + {perk.title} + {perk.description} + + + + ))} + + + ); +} + +function CashbackPanel({ + tier, + s, + benefits, +}: { + tier: RewardsTier; + s: number; + benefits?: TierBenefits; +}) { + const content = tierPresentationContent(tier, benefits); + const categories = [ + ...SUBSCRIPTION_CATEGORIES.map(category => ({ + ...category, + rate: content.subscriptionRate, + asset: null, + })), + { + key: 'rides', + label: 'Rides', + rate: content.rideRate, + brands: [], + asset: 'images/rewards-tiers/v4/rides.png' as const, + }, + { + key: 'airlines', + label: 'Airlines', + rate: content.airlineRate, + brands: [], + asset: 'images/rewards-tiers/v4/airlines.png' as const, + }, + ]; + return ( + + + + Every purchase + + + {categories.map(category => ( + + + {category.label} + {category.asset ? ( + + ) : ( + + {category.brands.map((brand, i) => ( + + ))} + + )} + + + + ))} + + + ); +} + +export function TierBenefitsPage({ + tier, + active, + width, + topInset, + bottomInset, + subtitle, + current, + position, + fees, + benefits, + offer, + showUpgradeSpace, +}: { + tier: RewardsTier; + active: boolean; + width: number; + topInset: number; + bottomInset: number; + subtitle: ReactNode; + current: boolean; + position: SharedValue; + fees?: TierFees; + benefits?: TierBenefits; + offer?: TierOffer; + showUpgradeSpace: boolean; +}) { + const s = scaleFor(width); + const table = tierPresentationFees(tier, fees); + const offers = tierOfferRows(offer); + const focused = useIsFocused(); + const reduceMotion = useReducedMotion(); + const entrance = useSharedValue(reduceMotion ? 1 : 0); + + useEffect(() => { + entrance.value = reduceMotion + ? 1 + : focused + ? withTiming(1, { duration: 520, easing: Easing.out(Easing.cubic) }) + : 0; + return () => cancelAnimation(entrance); + }, [entrance, focused, reduceMotion]); + + const index = [RewardsTier.CORE, RewardsTier.PRIME, RewardsTier.ULTRA].indexOf(tier); + const contentMotion = useAnimatedStyle(() => { + const distance = reduceMotion ? 0 : Math.min(1, Math.abs(-position.value / width - index)); + const fade = distance * distance * (3 - 2 * distance); + return { + opacity: entrance.value * (1 - fade), + transform: [{ translateY: ((1 - entrance.value) * 28 + fade * 28) * s }], + }; + }); + return ( + + + + {TIER_PRESENTATION[tier].headline} + + + {current ? ( + + + Your current tier + + + ) : ( + subtitle + )} + + + + + + + + + + + + {table.lines.map(line => ( + + + {line.label} + + + + ))} + + Cashback cap + + {table.cashbackCap} + + + + + {tier !== RewardsTier.CORE && offers.length > 0 && ( + + + {offers.map(row => ( + + + {row.label} + + + + ))} + + + )} + + + ); +} + +const styles = StyleSheet.create({ + divider: { height: 1, backgroundColor: 'rgba(255,255,255,0.1)' }, +}); diff --git a/components/Rewards/NewRewards/TierHero/TierStar.ios.tsx b/components/Rewards/NewRewards/TierHero/TierStar.ios.tsx index 423c6d1e1..6ec65d494 100644 --- a/components/Rewards/NewRewards/TierHero/TierStar.ios.tsx +++ b/components/Rewards/NewRewards/TierHero/TierStar.ios.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from 'react'; import { useVideoPlayer, VideoView } from 'expo-video'; import { RewardsTier } from '@/lib/types'; @@ -34,7 +35,18 @@ const TIER_STAR_VIDEOS: Record = { [RewardsTier.ULTRA]: require('@/assets/animations/star-3.mov'), }; -const TierStar = ({ tier, size = TIER_STAR_SIZES[tier] }: { tier: RewardsTier; size?: number }) => { +const TierStar = ({ + tier, + size = TIER_STAR_SIZES[tier], + playing = true, + onReady, +}: { + tier: RewardsTier; + size?: number; + playing?: boolean; + onReady?: () => void; +}) => { + const [hasFrame, setHasFrame] = useState(false); const player = useVideoPlayer(TIER_STAR_VIDEOS[tier], p => { p.loop = true; p.muted = true; @@ -43,6 +55,12 @@ const TierStar = ({ tier, size = TIER_STAR_SIZES[tier] }: { tier: RewardsTier; s p.audioMixingMode = 'mixWithOthers'; p.play(); }); + useEffect(() => { + // Warm the first frame once, then pause inactive stars without destroying + // their player or decoded surface. A slide change can resume immediately. + if (playing || !hasFrame) player.play(); + else player.pause(); + }, [hasFrame, player, playing]); return ( { + setHasFrame(true); + onReady?.(); + }} // Decorative, and it sits under the pager's swipe gesture. pointerEvents="none" accessible={false} diff --git a/components/Rewards/NewRewards/TierHero/TierStar.tsx b/components/Rewards/NewRewards/TierHero/TierStar.tsx index 6d501b737..7f753df83 100644 --- a/components/Rewards/NewRewards/TierHero/TierStar.tsx +++ b/components/Rewards/NewRewards/TierHero/TierStar.tsx @@ -1,3 +1,5 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; import { Image } from 'expo-image'; import { RewardsTier } from '@/lib/types'; @@ -14,17 +16,43 @@ const TIER_STAR_ANIMATIONS: Record = { [RewardsTier.ULTRA]: require('@/assets/animations/star-3.webp'), }; -const TierStar = ({ tier, size = TIER_STAR_SIZES[tier] }: { tier: RewardsTier; size?: number }) => ( - -); +const TierStar = ({ + tier, + size = TIER_STAR_SIZES[tier], + playing = true, + onReady, +}: { + tier: RewardsTier; + size?: number; + playing?: boolean; + onReady?: () => void; +}) => { + const image = useRef(null); + const syncPlayback = useCallback(() => { + // Browsers render WebP animation themselves; the playback methods are native-only. + if (Platform.OS === 'web') return; + const action = playing ? image.current?.startAnimating() : image.current?.stopAnimating(); + void action?.catch(() => undefined); + }, [playing]); + useEffect(syncPlayback, [syncPlayback]); + return ( + + ); +}; export default TierStar; diff --git a/components/Rewards/NewRewards/TierHero/__tests__/TierStar.ios.test.tsx b/components/Rewards/NewRewards/TierHero/__tests__/TierStar.ios.test.tsx new file mode 100644 index 000000000..1b867a3eb --- /dev/null +++ b/components/Rewards/NewRewards/TierHero/__tests__/TierStar.ios.test.tsx @@ -0,0 +1,58 @@ +import React from 'react'; + +import TierStar from '@/components/Rewards/NewRewards/TierHero/TierStar.ios'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); +const mockPlayer = { play: jest.fn(), pause: jest.fn(), loop: false, muted: false }; + +jest.mock('@/assets/animations/star-1.mov', () => 1); +jest.mock('@/assets/animations/star-2.mov', () => 2); +jest.mock('@/assets/animations/star-3.mov', () => 3); +jest.mock('expo-video', () => ({ + VideoView: 'VideoView', + useVideoPlayer: (_source: unknown, setup: (player: typeof mockPlayer) => void) => { + const React = jest.requireActual('react'); + const initialized = React.useRef(false); + if (!initialized.current) { + setup(mockPlayer); + initialized.current = true; + } + return mockPlayer; + }, +})); + +describe('iOS tier star playback', () => { + beforeEach(() => jest.clearAllMocks()); + + it('warms the first frame, pauses offscreen, and resumes the same player', async () => { + const ready = jest.fn(); + let tree: ReturnType; + await act(async () => { + tree = create(); + }); + expect(mockPlayer.play).toHaveBeenCalled(); + expect(mockPlayer.pause).not.toHaveBeenCalled(); + await act(async () => tree.root.findByType('VideoView').props.onFirstFrameRender()); + expect(ready).toHaveBeenCalledTimes(1); + expect(mockPlayer.pause).toHaveBeenCalledTimes(1); + mockPlayer.play.mockClear(); + await act(async () => { + tree.update(); + }); + expect(mockPlayer.play).toHaveBeenCalledTimes(1); + expect(tree.root.findByType('VideoView').props.player).toBe(mockPlayer); + await act(async () => tree.unmount()); + }); + + it('keeps a visible star playing after its first frame is ready', async () => { + let tree: ReturnType; + await act(async () => { + tree = create(); + }); + await act(async () => tree.root.findByType('VideoView').props.onFirstFrameRender()); + expect(mockPlayer.pause).not.toHaveBeenCalled(); + await act(async () => tree.unmount()); + }); +}); diff --git a/components/Rewards/NewRewards/TierSwitcher.tsx b/components/Rewards/NewRewards/TierSwitcher.tsx index f0445790a..00904ce3a 100644 --- a/components/Rewards/NewRewards/TierSwitcher.tsx +++ b/components/Rewards/NewRewards/TierSwitcher.tsx @@ -1,6 +1,14 @@ -import { useEffect } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; -import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import { type RefObject, useEffect } from 'react'; +import { Platform, Pressable, StyleSheet, View } from 'react-native'; +import Animated, { + interpolateColor, + type SharedValue, + useAnimatedStyle, + useReducedMotion, + useSharedValue, + withTiming, +} from 'react-native-reanimated'; +import { BlurView } from 'expo-blur'; import { EASE_OUT_QUINT } from '@/components/Card/NewCardDetails/heroMotion'; import { Text } from '@/components/ui/text'; @@ -19,12 +27,56 @@ const PILL_BORDER_COLOR = 'rgba(255, 255, 255, 0.14)'; const PILL_TIMING = { duration: 240, easing: EASE_OUT_QUINT }; const LABEL_STYLE = { fontFamily: 'MonaSans_500Medium', fontSize: 14, lineHeight: 17 } as const; +const AnimatedLabel = Animated.createAnimatedComponent(Text); + +function TierLabel({ + label, + index, + selected, + light, + progress, + pageWidth, + scale, +}: { + label: string; + index: number; + selected: boolean; + light: boolean; + progress?: SharedValue; + pageWidth?: number; + scale: number; +}) { + const color = useAnimatedStyle(() => ({ + color: + light && progress && pageWidth + ? interpolateColor( + Math.min(1, Math.abs(-progress.value / pageWidth - index)), + [0, 1], + ['#0F0F11', 'rgba(255,255,255,0.6)'], + ) + : selected + ? light + ? '#0F0F11' + : '#FFFFFF' + : 'rgba(255,255,255,0.6)', + })); + return ( + + {label} + + ); +} interface TierSwitcherProps { tiers: readonly RewardsTier[]; labels: Record; selected: RewardsTier; onSelect: (tier: RewardsTier) => void; + appearance?: 'dark' | 'light'; + progress?: SharedValue; + pageWidth?: number; + scale?: number; + blurTarget?: RefObject; } /** @@ -37,26 +89,90 @@ interface TierSwitcherProps { * track's padding box while the pill's were not — which is what left the pill sitting * beside the selected tab instead of under it. */ -const TierSwitcher = ({ tiers, labels, selected, onSelect }: TierSwitcherProps) => { - const tabWidth = (TRACK_WIDTH - TRACK_INSET * 2) / tiers.length; +const TierSwitcher = ({ + tiers, + labels, + selected, + onSelect, + appearance = 'dark', + progress, + pageWidth, + scale = 1, + blurTarget, +}: TierSwitcherProps) => { + const light = appearance === 'light'; + const reduceMotion = useReducedMotion(); + const trackWidth = (light ? 248 : TRACK_WIDTH) * scale; + const trackHeight = (light ? 33 : TRACK_HEIGHT) * scale; + const tabHeight = (light ? 25 : TAB_HEIGHT) * scale; + const inset = (trackHeight - tabHeight) / 2; + const tabWidth = (trackWidth - inset * 2) / tiers.length; + const pillWidth = tabWidth; const selectedIndex = Math.max(tiers.indexOf(selected), 0); - const pillX = useSharedValue(TRACK_INSET + selectedIndex * tabWidth); + const pillX = useSharedValue(inset + selectedIndex * tabWidth + (tabWidth - pillWidth) / 2); useEffect(() => { - pillX.value = withTiming(TRACK_INSET + selectedIndex * tabWidth, PILL_TIMING); - }, [pillX, selectedIndex, tabWidth]); + const x = inset + selectedIndex * tabWidth + (tabWidth - pillWidth) / 2; + pillX.value = reduceMotion ? x : withTiming(x, PILL_TIMING); + }, [inset, pillWidth, pillX, reduceMotion, selectedIndex, tabWidth]); - const pillStyle = useAnimatedStyle(() => ({ transform: [{ translateX: pillX.value }] })); + const pillStyle = useAnimatedStyle(() => ({ + transform: [ + { + translateX: + progress && pageWidth && !reduceMotion + ? inset + + Math.max(0, Math.min(tiers.length - 1, -progress.value / pageWidth)) * tabWidth + + (tabWidth - pillWidth) / 2 + : pillX.value, + }, + ], + })); return ( - + {light && ( + + )} + - {tiers.map(tier => { + {tiers.map((tier, index) => { const isSelected = tier === selected; return ( @@ -64,13 +180,21 @@ const TierSwitcher = ({ tiers, labels, selected, onSelect }: TierSwitcherProps) key={tier} accessibilityRole="tab" accessibilityState={{ selected: isSelected }} + aria-selected={isSelected} className="items-center justify-center rounded-full transition-all active:opacity-70" onPress={() => onSelect(tier)} - style={{ height: TAB_HEIGHT, width: tabWidth }} + style={{ height: tabHeight, width: tabWidth }} + hitSlop={{ top: 10, bottom: 10 }} > - - {labels[tier]} - + ); })} diff --git a/components/Rewards/NewRewards/YieldBoostContent.tsx b/components/Rewards/NewRewards/YieldBoostContent.tsx index 7d439824f..0441baac1 100644 --- a/components/Rewards/NewRewards/YieldBoostContent.tsx +++ b/components/Rewards/NewRewards/YieldBoostContent.tsx @@ -2,7 +2,7 @@ import { View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { cn, formatNumber, formatWholeDollars } from '@/lib/utils'; +import { cn, formatBalanceUSD, formatNumber, formatWholeDollars } from '@/lib/utils'; import RewardsDiamondIcon from './RewardsDiamondIcon'; @@ -24,6 +24,7 @@ const YieldBoostContent = ({ yieldBoostPercentage, yieldBoostCap, yieldBoostEarned, + yieldBoostBalanceCap = 0, onClose, animationSession, isSheet = true, @@ -44,9 +45,11 @@ const YieldBoostContent = ({ className="mt-[7px] w-[284px] text-center text-base text-white/70" style={{ fontFamily: 'MonaSans_400Regular', lineHeight: 18 }} > - {yieldBoostCap > 0 - ? `Earn USDC on top of your savings deposits, up to ${formatWholeDollars(yieldBoostCap)}` - : 'Earn USDC on top of your savings deposits'} + {yieldBoostBalanceCap > 0 + ? `Earn soFUSE on top of your savings yield, on your first ${formatWholeDollars(yieldBoostBalanceCap)}` + : yieldBoostCap > 0 + ? `Earn USDC on top of your savings deposits, up to ${formatWholeDollars(yieldBoostCap)}` + : 'Earn USDC on top of your savings deposits'} @@ -59,9 +62,7 @@ const YieldBoostContent = ({ Total earned - - {formatWholeDollars(yieldBoostEarned)} - + {formatBalanceUSD(yieldBoostEarned)} diff --git a/components/Rewards/NewRewards/YieldBoostSheet.types.ts b/components/Rewards/NewRewards/YieldBoostSheet.types.ts index 49b49ab11..8223e2b5f 100644 --- a/components/Rewards/NewRewards/YieldBoostSheet.types.ts +++ b/components/Rewards/NewRewards/YieldBoostSheet.types.ts @@ -5,8 +5,14 @@ export interface YieldBoostData { yieldBoostPercentage: number; /** Ceiling on boost payouts for the tier, in USD. 0 hides the cap copy. */ yieldBoostCap: number; - /** Boost payouts the user has received so far, in USD. */ + /** Boost the user has earned so far, claimed or not, in USD. */ yieldBoostEarned: number; + /** + * Savings balance the boost is paid on, in USD (Prime $10K, Ultra $25K). + * When set, the copy describes the soFUSE boost and its cap; absent or 0 + * keeps the older payout-cap wording for a backend that doesn't send it yet. + */ + yieldBoostBalanceCap?: number; } export interface YieldBoostSheetProps extends YieldBoostData { diff --git a/components/Rewards/NewRewards/__tests__/categoryCashback.test.ts b/components/Rewards/NewRewards/__tests__/categoryCashback.test.ts new file mode 100644 index 000000000..d84559d7a --- /dev/null +++ b/components/Rewards/NewRewards/__tests__/categoryCashback.test.ts @@ -0,0 +1,38 @@ +import { categoryCashbackPresentation } from '@/components/Rewards/NewRewards/categoryCashback'; +import { RewardsTier } from '@/lib/types'; + +describe('category cashback presentation', () => { + it('keeps the live subscription rate and uses the Prime ride rate', () => { + const result = categoryCashbackPresentation(RewardsTier.PRIME, 25); + expect(result.rates).toEqual({ ai: 25, streaming: 25, music: 25, rides: 8, airlines: 0 }); + expect(result.headlineRate).toBe(25); + expect(result.actionLabel).toBe('Upgrade to Ultra'); + }); + + it('unlocks airlines for Ultra and replaces the upgrade action with dismissal', () => { + const result = categoryCashbackPresentation(RewardsTier.ULTRA, 20); + expect(result.rates).toEqual({ ai: 20, streaming: 20, music: 20, rides: 10, airlines: 10 }); + expect(result.actionLabel).toBe('Got it'); + }); + + it('shows the locked Core offer without granting the preview benefit', () => { + const result = categoryCashbackPresentation(RewardsTier.CORE, 0); + expect(Object.values(result.rates)).toEqual([0, 0, 0, 0, 0]); + expect(result.headlineRate).toBe(20); + expect(result.actionLabel).toBe('Upgrade to Prime'); + expect(result.upgradeTier).toBe(RewardsTier.PRIME); + }); + + it('does not replace an explicit zero or invalid API rate with a design rate', () => { + for (const rate of [0, -1, NaN, Infinity]) { + const result = categoryCashbackPresentation(RewardsTier.PRIME, rate); + expect(result.rates.ai).toBe(0); + expect(result.rates.music).toBe(0); + expect(result.headlineRate).toBe(8); + } + }); + + it('bases the headline on the highest unlocked category rate', () => { + expect(categoryCashbackPresentation(RewardsTier.ULTRA, 5).headlineRate).toBe(10); + }); +}); diff --git a/components/Rewards/NewRewards/__tests__/tierBenefitsPresentation.test.ts b/components/Rewards/NewRewards/__tests__/tierBenefitsPresentation.test.ts new file mode 100644 index 000000000..78a0fe287 --- /dev/null +++ b/components/Rewards/NewRewards/__tests__/tierBenefitsPresentation.test.ts @@ -0,0 +1,119 @@ +import { + TIER_PRESENTATION, + tierOfferRows, + tierOfferSubtitle, + tierPresentationContent, + tierPresentationFees, +} from '@/components/Rewards/NewRewards/tierBenefitsPresentation'; +import { RewardsTier, type TierBenefits, type TierFees, type TierOffer } from '@/lib/types'; + +const { CORE, PRIME, ULTRA } = RewardsTier; +const benefits = (tier: RewardsTier, overrides: Partial = {}) => + ({ tier, ...overrides }) as TierBenefits; +const offer = (overrides: Partial = {}): TierOffer => ({ + tier: PRIME, + annualFeeUsd: 149.5, + cashAvailable: true, + lockFuse: 37_500, + lockAvailable: true, + held: false, + ...overrides, +}); + +describe('tierPresentationContent', () => { + it('quotes actual subscription and yield benefits consistently across stats and perks', () => { + const content = tierPresentationContent( + PRIME, + benefits(PRIME, { + subscriptionDiscountRate: 25, + yieldBoostPercentage: 2.5, + yieldBoostBalanceCap: 12_500, + }), + ); + expect(content.subscriptionRate).toBe('25%'); + expect(content.stats).toContainEqual({ value: '25%', label: 'Subscriptions' }); + expect(content.stats).toContainEqual({ value: '+2.5%', label: 'Yield boost' }); + expect(content.perks[0].description).toBe('+2.5% APY on up to $12,500 in savings'); + expect(content.perks[1].description).toBe('25% on subscriptions, 8% on rides'); + }); + + it('preserves meaningful zeros instead of promising fallback rewards', () => { + const content = tierPresentationContent( + ULTRA, + benefits(ULTRA, { + subscriptionDiscountRate: 0, + yieldBoostPercentage: 0, + yieldBoostBalanceCap: 0, + }), + ); + expect(content.subscriptionRate).toBe('0%'); + expect(content.stats).toContainEqual({ value: '0%', label: 'Subscriptions' }); + expect(content.stats).toContainEqual({ value: '0%', label: 'Yield boost' }); + expect(content.perks[0].description).toBe('0% APY on up to $0 in savings'); + expect(content.perks[1].description).toBe('0% on subscriptions, 10% on rides'); + }); + + it.each([CORE, PRIME, ULTRA])('keeps the design when %s numerical fields are absent', tier => { + expect(tierPresentationContent(tier)).toEqual(TIER_PRESENTATION[tier]); + expect(tierPresentationContent(tier, benefits(tier))).toEqual(TIER_PRESENTATION[tier]); + }); + + it('uses a partial live response without replacing other design fallback values', () => { + const content = tierPresentationContent( + ULTRA, + benefits(ULTRA, { subscriptionDiscountRate: 50 }), + ); + expect(content.subscriptionRate).toBe('50%'); + expect(content.stats).toContainEqual({ value: '+3%', label: 'Yield boost' }); + expect(content.perks[0].description).toBe('+3% APY on up to $25K in savings'); + expect(content.airlineRate).toBe('10%'); + expect(TIER_PRESENTATION[ULTRA].subscriptionRate).toBe('20%'); + }); +}); + +describe('tierPresentationFees', () => { + it('preserves every authoritative fee row, including stocks, and actual cashback caps', () => { + const live = { + lines: [ + { key: 'fx', label: 'FX conversion', rate: 0.0015, value: '0.15%' }, + { key: 'stocks', label: 'Stocks', rate: 0.002, value: '0.2%' }, + ], + cashbackCap: 'Up to $175 monthly', + } as TierFees; + expect(tierPresentationFees(PRIME, live)).toEqual({ + lines: live.lines, + cashbackCap: 'Up to $175 monthly', + }); + }); + + it('uses the design fallback only when the actual fee table is absent or empty', () => { + const fallback = tierPresentationFees(PRIME); + expect(tierPresentationFees(PRIME, { lines: [] } as unknown as TierFees)).toEqual(fallback); + expect(fallback.lines.find(line => line.key === 'bank_deposit')?.value).toBe('0.25%'); + expect(fallback.lines.find(line => line.key === 'fx')?.value).toBe('Free'); + }); +}); + +describe('membership offer presentation', () => { + it('quotes actual endpoint prices and lock amounts instead of invented defaults', () => { + expect(tierOfferRows(offer())).toEqual([ + { label: 'Annual membership', value: '$149.5/year' }, + { label: 'Or lock FUSE', value: '37.5K FUSE' }, + ]); + expect(tierOfferSubtitle(offer())).toBe('$149.5/year or 37.5K FUSE locked'); + }); + + it('does not invent an offer when membership data or available routes are missing', () => { + expect(tierOfferRows(undefined)).toEqual([]); + expect(tierOfferSubtitle(undefined)).toBeNull(); + const unavailable = offer({ cashAvailable: false, lockAvailable: false }); + expect(tierOfferRows(unavailable)).toEqual([]); + expect(tierOfferSubtitle(unavailable)).toBeNull(); + expect(tierOfferRows(offer({ annualFeeUsd: null, lockFuse: 0 }))).toEqual([]); + }); + + it('shows only the route actually on sale', () => { + expect(tierOfferSubtitle(offer({ cashAvailable: false }))).toBe('Requires 37.5K FUSE locked'); + expect(tierOfferSubtitle(offer({ lockAvailable: false }))).toBe('$149.5/year'); + }); +}); diff --git a/components/Rewards/NewRewards/categoryCashback.ts b/components/Rewards/NewRewards/categoryCashback.ts new file mode 100644 index 000000000..771fd8494 --- /dev/null +++ b/components/Rewards/NewRewards/categoryCashback.ts @@ -0,0 +1,53 @@ +import { RewardsTier } from '@/lib/types'; + +export const CATEGORY_CASHBACK_TABS = [ + { key: 'ai', label: 'AI' }, + { key: 'streaming', label: 'Streaming' }, + { key: 'music', label: 'Music' }, + { key: 'rides', label: 'Rides' }, + { key: 'airlines', label: 'Airlines' }, +] as const; + +export type CashbackCategoryKey = (typeof CATEGORY_CASHBACK_TABS)[number]['key']; + +/** + * Subscription rates still come from rewards/user-data. Rides and airlines + * have no separate API fields yet; these are the v4 Figma tier values, shared + * by the category sheet's rate, locked state and headline. + */ +export const categoryCashbackPresentation = ( + tier: RewardsTier, + subscriptionDiscountRate: number, +) => { + const subscriptionRate = + Number.isFinite(subscriptionDiscountRate) && subscriptionDiscountRate > 0 + ? subscriptionDiscountRate + : 0; + const rideRate = tier === RewardsTier.ULTRA ? 10 : tier === RewardsTier.PRIME ? 8 : 0; + const airlineRate = tier === RewardsTier.ULTRA ? 10 : 0; + const rates: Record = { + ai: subscriptionRate, + streaming: subscriptionRate, + music: subscriptionRate, + rides: rideRate, + airlines: airlineRate, + }; + + return { + rates, + headlineRate: tier === RewardsTier.CORE ? 20 : Math.max(...Object.values(rates)), + subtitle: + tier === RewardsTier.CORE + ? 'on subscriptions, rides and flights with Prime or Ultra' + : tier === RewardsTier.ULTRA + ? 'on subscriptions, rides and flights' + : 'on subscriptions and rides', + actionLabel: + tier === RewardsTier.CORE + ? 'Upgrade to Prime' + : tier === RewardsTier.PRIME + ? 'Upgrade to Ultra' + : 'Got it', + upgradeTier: tier === RewardsTier.CORE ? RewardsTier.PRIME : RewardsTier.ULTRA, + }; +}; diff --git a/components/Rewards/NewRewards/categoryCashbackBrands.ts b/components/Rewards/NewRewards/categoryCashbackBrands.ts new file mode 100644 index 000000000..ee5075cbd --- /dev/null +++ b/components/Rewards/NewRewards/categoryCashbackBrands.ts @@ -0,0 +1,301 @@ +import type { CashbackCategoryKey } from './categoryCashback'; +import type { ImageSource } from 'expo-image'; + +interface BrandLayer { + source: ImageSource; + width: number; + height: number; + left: number; + top: number; + flipY?: boolean; +} + +export interface CategoryCashbackBrand { + name: string; + background?: { color: string; size?: number; left?: number; top?: number; radius?: number }; + layers: BrandLayer[]; + ring?: 'rides' | 'airlines'; + badgeText?: string; +} + +// Positions and intrinsic SVG dimensions are the 30px merchant slots exported +// from Figma 27572:3100, including the ring and transformed logo layers. +export const CATEGORY_CASHBACK_BRANDS: Record = { + ai: [ + { + name: 'OpenAI', + layers: [ + { + source: require('@/assets/images/subscription-cashback/openai.svg'), + width: 30, + height: 30, + left: 0, + top: 0, + }, + ], + }, + { + name: 'Claude', + background: { color: '#D97757', left: 1 }, + layers: [ + { + source: require('@/assets/images/subscription-cashback/claude.svg'), + width: 20.4545, + height: 20.4545, + left: 6, + top: 5, + }, + ], + }, + { + name: 'Gemini', + background: { color: '#FFFFFF' }, + layers: [ + { + source: require('@/assets/images/subscription-cashback/gemini.svg'), + width: 24.5455, + height: 24.5455, + left: 2.73, + top: 2.73, + }, + ], + }, + ], + streaming: [ + { + name: 'Netflix', + background: { color: '#000000' }, + layers: [ + { + source: require('@/assets/images/subscription-cashback/netflix.svg'), + width: 10.9091, + height: 20.4545, + left: 9.55, + top: 4.09, + }, + ], + }, + { + name: 'Disney', + background: { color: '#FFFFFF', size: 29, left: 1 }, + layers: [ + { + source: require('@/assets/images/subscription-cashback/disney.svg'), + width: 21.0909, + height: 21.0909, + left: 4.95, + top: 3.95, + }, + { + source: require('@/assets/images/subscription-cashback/disney-plus.svg'), + width: 18.7843, + height: 18.7842, + left: 6.76, + top: 5.76, + }, + ], + }, + { + name: 'HBO Max', + layers: [ + { + source: require('@/assets/images/subscription-cashback/hbo.svg'), + width: 29, + height: 29, + left: 1, + top: 0, + }, + ], + }, + { + name: 'Amazon Prime', + background: { color: '#FFFFFF', size: 28, left: 1, top: 1 }, + layers: [ + { + source: require('@/assets/images/subscription-cashback/amazon.svg'), + width: 17.805, + height: 17.8111, + left: 6.11, + top: 6.09, + }, + ], + }, + { + name: 'Apple TV', + layers: [ + { + source: require('@/assets/images/subscription-cashback/apple-tv.svg'), + width: 28, + height: 28, + left: 1, + top: 1, + }, + ], + }, + ], + music: [ + { + name: 'Spotify', + layers: [ + { + source: require('@/assets/images/subscription-cashback/spotify.svg'), + width: 30, + height: 30, + left: 0, + top: 0, + }, + ], + }, + { + name: 'Apple Music', + layers: [ + { + source: require('@/assets/images/subscription-cashback/apple-music.svg'), + width: 30, + height: 30, + left: 0, + top: 0, + }, + ], + }, + { + name: 'Youtube Music', + background: { color: '#FFFFFF' }, + layers: [ + { + source: require('@/assets/images/subscription-cashback/youtube.svg'), + width: 21.8182, + height: 15.2727, + left: 4.09, + top: 7.36, + }, + ], + }, + ], + rides: [ + { + name: 'Uber', + background: { color: '#000000', radius: 13 }, + ring: 'rides', + layers: [ + { + source: require('@/assets/images/subscription-cashback/uber.svg'), + width: 16.1538, + height: 5.45728, + left: 6.92, + top: 12.28, + }, + ], + }, + { + name: 'Grab', + background: { color: '#00B14F', radius: 13 }, + ring: 'rides', + layers: [ + { + source: require('@/assets/images/subscription-cashback/grab.svg'), + width: 15.055, + height: 5.79629, + left: 6.64, + top: 12.26, + flipY: true, + }, + ], + }, + { + name: 'Bolt', + background: { color: '#2B9C64', radius: 13 }, + ring: 'rides', + layers: [ + { + source: require('@/assets/images/subscription-cashback/bolt.svg'), + width: 15.563, + height: 9.05635, + left: 7.21, + top: 10.47, + }, + ], + }, + { + name: 'Gojek', + background: { color: '#FFFFFF' }, + ring: 'rides', + layers: [ + { + source: require('@/assets/images/subscription-cashback/gojek.svg'), + width: 20.7369, + height: 20.8027, + left: 4.62, + top: 4.62, + }, + ], + }, + ], + airlines: [ + { + name: 'Emirates', + background: { color: '#D72027', radius: 13 }, + ring: 'airlines', + layers: [ + { + source: require('@/assets/images/subscription-cashback/emirates.svg'), + width: 19.0385, + height: 15.691, + left: 5.48, + top: 7.15, + }, + ], + }, + { + name: 'Turkish Airlines', + background: { color: '#FFFFFF', radius: 13 }, + ring: 'airlines', + layers: [ + { + source: require('@/assets/images/subscription-cashback/turkish.svg'), + width: 25.3848, + height: 25.3848, + left: 2.31, + top: 2.31, + flipY: true, + }, + ], + }, + { + name: 'Singapore Airlines', + background: { color: '#FFFFFF', radius: 13 }, + ring: 'airlines', + layers: [ + { + source: require('@/assets/images/subscription-cashback/singapore.svg'), + width: 12.5075, + height: 17.3458, + left: 8.76, + top: 6.35, + }, + ], + }, + { + name: 'Qatar Airways', + background: { color: '#56002D' }, + ring: 'airlines', + layers: [ + { + source: require('@/assets/images/subscription-cashback/qatar.svg'), + width: 24.2308, + height: 23.6865, + left: 5.77, + top: 1.15, + flipY: true, + }, + ], + }, + { + name: '8 more airlines', + background: { color: '#333333' }, + ring: 'airlines', + layers: [], + badgeText: '+8', + }, + ], +}; diff --git a/components/Rewards/NewRewards/tierBenefitsPresentation.ts b/components/Rewards/NewRewards/tierBenefitsPresentation.ts new file mode 100644 index 000000000..ca913712b --- /dev/null +++ b/components/Rewards/NewRewards/tierBenefitsPresentation.ts @@ -0,0 +1,188 @@ +import { formatTierCashbackRate } from '@/lib/tierCashback'; +import { TIER_YIELD_BOOST_RATES } from '@/lib/tierYieldBoost'; +import { RewardsTier, type TierBenefits, type TierFees, type TierOffer } from '@/lib/types'; + +export const TIER_LABELS: Record = { + [RewardsTier.CORE]: 'Core', + [RewardsTier.PRIME]: 'Prime', + [RewardsTier.ULTRA]: 'Ultra', +}; + +export const TIER_PRESENTATION = { + [RewardsTier.CORE]: { + headline: 'The Solid\nFoundation', + accent: '#94F27F', + subscriptionRate: null, + rideRate: null, + airlineRate: null, + stats: [ + { value: formatTierCashbackRate(RewardsTier.CORE), label: 'Cashback' }, + { value: '$0', label: 'Card cost' }, + ], + perks: [ + { title: 'Free virtual card', description: 'Issued instantly' }, + { title: 'Set up in minutes', description: 'Under 5 minutes' }, + { title: 'Spend globally', description: 'Card accepted in 180+ countries' }, + ], + }, + [RewardsTier.PRIME]: { + headline: 'Elevated Daily\nRewards', + accent: '#FFFFFF', + subscriptionRate: '10%', + rideRate: '8%', + airlineRate: null, + stats: [ + { value: formatTierCashbackRate(RewardsTier.PRIME), label: 'Cashback' }, + { value: '+2%', label: 'Yield boost' }, + { value: '10%', label: 'Subscriptions' }, + ], + perks: [ + { title: 'Yield boost', description: '+2% APY on up to $10K in savings' }, + { title: 'Subscription & ride rewards', description: '10% on subscriptions, 8% on rides' }, + { title: 'Higher cashback caps', description: 'Up to $100 cashback a month' }, + ], + }, + [RewardsTier.ULTRA]: { + headline: 'Next-Level\nSpending Power', + accent: '#EFE5A9', + subscriptionRate: '20%', + rideRate: '10%', + airlineRate: '10%', + stats: [ + { value: formatTierCashbackRate(RewardsTier.ULTRA), label: 'Cashback' }, + { value: '+3%', label: 'Yield boost' }, + { value: '20%', label: 'Subscriptions' }, + ], + perks: [ + { title: 'Yield boost', description: '+3% APY on up to $25K in savings' }, + { title: 'Subscription & ride rewards', description: '20% on subscriptions, 10% on rides' }, + { title: 'Airline rewards', description: '10% back on 12 global airlines' }, + ], + }, +} as const; + +export interface TierPresentationContent { + headline: string; + accent: string; + subscriptionRate: string | null; + rideRate: string | null; + airlineRate: string | null; + stats: { value: string; label: string }[]; + perks: { title: string; description: string }[]; +} + +const balanceCapLabel = (amount: number) => + amount >= 1000 && amount % 1000 === 0 + ? `$${amount / 1000}K` + : `$${amount.toLocaleString('en-US')}`; + +const isBenefitNumber = (value: number | undefined): value is number => + typeof value === 'number' && Number.isFinite(value) && value >= 0; + +/** Live numerical benefits win, including zero; older APIs keep the design fallback. */ +export function tierPresentationContent( + tier: RewardsTier, + live?: TierBenefits, +): TierPresentationContent { + const fallback = TIER_PRESENTATION[tier]; + const hasSubscriptionRate = isBenefitNumber(live?.subscriptionDiscountRate); + const subscriptionPercentage = hasSubscriptionRate + ? live!.subscriptionDiscountRate! + : tier === RewardsTier.PRIME + ? 10 + : tier === RewardsTier.ULTRA + ? 20 + : 0; + // Through the shared table rather than a second set of literals: this screen and the + // rewards tab's teaser card each used to keep their own, and they drifted. + const yieldPercentage = isBenefitNumber(live?.yieldBoostPercentage) + ? live.yieldBoostPercentage + : TIER_YIELD_BOOST_RATES[tier]; + const balanceCap = isBenefitNumber(live?.yieldBoostBalanceCap) + ? live.yieldBoostBalanceCap + : tier === RewardsTier.PRIME + ? 10_000 + : tier === RewardsTier.ULTRA + ? 25_000 + : 0; + const yieldRate = `${yieldPercentage > 0 ? '+' : ''}${yieldPercentage}%`; + + return { + ...fallback, + subscriptionRate: hasSubscriptionRate + ? `${subscriptionPercentage}%` + : fallback.subscriptionRate, + stats: fallback.stats.map(stat => ({ + ...stat, + value: + stat.label === 'Subscriptions' + ? `${subscriptionPercentage}%` + : stat.label === 'Yield boost' + ? yieldRate + : stat.value, + })), + perks: fallback.perks.map(perk => ({ + ...perk, + description: + perk.title === 'Yield boost' + ? `${yieldRate} APY on up to ${balanceCapLabel(balanceCap)} in savings` + : perk.title === 'Subscription & ride rewards' + ? `${subscriptionPercentage}% on subscriptions, ${fallback.rideRate} on rides` + : perk.description, + })), + }; +} + +const FEE_ROWS = [ + ['virtual_card', 'Virtual Card'], + ['bank_deposit', 'Bank deposit'], + ['swap', 'Swaps'], + ['fx', 'FX conversion'], + ['offramp', 'Bank withdrawal'], +] as const; + +/** The v4 design is the missing-data fallback; an actual billing table always wins. */ +export function tierPresentationFees(tier: RewardsTier, live?: TierFees) { + if (live?.lines?.length) return { lines: live.lines, cashbackCap: live.cashbackCap }; + return { + lines: FEE_ROWS.map(([key, label]) => ({ + key, + label, + value: + key === 'virtual_card' || + tier === RewardsTier.ULTRA || + (tier === RewardsTier.PRIME && (key === 'fx' || key === 'offramp')) + ? 'Free' + : tier === RewardsTier.PRIME + ? '0.25%' + : '0.5%', + })), + cashbackCap: `Up to $${tier === RewardsTier.CORE ? 50 : tier === RewardsTier.PRIME ? 100 : 200}/Month`, + }; +} + +export function tierOfferRows(offer: TierOffer | undefined) { + if (!offer) return []; + const rows: { label: string; value: string }[] = []; + if (offer.cashAvailable && offer.annualFeeUsd != null && offer.annualFeeUsd > 0) { + rows.push({ + label: 'Annual membership', + value: `$${offer.annualFeeUsd.toLocaleString('en-US')}/year`, + }); + } + if (offer.lockAvailable && offer.lockFuse > 0) { + rows.push({ + label: rows.length ? 'Or lock FUSE' : 'Lock FUSE', + value: `${offer.lockFuse / 1000}K FUSE`, + }); + } + return rows; +} + +export function tierOfferSubtitle(offer: TierOffer | undefined) { + const rows = tierOfferRows(offer); + if (rows.length === 2) return `${rows[0].value} or ${rows[1].value} locked`; + if (rows.length === 1) + return rows[0].label === 'Lock FUSE' ? `Requires ${rows[0].value} locked` : rows[0].value; + return null; +} diff --git a/components/Savings/NewSavings/SavingsYieldBoostCard.tsx b/components/Savings/NewSavings/SavingsYieldBoostCard.tsx deleted file mode 100644 index a8bfcdd7d..000000000 --- a/components/Savings/NewSavings/SavingsYieldBoostCard.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { View } from 'react-native'; - -import { resolveTierBenefitRates } from '@/components/Rewards/NewRewards/tierBenefitCards'; -import { Text } from '@/components/ui/text'; -import { useRewardsUserData } from '@/hooks/useRewards'; -import { isDevFeatureEnabled } from '@/lib/config'; -import { formatBalanceUSD, formatNumber } from '@/lib/utils'; - -/** - * "Yield boost" — the extra APY the user's rewards tier adds on top of the base - * savings yield, and what it has paid out so far. - * - * Renders nothing for tiers that grant no boost, so Core users don't see an - * empty box for a perk they haven't unlocked. Off production the boost is - * previewed at stock Prime rates instead, matching the rewards screen so the - * strip is reviewable from a Core test account. - */ -const SavingsYieldBoostCard = () => { - const { data: rewardsData } = useRewardsUserData(); - - const { yieldBoostPercentage: boostPercentage, yieldBoostEarned } = resolveTierBenefitRates( - { - yieldBoostPercentage: rewardsData?.yieldBoostPercentage ?? 0, - yieldBoostCap: rewardsData?.yieldBoostCap ?? 0, - yieldBoostEarned: rewardsData?.yieldBoostEarned ?? 0, - subscriptionDiscountRate: rewardsData?.subscriptionDiscountRate ?? 0, - }, - isDevFeatureEnabled, - ); - - if (boostPercentage <= 0) return null; - - return ( - - - Yield boost - - - - - - Boost amount - - - +{formatNumber(boostPercentage, 2, 0)}% Boost - - - - - Total Earned - - {formatBalanceUSD(yieldBoostEarned)} - - - - - ); -}; - -export default SavingsYieldBoostCard; diff --git a/components/Swap/BuyFuseScreen.tsx b/components/Swap/BuyFuseScreen.tsx index 41794f489..e1f47d1a7 100644 --- a/components/Swap/BuyFuseScreen.tsx +++ b/components/Swap/BuyFuseScreen.tsx @@ -81,15 +81,7 @@ function BuyFuseForAccount({ requestedTier, upgradeContext }: BuyFuseScreenProps })), ); const { onUserInput } = useSwapActionHandlers(); - const { - currencyBalances, - parsedAmount, - toggledTrade: trade, - voltageTrade, - isVoltageTrade, - isVoltageTradeLoading, - tradeState, - } = useDerivedSwapInfo(); + const { currencyBalances, parsedAmount, toggledTrade: trade, tradeState } = useDerivedSwapInfo(); useEffect(() => { selectCurrency(SwapField.INPUT, STABLECOINS_TOKENS.USDC.address); @@ -122,9 +114,7 @@ function BuyFuseForAccount({ requestedTier, upgradeContext }: BuyFuseScreenProps const canBuy = upgradeContext ? true : !!currentTier && !isError && !pending && !staleRequest && !!selectedTarget; - const selectedTrade = isVoltageTrade ? voltageTrade.trade : trade; - const outputAmount = - independentField === SwapField.OUTPUT ? parsedAmount : selectedTrade?.outputAmount; + const outputAmount = independentField === SwapField.OUTPUT ? parsedAmount : trade?.outputAmount; const enteredFuse = Math.max( 0, Number(outputAmount?.toExact() ?? (independentField === SwapField.OUTPUT ? typedValue : 0)) || @@ -140,9 +130,7 @@ function BuyFuseForAccount({ requestedTier, upgradeContext }: BuyFuseScreenProps const inputBalance = currencyBalances[SwapField.INPUT]; const fundingBalanceLabel = formatBuyFuseFundingBalance(inputBalance?.toSignificant(8)); const canUseMax = Boolean(inputBalance?.greaterThan(0)); - const isOutputLoading = - independentField === SwapField.INPUT && - (isVoltageTradeLoading || tradeState.state === 'LOADING'); + const isOutputLoading = independentField === SwapField.INPUT && tradeState.state === 'LOADING'; const displayedAmount = independentField === SwapField.OUTPUT ? typedValue diff --git a/components/Swap/SwapButton.tsx b/components/Swap/SwapButton.tsx index a91b1fb44..0e10ecdb3 100644 --- a/components/Swap/SwapButton.tsx +++ b/components/Swap/SwapButton.tsx @@ -13,7 +13,6 @@ import { SWAP_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import usePegSwapCallback, { PegSwapType } from '@/hooks/swap/usePegswapCallback'; import { useSwapCallback } from '@/hooks/swap/useSwapCallback'; -import { useVoltageSwapCallback } from '@/hooks/swap/useVoltageSwapCallback'; import useWrapCallback, { WrapType } from '@/hooks/swap/useWrapCallback'; import useUser from '@/hooks/useUser'; import { track } from '@/lib/analytics'; @@ -62,15 +61,13 @@ const SwapButton: React.FC = ({ parsedAmount, currencies, inputError: swapInputError, - isVoltageTradeLoading, - isVoltageTrade, - voltageTrade, swapFee, revenueWalletAddress, + refreshQuote, } = useDerivedSwapInfo(); - // Passed down rather than re-derived inside each callback, so all three swap - // rails collect the same fee the confirm sheet quoted. + // Passed down rather than re-derived inside each callback, so every swap + // rail collects the same fee the confirm sheet quoted. const feeCollection = useMemo( () => ({ fee: swapFee, revenueWalletAddress }), [swapFee, revenueWalletAddress], @@ -119,12 +116,9 @@ const SwapButton: React.FC = ({ [setTransaction, setModal, resetForm, currencies, onConfirmed, user?.userId], ); - const selectedTrade = isVoltageTrade ? voltageTrade.trade : trade; - const parsedAmountA = - independentField === SwapField.INPUT ? parsedAmount : selectedTrade?.inputAmount; + const parsedAmountA = independentField === SwapField.INPUT ? parsedAmount : trade?.inputAmount; - const parsedAmountB = - independentField === SwapField.OUTPUT ? parsedAmount : selectedTrade?.outputAmount; + const parsedAmountB = independentField === SwapField.OUTPUT ? parsedAmount : trade?.outputAmount; const parsedAmounts = useMemo( () => ({ @@ -140,12 +134,9 @@ const SwapButton: React.FC = ({ parsedAmounts[independentField]?.greaterThan('0'), ); - const routeNotFound = - !isVoltageTrade && - (tradeState.state === TradeState.NO_ROUTE_FOUND || trade?.swaps.length === 0); + const routeNotFound = tradeState.state === TradeState.NO_ROUTE_FOUND || trade?.swaps.length === 0; const isLoadingRoute = - !isVoltageTrade && - (tradeState.state === TradeState.LOADING || tradeState.state === TradeState.SYNCING); + tradeState.state === TradeState.LOADING || tradeState.state === TradeState.SYNCING; // Get peg swap calldata for batch operations const inputAmount = useMemo( @@ -186,37 +177,6 @@ const SwapButton: React.FC = ({ feeCollection, ); - const { - callback: voltageSwapCallback, - isLoading: isVoltageSwapLoading, - needAllowance: needVoltageSwapAllowance, - error: voltageSwapCallbackError, - } = useVoltageSwapCallback( - isVoltageTrade ? voltageTrade.trade : undefined, - allowedSlippage, - currencies[SwapField.INPUT] && currencies[SwapField.OUTPUT] && voltageTrade.trade - ? { - title: 'Swap transaction completed', - description: `${voltageTrade.trade.inputAmount?.toSignificant()} ${currencies[SwapField.INPUT]?.symbol} → ${voltageTrade.trade.outputAmount?.toSignificant()} ${currencies[SwapField.OUTPUT]?.symbol}`, - inputAmount: voltageTrade.trade.inputAmount?.toSignificant(), - outputAmount: voltageTrade.trade.outputAmount?.toSignificant(), - inputSymbol: currencies[SwapField.INPUT]?.symbol, - outputSymbol: currencies[SwapField.OUTPUT]?.symbol, - chainId: 122, - onSuccess: createSwapSuccessHandler( - currencies[SwapField.INPUT]?.symbol || '', - currencies[SwapField.OUTPUT]?.symbol || '', - voltageTrade.trade.inputAmount?.toSignificant() || '', - voltageTrade.trade.outputAmount?.toSignificant(), - ), - } - : undefined, - feeCollection, - ); - - const selectedSwapCallback = isVoltageTrade ? voltageSwapCallback : swapCallback; - const selectedCallbackError = isVoltageTrade ? voltageSwapCallbackError : swapCallbackError; - const { pegSwapType, callback: pegSwapCallback, @@ -247,29 +207,20 @@ const SwapButton: React.FC = ({ const priceImpactSeverity = useMemo(() => { if (!trade) return 0; const realizedLpFeePercent = computeRealizedLPFeePercent(trade); - const priceImpact = isVoltageTrade - ? voltageTrade?.trade?.priceImpact?.subtract(realizedLpFeePercent) - : trade?.priceImpact?.subtract(realizedLpFeePercent); - return warningSeverity(priceImpact); - }, [trade, isVoltageTrade, voltageTrade]); + return warningSeverity(trade.priceImpact?.subtract(realizedLpFeePercent)); + }, [trade]); const showPegSwap = pegSwapType !== PegSwapType.NOT_APPLICABLE; const needsApproval = useMemo(() => { if (showPegSwap) return needPegSwapAllowance; - return isVoltageTrade ? needVoltageSwapAllowance : needSwapAllowance; - }, [ - showPegSwap, - needPegSwapAllowance, - isVoltageTrade, - needVoltageSwapAllowance, - needSwapAllowance, - ]); + return needSwapAllowance; + }, [showPegSwap, needPegSwapAllowance, needSwapAllowance]); const handleSwap = useCallback(async () => { if (disabled || executing.current) return; - if (!selectedSwapCallback) { - setSubmissionError(selectedCallbackError || 'Unable to get a quote. Try another amount.'); + if (!swapCallback) { + setSubmissionError(swapCallbackError || 'Unable to get a quote. Try another amount.'); return; } executing.current = true; @@ -278,10 +229,22 @@ const SwapButton: React.FC = ({ successHandled.current = false; operationSession.current = useRewardsUpgradeStore.getState().session; try { + // The quote on screen can be up to one refresh old. Check it still holds + // before asking for a signature, rather than letting the swap revert. + if (!(await refreshQuote())) { + Sentry.addBreadcrumb({ + message: 'Swap quote moved before signing', + category: 'swap', + level: 'info', + }); + setSubmissionError('The price changed. Check the new amount and swap again.'); + return; + } + track(TRACKING_EVENTS.SWAP_INITIATED, { user_id: user?.userId, safe_address: user?.safeAddress, - trade_type: isVoltageTrade ? 'voltage' : 'standard', + trade_type: 'standard', input_currency: currencies[SwapField.INPUT]?.symbol, output_currency: currencies[SwapField.OUTPUT]?.symbol, input_amount: trade?.inputAmount?.toSignificant(), @@ -296,7 +259,6 @@ const SwapButton: React.FC = ({ category: 'swap', level: 'info', data: { - isVoltageTrade, inputCurrency: currencies[SwapField.INPUT]?.symbol, outputCurrency: currencies[SwapField.OUTPUT]?.symbol, inputAmount: trade?.inputAmount?.toSignificant(), @@ -305,13 +267,13 @@ const SwapButton: React.FC = ({ }, }); - const result = await selectedSwapCallback(); + const result = await swapCallback(); if (!result) return; track(TRACKING_EVENTS.SWAP_COMPLETED, { user_id: user?.userId, safe_address: user?.safeAddress, - trade_type: isVoltageTrade ? 'voltage' : 'standard', + trade_type: 'standard', input_currency: currencies[SwapField.INPUT]?.symbol, output_currency: currencies[SwapField.OUTPUT]?.symbol, input_amount: trade?.inputAmount?.toSignificant(), @@ -323,7 +285,7 @@ const SwapButton: React.FC = ({ track(TRACKING_EVENTS.SWAP_FAILED, { user_id: user?.userId, safe_address: user?.safeAddress, - trade_type: isVoltageTrade ? 'voltage' : 'standard', + trade_type: 'standard', input_currency: currencies[SwapField.INPUT]?.symbol, output_currency: currencies[SwapField.OUTPUT]?.symbol, input_amount: trade?.inputAmount?.toSignificant(), @@ -333,7 +295,6 @@ const SwapButton: React.FC = ({ Sentry.captureException(error, { tags: { type: 'swap_button_error', - isVoltageTrade: String(isVoltageTrade), }, extra: { inputCurrency: currencies[SwapField.INPUT]?.symbol, @@ -357,9 +318,9 @@ const SwapButton: React.FC = ({ setIsSubmitting(false); } }, [ - selectedSwapCallback, - selectedCallbackError, - isVoltageTrade, + swapCallback, + swapCallbackError, + refreshQuote, currencies, trade, allowedSlippage, @@ -573,31 +534,27 @@ const SwapButton: React.FC = ({ ); } - const isAnyLoading = - isSubmitting || isWrapLoading || isPegSwapLoading || isSwapLoading || isVoltageSwapLoading; + const isAnyLoading = isSubmitting || isWrapLoading || isPegSwapLoading || isSwapLoading; const isButtonDisabled = Boolean( disabled || !isValid || !typedValue || !userHasSpecifiedInputOutput || - !selectedSwapCallback || + !swapCallback || routeNotFound || priceImpactTooHigh || isSubmitting || isLoadingRoute || - isVoltageTradeLoading || - (isVoltageTrade && isVoltageSwapLoading) || - isSwapLoading || - isVoltageSwapLoading, + isSwapLoading, ); const quoteError = - typedValue && !isLoadingRoute && !isVoltageTradeLoading && !selectedSwapCallback - ? selectedCallbackError || 'Unable to get a quote. Try another amount.' + typedValue && !isLoadingRoute && !swapCallback + ? swapCallbackError || 'Unable to get a quote. Try another amount.' : undefined; const noRouteError = - routeNotFound && userHasSpecifiedInputOutput && !isLoadingRoute && !isVoltageTradeLoading + routeNotFound && userHasSpecifiedInputOutput && !isLoadingRoute ? 'We couldn’t get a price. Try a different amount.' : undefined; const errorMessage = @@ -619,14 +576,12 @@ const SwapButton: React.FC = ({ > {isAnyLoading ? ( Processing Transaction... - ) : typedValue && (isLoadingRoute || isVoltageTradeLoading) ? ( + ) : typedValue && isLoadingRoute ? ( Finding Routes... - ) : typedValue && (!selectedSwapCallback || routeNotFound) ? ( + ) : typedValue && (!swapCallback || routeNotFound) ? ( {label} ) : priceImpactSeverity > 2 && !priceImpactTooHigh ? ( Swap Anyway - ) : needsApproval ? ( - Approve & Swap ) : !typedValue ? ( Enter an amount ) : ( diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index d864d1018..247f7df20 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -18,7 +18,7 @@ import { TRACKING_EVENTS } from '@/constants/tracking-events'; import useGeoCompliance from '@/hooks/useGeoCompliance'; import { track } from '@/lib/analytics'; import getTokenIcon from '@/lib/getTokenIcon'; -import { useSwapState } from '@/store/swapStore'; +import { DerivedSwapInfoProvider, useSwapState } from '@/store/swapStore'; import { useComplianceStore } from '@/store/useComplianceStore'; /** @@ -104,7 +104,11 @@ const SwapModalProvider = () => { } if (isBuyFuse) { - return ; + return ( + + + + ); } if (isTransactionStatus) { @@ -126,16 +130,18 @@ const SwapModalProvider = () => { } return ( - - - - - - - - + + + + + + + + + + - + ); }, [ isSwapAvailable, diff --git a/components/Swap/SwapPair.tsx b/components/Swap/SwapPair.tsx index 8cd5a137a..2e66d4178 100644 --- a/components/Swap/SwapPair.tsx +++ b/components/Swap/SwapPair.tsx @@ -19,9 +19,6 @@ const SwapPair: React.FC = () => { currencyBalances, parsedAmount, currencies, - voltageTrade, - isVoltageTrade, - isVoltageTradeLoading, tradeState, } = useDerivedSwapInfo(); @@ -83,38 +80,14 @@ const SwapPair: React.FC = () => { const showPegSwap: boolean = pegSwapType !== PegSwapType.NOT_APPLICABLE; const parsedAmountA = useMemo(() => { - if (isVoltageTradeLoading || tradeState.state === 'LOADING') return; - return independentField === SwapField.INPUT - ? parsedAmount - : isVoltageTrade - ? voltageTrade?.trade?.inputAmount - : trade?.inputAmount; - }, [ - independentField, - parsedAmount, - isVoltageTrade, - voltageTrade?.trade?.inputAmount, - trade?.inputAmount, - isVoltageTradeLoading, - tradeState.state, - ]); + if (tradeState.state === 'LOADING') return; + return independentField === SwapField.INPUT ? parsedAmount : trade?.inputAmount; + }, [independentField, parsedAmount, trade?.inputAmount, tradeState.state]); const parsedAmountB = useMemo(() => { - if (isVoltageTradeLoading || tradeState.state === 'LOADING') return; - return independentField === SwapField.OUTPUT - ? parsedAmount - : isVoltageTrade - ? voltageTrade?.trade?.outputAmount - : trade?.outputAmount; - }, [ - independentField, - parsedAmount, - isVoltageTrade, - voltageTrade?.trade?.outputAmount, - trade?.outputAmount, - isVoltageTradeLoading, - tradeState.state, - ]); + if (tradeState.state === 'LOADING') return; + return independentField === SwapField.OUTPUT ? parsedAmount : trade?.outputAmount; + }, [independentField, parsedAmount, trade?.outputAmount, tradeState.state]); const parsedAmounts = useMemo( () => @@ -167,7 +140,7 @@ const SwapPair: React.FC = () => { () => ({ [independentField]: typedValue, [dependentField]: - isVoltageTradeLoading || tradeState.state === 'LOADING' + tradeState.state === 'LOADING' ? '...' : showWrap || showPegSwap ? (parsedAmounts[independentField]?.toExact() ?? '') @@ -182,7 +155,6 @@ const SwapPair: React.FC = () => { showWrap, showPegSwap, parsedAmounts, - isVoltageTradeLoading, tradeState.state, ], ); @@ -218,7 +190,7 @@ const SwapPair: React.FC = () => { fiatValue={fiatValueOutputFormatted ?? undefined} showBalance={false} title="To" - isLoading={isVoltageTradeLoading || tradeState.state === 'LOADING'} + isLoading={tradeState.state === 'LOADING'} /> ); diff --git a/components/Swap/SwapParams.tsx b/components/Swap/SwapParams.tsx index 966ac859f..90d40fce6 100644 --- a/components/Swap/SwapParams.tsx +++ b/components/Swap/SwapParams.tsx @@ -25,11 +25,7 @@ import { ChevronRight } from '@/lib/icons/ChevronRight'; import { SwapField } from '@/lib/types/swap-field'; import { TradeState } from '@/lib/types/trade-state'; import { cn } from '@/lib/utils'; -import { - computeRealizedLPFeePercent, - computeSlippageAdjustedAmounts, - warningSeverity, -} from '@/lib/utils/swap/prices'; +import { computeRealizedLPFeePercent, warningSeverity } from '@/lib/utils/swap/prices'; import { toFeeCurrencyAmount } from '@/lib/utils/swapFee'; import { publicClient } from '@/lib/wagmi'; import { useDerivedSwapInfo, useSwapState } from '@/store/swapStore'; @@ -45,9 +41,6 @@ const SwapParams = ({ label = 'Fee', expandable = true }: SwapParamsProps) => { toggledTrade: trade, allowedSlippage, currencies, - voltageTrade, - isVoltageTrade, - isVoltageTradeLoading, swapFee, } = useDerivedSwapInfo(); const typedValue = useSwapState(state => state.typedValue); @@ -197,11 +190,9 @@ const SwapParams = ({ label = 'Fee', expandable = true }: SwapParamsProps) => { const realizedLpFeePercent = computeRealizedLPFeePercent(trade); const realizedLPFee = trade.inputAmount.multiply(realizedLpFeePercent); - const priceImpact = isVoltageTrade - ? voltageTrade?.trade?.priceImpact - : trade.priceImpact.subtract(realizedLpFeePercent); + const priceImpact = trade.priceImpact.subtract(realizedLpFeePercent); return { priceImpact, realizedLPFee }; - }, [trade, isVoltageTrade, voltageTrade]); + }, [trade]); const LPFeeString = realizedLPFee ? `${realizedLPFee.toSignificant(4)} ${realizedLPFee.currency.symbol}` @@ -274,13 +265,9 @@ const SwapParams = ({ label = 'Fee', expandable = true }: SwapParamsProps) => { {trade.tradeType === TradeType.EXACT_INPUT ? 'Minimum received' : 'Maximum sent'} - {isVoltageTrade && !isVoltageTradeLoading - ? computeSlippageAdjustedAmounts(voltageTrade?.trade, allowedSlippage)[ - trade.tradeType === TradeType.EXACT_INPUT ? 'outputAmount' : 'inputAmount' - ]?.toSignificant(6) - : trade.tradeType === TradeType.EXACT_INPUT - ? `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${trade.outputAmount.currency.symbol}` - : `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${trade.inputAmount.currency.symbol}`} + {trade.tradeType === TradeType.EXACT_INPUT + ? `${trade.minimumAmountOut(allowedSlippage).toSignificant(6)} ${trade.outputAmount.currency.symbol}` + : `${trade.maximumAmountIn(allowedSlippage).toSignificant(6)} ${trade.inputAmount.currency.symbol}`} diff --git a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx index 0ea617125..0caecf142 100644 --- a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx +++ b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx @@ -47,9 +47,6 @@ jest.mock('@/store/swapStore', () => ({ currencyBalances: { INPUT: undefined }, parsedAmount: undefined, toggledTrade: undefined, - voltageTrade: { trade: undefined }, - isVoltageTrade: false, - isVoltageTradeLoading: false, tradeState: { state: 'NO_ROUTE_FOUND' }, }), })); diff --git a/components/Swap/__tests__/SwapButton.test.tsx b/components/Swap/__tests__/SwapButton.test.tsx index ddd153932..099f58cb5 100644 --- a/components/Swap/__tests__/SwapButton.test.tsx +++ b/components/Swap/__tests__/SwapButton.test.tsx @@ -45,8 +45,7 @@ jest.mock('@/store/swapStore', () => ({ OUTPUT: { symbol: 'FUSE', wrapped: { address: '0x2' } }, }, allowedSlippage: { toSignificant: () => '0.5' }, - voltageTrade: {}, - isVoltageTrade: false, + refreshQuote: mockRefreshQuote, }), })); jest.mock('@/hooks/swap/useSwapCallback', () => ({ @@ -56,11 +55,10 @@ jest.mock('@/hooks/swap/useSwapCallback', () => ({ callback: mockCallbackAvailable ? mockExecute : undefined, error: mockCallbackAvailable ? undefined : 'Unable to prepare the quote. Try another amount.', isLoading: false, - needAllowance: false, + needAllowance: mockNeedAllowance, }; }, })); -jest.mock('@/hooks/swap/useVoltageSwapCallback', () => ({ useVoltageSwapCallback: () => ({}) })); jest.mock('@/hooks/swap/usePegswapCallback', () => ({ __esModule: true, PegSwapType: { NOT_APPLICABLE: 'none' }, @@ -74,14 +72,16 @@ jest.mock('@/hooks/swap/useWrapCallback', () => ({ const mockSetModal = jest.fn(), mockSetTransaction = jest.fn(), mockReset = jest.fn(), - mockExecute = jest.fn(); + mockExecute = jest.fn(), + mockRefreshQuote = jest.fn(); let mockInfo: any, mockInputError: string | undefined, mockUser = 'a', mockSession = 0, mockRouteState = 'VALID', mockHasTrade = true, - mockCallbackAvailable = true; + mockCallbackAvailable = true, + mockNeedAllowance = false; const mockAmount = { toSignificant: () => '100', greaterThan: () => true }; const mockTrade = { swaps: [{}], @@ -98,7 +98,9 @@ beforeEach(() => { mockRouteState = 'VALID'; mockHasTrade = true; mockCallbackAvailable = true; + mockNeedAllowance = false; mockInputError = undefined; + mockRefreshQuote.mockResolvedValue(true); }); afterEach(() => act(() => root.unmount())); const mount = (props: any = {}) => @@ -258,3 +260,33 @@ it('shows progress immediately while wallet confirmation is pending', async () = await pending; }); }); + +it('keeps its own label when the swap also approves the token', () => { + mockNeedAllowance = true; + mount({ label: 'Buy FUSE' }); + const rendered = JSON.stringify(root.toJSON()); + expect(rendered).toContain('Buy FUSE'); + expect(rendered).not.toContain('Approve'); +}); + +it('stops before signing when the price moved past the slippage limit', async () => { + mockRefreshQuote.mockResolvedValueOnce(false); + mount({ label: 'Buy FUSE' }); + await act(async () => { + await press(); + }); + expect(mockExecute).not.toHaveBeenCalled(); + expect(JSON.stringify(root.toJSON())).toContain( + 'The price changed. Check the new amount and swap again.', + ); + expect(root.root.findByType('Button').props.disabled).toBe(false); +}); + +it('re-checks the quote before every signature', async () => { + mount(); + await act(async () => { + await press(); + }); + expect(mockRefreshQuote).toHaveBeenCalledTimes(1); + expect(mockExecute).toHaveBeenCalledTimes(1); +}); diff --git a/components/Swap/__tests__/SwapModalProvider.ios.test.tsx b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx index e66869289..7b36ef594 100644 --- a/components/Swap/__tests__/SwapModalProvider.ios.test.tsx +++ b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx @@ -30,6 +30,7 @@ jest.mock('@/store/useComplianceStore', () => ({ selector({ hasAcceptedDisclaimer: () => true, acceptDisclaimer: jest.fn() }), })); jest.mock('@/store/swapStore', () => ({ + DerivedSwapInfoProvider: ({ children }: { children: unknown }) => children, useSwapState: (selector: (state: any) => unknown) => selector({ currentModal: mockCurrentModal, diff --git a/constants/__tests__/transfiProhibitedCountries.test.ts b/constants/__tests__/transfiProhibitedCountries.test.ts new file mode 100644 index 000000000..39d811929 --- /dev/null +++ b/constants/__tests__/transfiProhibitedCountries.test.ts @@ -0,0 +1,33 @@ +const loadCompliance = (value: string | undefined): typeof import('@/constants/compliance') => { + let compliance!: typeof import('@/constants/compliance'); + jest.isolateModules(() => { + jest.doMock('@/lib/config', () => ({ EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES: value })); + // eslint-disable-next-line @typescript-eslint/no-require-imports -- a fresh module per env value + compliance = require('@/constants/compliance'); + }); + return compliance; +}; + +describe('TRANSFI_PROHIBITED_COUNTRIES', () => { + afterEach(() => jest.dontMock('@/lib/config')); + + it('falls back to UA, RU and BY when the env var is unset', () => { + expect(loadCompliance(undefined).TRANSFI_PROHIBITED_COUNTRIES).toEqual(['UA', 'RU', 'BY']); + }); + + it('parses a comma-separated list, trimming and upper-casing each code', () => { + const { TRANSFI_PROHIBITED_COUNTRIES, isTransfiProhibitedCountry } = + loadCompliance(' ua, ru ,,IR '); + expect(TRANSFI_PROHIBITED_COUNTRIES).toEqual(['UA', 'RU', 'IR']); + expect(isTransfiProhibitedCountry('ir')).toBe(true); + expect(isTransfiProhibitedCountry('BY')).toBe(false); + }); + + it('blocks nothing when the env var is set empty', () => { + expect(loadCompliance('').TRANSFI_PROHIBITED_COUNTRIES).toEqual([]); + }); + + it('never treats an unknown country as prohibited', () => { + expect(loadCompliance(undefined).isTransfiProhibitedCountry(undefined)).toBe(false); + }); +}); diff --git a/constants/compliance.ts b/constants/compliance.ts index 68b69d6b2..d62d495b5 100644 --- a/constants/compliance.ts +++ b/constants/compliance.ts @@ -1,3 +1,5 @@ +import { EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES } from '@/lib/config'; + export const OFAC_SANCTIONED_COUNTRIES = ['CU', 'IR', 'KP', 'SY', 'RU', 'BY'] as const; export const CRYPTO_BANNED_COUNTRIES = [ @@ -61,6 +63,25 @@ export const MERCURYO_ALL_RESTRICTED: string[] = [ ...MERCURYO_EXCLUDED_COUNTRIES, ]; +const DEFAULT_TRANSFI_PROHIBITED_COUNTRIES = ['UA', 'RU', 'BY']; + +/** + * Countries TransFi will not onboard, so its local-currency cash deposits are + * not offered there. Read from EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES so the + * list can follow TransFi's without a code change; their published list is at + * https://docs.transfi.com/docs/prohibited-countries. + */ +export const TRANSFI_PROHIBITED_COUNTRIES: string[] = + EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES === undefined + ? DEFAULT_TRANSFI_PROHIBITED_COUNTRIES + : EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES.split(',') + .map((code: string) => code.trim().toUpperCase()) + .filter(Boolean); + +export function isTransfiProhibitedCountry(countryCode: string | undefined): boolean { + return !!countryCode && TRANSFI_PROHIBITED_COUNTRIES.includes(countryCode.toUpperCase()); +} + export type FeatureType = 'swap' | 'bridge' | 'buyCrypto' | 'bankTransfer' | 'card'; export function isCountryRestricted(countryCode: string, feature: FeatureType): boolean { diff --git a/constants/modals.ts b/constants/modals.ts index c6ec6f91a..2468aa6bf 100644 --- a/constants/modals.ts +++ b/constants/modals.ts @@ -29,9 +29,9 @@ export const DEPOSIT_MODAL = { }, /** * "Deposit US Dollars" — how to fund in USD: the bank rail (the virtual - * account's own wire/ACH details) or Cash App. Only reached where Cash App is - * available; elsewhere the cash list opens the virtual account directly, - * since a chooser with one option asks a question with one answer. + * account's own wire/ACH details), Apple Pay through Onramper's widget, or + * Cash App where it is available. Always reached from the cash list's USD + * row, since the first two are offered everywhere. */ OPEN_DEPOSIT_USD_METHOD: { name: 'open_deposit_usd_method', diff --git a/constants/routing.ts b/constants/routing.ts index c35efd4bc..5dfc8bf3a 100644 --- a/constants/routing.ts +++ b/constants/routing.ts @@ -4,20 +4,18 @@ import { ChainsId } from './chains'; import { STABLECOINS_TOKENS } from './tokens'; type ChainTokenList = { - readonly [chainId: number]: Token[]; + readonly [chainId: number]: Token[]; }; export const WNATIVE_EXTENDED: { [chainId: number]: Token } = { - ...WNATIVE, + ...WNATIVE, }; const WNATIVE_ONLY: ChainTokenList = Object.fromEntries( - Object.entries(WNATIVE_EXTENDED).map(([key, value]) => [key, [value]]) + Object.entries(WNATIVE_EXTENDED).map(([key, value]) => [key, [value]]), ); export const BASES_TO_CHECK_TRADES_AGAINST: ChainTokenList = { - ...WNATIVE_ONLY, - [ChainsId.Fuse]: [...WNATIVE_ONLY[ChainsId.Fuse], ...Object.values(STABLECOINS_TOKENS)], + ...WNATIVE_ONLY, + [ChainsId.Fuse]: [...WNATIVE_ONLY[ChainsId.Fuse], ...Object.values(STABLECOINS_TOKENS)], }; - -export const VOLTAGE_FINANCE_API_ROUTER = 'https://router.voltage.finance/swap/v1/quote'; diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index eeb1b12a7..ea901dae4 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -183,6 +183,15 @@ export const TRACKING_EVENTS = { BUY_CRYPTO_KYC_HOSTED_RETRY_PAGE_OPENED: 'buy_crypto_kyc_hosted_retry_page_opened', BUY_CRYPTO_KYC_HOSTED_RETRY_UNAVAILABLE: 'buy_crypto_kyc_hosted_retry_unavailable', BUY_CRYPTO_KYC_HOSTED_RETRY_FAILED: 'buy_crypto_kyc_hosted_retry_failed', + /** + * KYC upgrade, offered when an order breaches the limits of the user's current + * TransFi level (the press itself is BUY_CRYPTO_ERROR_ACTION_PRESSED): + * _PAGE_OPENED when TransFi's page is handed to them, _IN_REVIEW when TransFi + * is already reviewing a submission, _FAILED when the request errors. + */ + BUY_CRYPTO_KYC_UPGRADE_PAGE_OPENED: 'buy_crypto_kyc_upgrade_page_opened', + BUY_CRYPTO_KYC_UPGRADE_IN_REVIEW: 'buy_crypto_kyc_upgrade_in_review', + BUY_CRYPTO_KYC_UPGRADE_FAILED: 'buy_crypto_kyc_upgrade_failed', BUY_CRYPTO_AMOUNT_VIEWED: 'buy_crypto_amount_viewed', BUY_CRYPTO_CURRENCY_SELECTED: 'buy_crypto_currency_selected', BUY_CRYPTO_PAYMENT_METHOD_SELECTED: 'buy_crypto_payment_method_selected', diff --git a/constants/transaction.ts b/constants/transaction.ts index e3a8c2158..47248c604 100644 --- a/constants/transaction.ts +++ b/constants/transaction.ts @@ -82,6 +82,10 @@ export const TRANSACTION_DETAILS: Record = sign: TransactionDirection.IN, category: TransactionCategory.REWARD, }, + [TransactionType.YIELD_BOOST_CLAIM]: { + sign: TransactionDirection.IN, + category: TransactionCategory.REWARD, + }, [TransactionType.CARD_WELCOME_BONUS]: { sign: TransactionDirection.IN, category: TransactionCategory.REWARD, diff --git a/hooks/__tests__/useQuoteInput.test.tsx b/hooks/__tests__/useQuoteInput.test.tsx new file mode 100644 index 000000000..5d2fe2bb7 --- /dev/null +++ b/hooks/__tests__/useQuoteInput.test.tsx @@ -0,0 +1,95 @@ +import React, { useEffect } from 'react'; + +import { QUOTE_DEBOUNCE_MS, useQuoteInput } from '@/hooks/swap/useQuoteInput'; +import { SwapField, SwapFieldType } from '@/lib/types/swap-field'; +// react-test-renderer is supplied by jest-expo without bundled declarations. +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +let input: ReturnType; +let root: ReturnType; + +function Harness({ field, value }: { field: SwapFieldType; value: string }) { + const result = useQuoteInput(field, value); + useEffect(() => { + input = result; + }); + return null; +} + +const render = (value: string, field: SwapFieldType = SwapField.INPUT) => + act(() => { + if (root) root.update(); + else root = create(); + }); +const wait = (ms: number) => + act(() => { + jest.advanceTimersByTime(ms); + }); + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + jest.useFakeTimers(); + root = undefined; +}); +afterEach(() => { + act(() => root?.unmount()); + jest.useRealTimers(); +}); + +it('quotes a typed amount only once typing pauses', () => { + render('1'); + render('10'); + expect(input).toMatchObject({ typedValue: '1', pending: true }); + + wait(QUOTE_DEBOUNCE_MS - 1); + expect(input).toMatchObject({ typedValue: '1', pending: true }); + + wait(1); + expect(input).toMatchObject({ typedValue: '10', pending: false }); +}); + +it('restarts the wait on every keystroke', () => { + render('1'); + render('10'); + wait(QUOTE_DEBOUNCE_MS - 50); + render('100'); + wait(QUOTE_DEBOUNCE_MS - 50); + expect(input).toMatchObject({ typedValue: '1', pending: true }); + + wait(50); + expect(input).toMatchObject({ typedValue: '100', pending: false }); +}); + +it('drops a cleared amount at once', () => { + render('100'); + render(''); + expect(input).toMatchObject({ typedValue: '', pending: false }); +}); + +it('does not wait when the pair flips and the amount moves to the other side', () => { + render('100'); + render('100', SwapField.OUTPUT); + expect(input).toMatchObject({ + independentField: SwapField.OUTPUT, + typedValue: '100', + pending: false, + }); +}); + +it('keeps quoting the old field while an amount typed into the other one waits', () => { + render('100'); + render('5', SwapField.OUTPUT); + expect(input).toMatchObject({ + independentField: SwapField.INPUT, + typedValue: '100', + pending: true, + }); + + wait(QUOTE_DEBOUNCE_MS); + expect(input).toMatchObject({ + independentField: SwapField.OUTPUT, + typedValue: '5', + pending: false, + }); +}); diff --git a/hooks/__tests__/useSwapExecution.test.tsx b/hooks/__tests__/useSwapExecution.test.tsx index 6d8f895aa..98a2e96be 100644 --- a/hooks/__tests__/useSwapExecution.test.tsx +++ b/hooks/__tests__/useSwapExecution.test.tsx @@ -1,7 +1,6 @@ import React, { useEffect } from 'react'; import { useSwapCallback } from '@/hooks/swap/useSwapCallback'; -import { useVoltageSwapCallback } from '@/hooks/swap/useVoltageSwapCallback'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { act, create } = require('react-test-renderer'); @@ -27,8 +26,7 @@ jest.mock('@/hooks/swap/useSwapFeeCollection', () => ({ useSwapFeeCollection: () => ({ feeTransaction: null, reportCollectedFee: jest.fn() }), })); jest.mock('@/hooks/useApprove', () => ({ - useApproveCallbackFromTrade: () => ({ needAllowance: false }), - useApproveCallbackFromVoltageTrade: () => ({ needAllowance: false }), + useApproveCallbackFromTrade: () => mockApproval, })); jest.mock('@/hooks/useUser', () => ({ __esModule: true, @@ -54,6 +52,7 @@ jest.mock('@/store/useRewardsUpgradeStore', () => ({ const mockCalls = [{ calldata: '0x123', value: '0x00' }]; let mockUser: any; +let mockApproval: { needAllowance: boolean; approvalConfig?: any }; const amount = { toSignificant: () => '100', currency: { symbol: 'USDC', isToken: false }, @@ -62,25 +61,13 @@ const trade = { inputAmount: amount, outputAmount: amount, to: '0xrouter', data: const slippage = { toSignificant: () => '0.5' }; const receipt = { transactionHash: '0xhash', status: 'success' }; const execute = executeTransactions as jest.Mock; -let hook: ReturnType | ReturnType; +let hook: ReturnType; let root: ReturnType; const onSuccess = jest.fn(); -function StandardHarness({ hasTrade }: { hasTrade: boolean }) { +function StandardHarness({ hasTrade, swapTrade = trade }: { hasTrade: boolean; swapTrade?: any }) { const result = useSwapCallback( - hasTrade ? (trade as any) : undefined, - slippage as any, - { onSuccess } as any, - ); - useEffect(() => { - hook = result; - }); - return null; -} - -function VoltageHarness({ hasTrade }: { hasTrade: boolean }) { - const result = useVoltageSwapCallback( - hasTrade ? (trade as any) : undefined, + hasTrade ? swapTrade : undefined, slippage as any, { onSuccess } as any, ); @@ -94,19 +81,15 @@ beforeEach(() => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; jest.clearAllMocks(); mockUser = { userId: 'a', safeAddress: '0xwallet', suborgId: 'org', signWith: 'passkey' }; + mockApproval = { needAllowance: false }; execute.mockResolvedValue(receipt); }); afterEach(() => act(() => root?.unmount())); -describe.each([ - ['standard', false], - ['Voltage', true], -] as const)('%s swap readiness and errors', (_name, voltage) => { +describe('swap readiness and errors', () => { const mount = (hasTrade = true) => act(() => { - root = create( - voltage ? : , - ); + root = create(); }); it('does not advertise an executable purchase without a wallet signer', () => { @@ -150,3 +133,34 @@ describe.each([ expect(onSuccess).toHaveBeenCalledTimes(1); }); }); + +describe('token approval', () => { + const token = { symbol: 'USDC', isToken: true, address: '0xtoken' }; + const tokenAmount = { toSignificant: () => '100', currency: token }; + const tokenTrade = { ...trade, inputAmount: tokenAmount }; + const approval = { + request: { address: '0xtoken', abi: [], functionName: 'approve', args: ['0xrouter', 100n] }, + }; + const batch = () => execute.mock.calls[0][1] as { to: string }[]; + + const swap = async () => { + act(() => { + root = create(); + }); + await act(async () => { + await hook.callback!(); + }); + }; + + it('adds no approval when the allowance already covers the swap', async () => { + mockApproval = { needAllowance: false }; + await swap(); + expect(batch().map(tx => tx.to)).toEqual(['0xrouter']); + }); + + it('approves the exact amount ahead of the swap when the allowance falls short', async () => { + mockApproval = { needAllowance: true, approvalConfig: approval }; + await swap(); + expect(batch().map(tx => tx.to)).toEqual(['0xtoken', '0xrouter']); + }); +}); diff --git a/hooks/swap/useBestTrade.ts b/hooks/swap/useBestTrade.ts index 2d2964a66..382c22477 100644 --- a/hooks/swap/useBestTrade.ts +++ b/hooks/swap/useBestTrade.ts @@ -1,220 +1,101 @@ -import { useMemo } from 'react'; -import { Currency, CurrencyAmount, Route, Trade, TradeType } from '@cryptoalgebra/fuse-sdk'; +import { useCallback, useMemo } from 'react'; +import { Currency, CurrencyAmount, Trade, TradeType } from '@cryptoalgebra/fuse-sdk'; import { TradeState, TradeStateType } from '@/lib/types/trade-state'; +import { quotedAmount, QuoterResult, selectBestQuote } from '@/lib/utils/swap/quotes'; import { useAllRoutes } from './useAllRoutes'; import { useQuotesResults } from './useQuotesResults'; -// const DEFAULT_GAS_QUOTE = 2_000_000 +export interface BestTrade { + state: TradeStateType; + trade: Trade | null; + fee?: readonly number[] | null; + priceAfterSwap?: readonly bigint[] | null; + /** + * Fetches a fresh quote for the route `trade` is on. Resolves with that + * route's new amount (the output on exact-in, the input on exact-out), or + * undefined when there's no trade or the route can't be priced right now. + */ + requote: () => Promise; +} /** - * Returns the best v3 trade for a desired exact input swap - * @param amountIn the amount to swap in - * @param currencyOut the desired output currency + * The best Algebra trade for a swap, in either direction. + * + * `amount` is the side the user fixed: the input on exact-in, the output on + * exact-out. Routes come from the currency pair alone, so they're found before + * an amount is typed and the first quote only waits on the quoter. */ -export function useBestTradeExactIn( - amountIn?: CurrencyAmount, - currencyOut?: Currency, -): { - state: TradeStateType; - trade: Trade | null; - fee?: bigint[] | null; - priceAfterSwap?: bigint[] | null; -} { - const { routes, loading: routesLoading } = useAllRoutes(amountIn?.currency, currencyOut); +export function useBestTrade( + tradeType: TradeType, + amount: CurrencyAmount | undefined, + currencyIn: Currency | undefined, + currencyOut: Currency | undefined, +): BestTrade { + const exactInput = tradeType === TradeType.EXACT_INPUT; + const { routes, loading: routesLoading } = useAllRoutes(currencyIn, currencyOut); const { data: quotesResults, isLoading: isQuotesLoading, refetch, - } = useQuotesResults({ - exactInput: true, - amountIn, - currencyOut, - }); + } = useQuotesResults({ routes, exactInput, amount }); - const trade = useMemo(() => { - if (!amountIn || !currencyOut) { - return { - state: TradeState.INVALID, - trade: null, - refetch, - }; + const best = useMemo(() => selectBestQuote(quotesResults, tradeType), [quotesResults, tradeType]); + + const requote = useCallback(async () => { + if (!best) return undefined; + const { data } = await refetch(); + const result = data?.[best.index]?.result as QuoterResult | undefined; + return result ? quotedAmount(result, tradeType) : undefined; + }, [best, refetch, tradeType]); + + return useMemo(() => { + if (!amount || !currencyIn || !currencyOut) { + return { state: TradeState.INVALID, trade: null, requote }; } if (routesLoading || isQuotesLoading) { - return { - state: TradeState.LOADING, - trade: null, - }; + return { state: TradeState.LOADING, trade: null, requote }; } - const { bestRoute, amountOut, fee, priceAfterSwap } = (quotesResults || []).reduce( - ( - currentBest: { - bestRoute: Route | null; - amountOut: any | null; - fee: bigint[] | null; - priceAfterSwap: bigint[] | null; - }, - { result }: any, - i, - ) => { - if (!result) return currentBest; - - if (currentBest.amountOut === null) { - return { - bestRoute: routes[i], - amountOut: result[0][result[0].length - 1], - fee: result[5], - priceAfterSwap: result[2], - }; - } else if (currentBest.amountOut < result[0][result[0].length - 1]) { - return { - bestRoute: routes[i], - amountOut: result[0][result[0].length - 1], - fee: result[5], - priceAfterSwap: result[2], - }; - } - - return currentBest; - }, - { - bestRoute: null, - amountOut: null, - fee: null, - priceAfterSwap: null, - }, - ); - - if (!bestRoute || !amountOut) { + if (!best) { return { state: TradeState.NO_ROUTE_FOUND, trade: null, fee: null, priceAfterSwap: null, + requote, }; } - return { - state: TradeState.VALID, - fee, - trade: Trade.createUncheckedTrade({ - route: bestRoute, - tradeType: TradeType.EXACT_INPUT, - inputAmount: amountIn, - outputAmount: CurrencyAmount.fromRawAmount(currencyOut, amountOut.toString()), - }), - priceAfterSwap, - refetch, - }; - }, [amountIn, currencyOut, quotesResults, routes, routesLoading, isQuotesLoading, refetch]); - return trade; -} - -/** - * Returns the best v3 trade for a desired exact output swap - * @param currencyIn the desired input currency - * @param amountOut the amount to swap out - */ -export function useBestTradeExactOut( - currencyIn?: Currency, - amountOut?: CurrencyAmount, -): { - state: TradeStateType; - trade: Trade | null; - fee?: bigint[] | null; - priceAfterSwap?: bigint[] | null; -} { - const { routes, loading: routesLoading } = useAllRoutes(currencyIn, amountOut?.currency); - - const { - data: quotesResults, - isLoading: isQuotesLoading, - refetch, - } = useQuotesResults({ - exactInput: false, - currencyIn, - amountOut, - }); - - const trade = useMemo(() => { - if (!amountOut || !currencyIn) { - return { - state: TradeState.INVALID, - trade: null, - refetch, - }; - } - - if (routesLoading || isQuotesLoading) { - return { - state: TradeState.LOADING, - trade: null, - }; - } - - const { bestRoute, amountIn, fee, priceAfterSwap } = (quotesResults || []).reduce( - ( - currentBest: { - bestRoute: Route | null; - amountIn: any | null; - fee: bigint[] | null; - priceAfterSwap: bigint[] | null; - }, - { result }: any, - i, - ) => { - if (!result) return currentBest; - - if (currentBest.amountIn === null) { - return { - bestRoute: routes[i], - amountIn: result[1][result[1].length - 1], - fee: result[5], - priceAfterSwap: result[2], - }; - } else if (currentBest.amountIn > result[0][result[0].length - 1]) { - return { - bestRoute: routes[i], - amountIn: result[1][result[1].length - 1], - fee: result[5], - priceAfterSwap: result[2], - }; - } - - return currentBest; - }, - { - bestRoute: null, - amountIn: null, - fee: null, - priceAfterSwap: null, - }, + const quoted = CurrencyAmount.fromRawAmount( + exactInput ? currencyOut : currencyIn, + best.amount.toString(), ); - if (!bestRoute || !amountIn) { - return { - state: TradeState.NO_ROUTE_FOUND, - trade: null, - fee: null, - priceAfterSwap, - }; - } - return { state: TradeState.VALID, - fee, trade: Trade.createUncheckedTrade({ - route: bestRoute, - tradeType: TradeType.EXACT_OUTPUT, - inputAmount: CurrencyAmount.fromRawAmount(currencyIn, amountIn.toString()), - outputAmount: amountOut, + route: routes[best.index], + tradeType, + inputAmount: exactInput ? amount : quoted, + outputAmount: exactInput ? quoted : amount, }), - priceAfterSwap, - refetch, + fee: best.fee, + priceAfterSwap: best.priceAfterSwap, + requote, }; - }, [amountOut, currencyIn, quotesResults, routes, routesLoading, isQuotesLoading, refetch]); - return trade; + }, [ + amount, + currencyIn, + currencyOut, + routesLoading, + isQuotesLoading, + best, + routes, + tradeType, + exactInput, + requote, + ]); } diff --git a/hooks/swap/useQuoteInput.ts b/hooks/swap/useQuoteInput.ts new file mode 100644 index 000000000..dd2588a9d --- /dev/null +++ b/hooks/swap/useQuoteInput.ts @@ -0,0 +1,35 @@ +import { useEffect, useState } from 'react'; + +import { SwapFieldType } from '@/lib/types/swap-field'; + +/** How long typing has to pause before the amount is quoted. */ +export const QUOTE_DEBOUNCE_MS = 300; + +/** + * The swap form's field and amount as far as quoting goes: what's typed, once + * typing pauses. + * + * Only a new, non-empty amount waits. Clearing the field, or flipping the pair + * (which carries the same amount over to the other side), applies at once, so + * the form never quotes an amount that's already gone. While an amount waits, + * this keeps returning the last one it let through, with `pending` set. + */ +export function useQuoteInput(independentField: SwapFieldType, typedValue: string) { + const [settled, setSettled] = useState({ independentField, typedValue }); + const pending = typedValue !== '' && typedValue !== settled.typedValue; + + useEffect(() => { + const timeout = setTimeout( + () => + setSettled(current => + current.independentField === independentField && current.typedValue === typedValue + ? current + : { independentField, typedValue }, + ), + pending ? QUOTE_DEBOUNCE_MS : 0, + ); + return () => clearTimeout(timeout); + }, [independentField, typedValue, pending]); + + return pending ? { ...settled, pending } : { independentField, typedValue, pending }; +} diff --git a/hooks/swap/useQuotesResults.ts b/hooks/swap/useQuotesResults.ts index 67ed80359..a18c651eb 100644 --- a/hooks/swap/useQuotesResults.ts +++ b/hooks/swap/useQuotesResults.ts @@ -1,44 +1,35 @@ import { useMemo } from 'react'; -import { Currency, CurrencyAmount, encodeRouteToPath } from '@cryptoalgebra/fuse-sdk'; +import { Currency, CurrencyAmount, encodeRouteToPath, Route } from '@cryptoalgebra/fuse-sdk'; import { useReadContracts } from 'wagmi'; import { ALGEBRA_QUOTER_V2 } from '@/constants/addresses'; import { algebraQuoterV2ABI } from '@/lib/abis'; import { fuseConfig } from '@/lib/wagmi'; -import { useAllRoutes } from './useAllRoutes'; +/** How often a quote left on screen is re-fetched, so it can't go stale. */ +const QUOTE_REFRESH_MS = 15_000; export function useQuotesResults({ + routes, exactInput, - amountIn, - amountOut, - currencyIn, - currencyOut, + amount, }: { + routes: Route[]; exactInput: boolean; - amountIn?: CurrencyAmount; - amountOut?: CurrencyAmount; - currencyIn?: Currency; - currencyOut?: Currency; + /** The input on exact-in, the output on exact-out. */ + amount?: CurrencyAmount; }) { - const { routes, loading: routesLoading } = useAllRoutes( - exactInput ? amountIn?.currency : currencyIn, - !exactInput ? amountOut?.currency : currencyOut, + const quoteInputs = useMemo( + () => + amount + ? routes.map(route => [ + encodeRouteToPath(route, !exactInput), + `0x${amount.quotient.toString(16)}`, + ]) + : [], + [amount, routes, exactInput], ); - const quoteInputs = useMemo(() => { - return routes.map(route => [ - encodeRouteToPath(route, !exactInput), - exactInput - ? amountIn - ? `0x${amountIn.quotient.toString(16)}` - : undefined - : amountOut - ? `0x${amountOut.quotient.toString(16)}` - : undefined, - ]); - }, [amountIn, amountOut, routes, exactInput]); - const functionName = exactInput ? 'quoteExactInput' : 'quoteExactOutput'; const { @@ -54,14 +45,15 @@ export function useQuotesResults({ })), config: fuseConfig, query: { - enabled: true, + enabled: quoteInputs.length > 0, staleTime: 5_000, + refetchInterval: QUOTE_REFRESH_MS, }, }); return { data: quotesResults, - isLoading: isLoading || routesLoading, + isLoading, refetch, }; } diff --git a/hooks/swap/useSwapCallback.ts b/hooks/swap/useSwapCallback.ts index 202114cfb..f65eeb55d 100644 --- a/hooks/swap/useSwapCallback.ts +++ b/hooks/swap/useSwapCallback.ts @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { Currency, Percent, Trade, TradeType } from '@cryptoalgebra/fuse-sdk'; import * as Sentry from '@sentry/react-native'; import { useQueryClient } from '@tanstack/react-query'; -import { Address, encodeFunctionData, erc20Abi } from 'viem'; +import { Address, encodeFunctionData } from 'viem'; import { fuse } from 'viem/chains'; import { algebraRouterConfig } from '@/generated/wagmi'; @@ -46,9 +46,6 @@ export function useSwapCallback( const queryClient = useQueryClient(); const account = user?.safeAddress; - const [bestCall, setBestCall] = useState( - undefined, - ); const [swapData, setSwapData] = useState(null); const [isSendingSwap, setIsSendingSwap] = useState(false); @@ -59,54 +56,46 @@ export function useSwapCallback( trade?.inputAmount.currency, ); - useEffect(() => { - function findBestCall() { - if (!swapCalldata || !account) return; - - setBestCall(undefined); - - // For AA wallets, we skip simulation entirely - // The bundler and paymaster will handle validation and gas estimation - if (swapCalldata.length > 0) { - const { calldata, value: _value } = swapCalldata[0]; // Use the first valid call + // Derived rather than set from an effect, so a new quote never renders a frame + // with the trade in place but its call still missing. + const bestCall = useMemo((): SuccessfulCall | undefined => { + if (!account) return undefined; - if (!calldata || !_value) { - console.warn('Invalid swap calldata'); - return; - } - - const value = BigInt(_value); - - // Set the best call without simulation - // The AA infrastructure will handle the actual gas estimation - setBestCall({ - calldata, - value, - gasEstimate: 500000n, // Conservative estimate for AA transactions - }); + // For AA wallets, we skip simulation entirely + // The bundler and paymaster will handle validation and gas estimation + const [call] = swapCalldata; // Use the first valid call + if (!call) return undefined; - Sentry.addBreadcrumb({ - message: 'Swap call prepared for AA wallet', - category: 'swap', - level: 'info', - data: { - account, - value: value.toString(), - hasCalldata: !!calldata, - }, - }); - } + if (!call.calldata || !call.value) { + console.warn('Invalid swap calldata'); + return undefined; } - findBestCall(); + // The AA infrastructure will handle the actual gas estimation + return { + calldata: call.calldata, + value: BigInt(call.value), + gasEstimate: 500000n, // Conservative estimate for AA transactions + }; }, [swapCalldata, account]); + useEffect(() => { + if (!bestCall) return; + Sentry.addBreadcrumb({ + message: 'Swap call prepared for AA wallet', + category: 'swap', + level: 'info', + data: { + account, + value: bestCall.value.toString(), + hasCalldata: true, + }, + }); + }, [bestCall, account]); + // Get approval info const { approvalConfig, needAllowance } = useApproveCallbackFromTrade(trade, allowedSlippage); - // For token inputs, check if we need approval - const isTokenInput = trade?.inputAmount.currency.isToken; - // Get the actual router address from swap config to ensure approval spender matches const actualRouterAddress = algebraRouterConfig.address as Address; @@ -144,55 +133,31 @@ export function useSwapCallback( const transactions: { to: Address; data: `0x${string}`; value: bigint }[] = []; - // Add approval transaction if needed - if (needAllowance || (isTokenInput && !approvalConfig)) { - if (approvalConfig) { - Sentry.addBreadcrumb({ - message: 'Adding approval transaction', - category: 'swap', - level: 'debug', - data: { - tokenAddress: approvalConfig.request.address, - spender: approvalConfig.request.args?.[0], - amount: approvalConfig.request.args?.[1]?.toString(), - }, - }); - - transactions.push({ - to: approvalConfig.request.address, - data: encodeFunctionData({ - abi: approvalConfig.request.abi, - functionName: approvalConfig.request.functionName, - args: approvalConfig.request.args, - }), - value: 0n, - }); - } else if (trade.inputAmount.currency.isToken) { - // Generate a fallback approval transaction - - // Create max approval transaction as fallback - const maxApproval = '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff'; - transactions.push({ - to: trade.inputAmount.currency.address as Address, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [actualRouterAddress, BigInt(maxApproval)], - }), - value: 0n, - }); - - Sentry.addBreadcrumb({ - message: 'Adding fallback approval transaction', - category: 'swap', - level: 'warning', - data: { - tokenAddress: trade.inputAmount.currency.address, - spender: actualRouterAddress, - amount: 'MAX', - }, - }); - } + // Approve exactly what this swap can spend, and only when the router's + // allowance doesn't already cover it. + if (needAllowance) { + if (!approvalConfig) throw new Error('Unable to prepare the token approval. Try again.'); + + Sentry.addBreadcrumb({ + message: 'Adding approval transaction', + category: 'swap', + level: 'debug', + data: { + tokenAddress: approvalConfig.request.address, + spender: approvalConfig.request.args?.[0], + amount: approvalConfig.request.args?.[1]?.toString(), + }, + }); + + transactions.push({ + to: approvalConfig.request.address, + data: encodeFunctionData({ + abi: approvalConfig.request.abi, + functionName: approvalConfig.request.functionName, + args: approvalConfig.request.args, + }), + value: 0n, + }); } Sentry.addBreadcrumb({ @@ -357,7 +322,6 @@ export function useSwapCallback( swapConfig, approvalConfig, needAllowance, - isTokenInput, account, user, safeAA, @@ -365,7 +329,6 @@ export function useSwapCallback( successInfo, trackTransaction, queryClient, - actualRouterAddress, feeTransaction, reportCollectedFee, ]); diff --git a/hooks/swap/useVoltageRouter.ts b/hooks/swap/useVoltageRouter.ts deleted file mode 100644 index c9e89d9af..000000000 --- a/hooks/swap/useVoltageRouter.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { useEffect, useMemo, useState } from 'react'; -import { ExtendedNative } from '@cryptoalgebra/fuse-sdk'; -import * as Sentry from '@sentry/react-native'; -import { Currency, CurrencyAmount, Percent, TradeType } from '@voltage-finance/sdk-core'; - -import { VOLTAGE_FINANCE_API_ROUTER } from '@/constants/routing'; -import { WFUSE_TOKEN } from '@/constants/tokens'; - -interface Source { - name: string; - proportion: number; -} - -export interface VoltageTrade { - inputAmount?: CurrencyAmount; - outputAmount?: CurrencyAmount; - data?: string; - allowanceTarget?: string; - to?: string; - value?: CurrencyAmount; - priceImpact?: Percent; - tradeType?: TradeType; - sources?: Source[]; - price?: number; -} - -interface Quote { - sellAmount: string; - buyAmount: string; - data: string; - allowanceTarget: string; - value: string; - to: string; - price: string; - sources: Source[]; - estimatedPriceImpact: string; -} - -async function getTrade( - inputToken: string, - outputToken: string, - inputAmount: string, - exactIn: boolean, - slippage: string, -): Promise { - return new Promise((resolve, reject) => { - const typeOfAmount = exactIn ? 'sellAmount' : 'buyAmount'; - - fetch( - `${VOLTAGE_FINANCE_API_ROUTER}?sellToken=${inputToken}&buyToken=${outputToken}&${typeOfAmount}=${inputAmount}&slippagePercentage=${slippage}`, - ) - .then(response => { - if (response.status === 200) { - resolve(response.json()); - } else { - reject(new Error('Failed to fetch quote')); - } - }) - .catch(error => reject(error)); - }); -} - -function getToken(currency?: Currency) { - return currency ? (currency.isToken ? currency.address : currency.symbol) : undefined; -} - -export function useVoltageRouter( - inputCurrency: Currency | ExtendedNative | undefined, - outputCurrency: Currency | ExtendedNative | undefined, - parsedAmount: CurrencyAmount | undefined, - isExactIn: boolean, - slippage: string, -): { trade: VoltageTrade | undefined; isLoading: boolean; isValid: boolean } { - const [quote, setQuote] = useState(null); - const [quoteLoading, setQuoteLoading] = useState(false); - - const inputToken = getToken(inputCurrency); - - const outputToken = getToken(outputCurrency); - - const amount = useMemo(() => parsedAmount?.quotient.toString(), [parsedAmount]); - - useEffect(() => { - let canceled = false; - - if (inputToken && outputToken && amount && slippage) { - setQuote(null); - setQuoteLoading(true); - - getTrade(inputToken, outputToken, amount, isExactIn, slippage) - .then(qoute => { - if (!canceled) { - setQuote(qoute); - setQuoteLoading(false); - } - }) - .catch(error => { - console.error('Failed to get Voltage trade quote:', error); - Sentry.captureException(error, { - tags: { - type: 'voltage_quote_error', - }, - extra: { - inputToken, - outputToken, - amount, - isExactIn, - slippage, - }, - }); - setQuote(null); - setQuoteLoading(false); - }); - } - - return () => { - canceled = true; - }; - }, [amount, inputToken, isExactIn, outputToken, slippage]); - - // Derive isValid from quote state instead of using setState in useMemo - const isValid = Boolean(quote && inputCurrency && outputCurrency); - - const trade = useMemo(() => { - if (!quote || !inputCurrency || !outputCurrency) { - return undefined; - } - return { - inputAmount: CurrencyAmount.fromRawAmount(inputCurrency, quote.sellAmount), - outputAmount: CurrencyAmount.fromRawAmount(outputCurrency, quote.buyAmount), - data: quote.data, - allowanceTarget: quote.allowanceTarget, - value: CurrencyAmount.fromRawAmount(WFUSE_TOKEN, quote.value), - priceImpact: new Percent( - String(parseInt(String(+quote.estimatedPriceImpact * 100))), - String(10000), - ), - price: parseFloat(quote.price), - to: quote.to, - tradeType: isExactIn ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT, - sources: quote.sources, - }; - }, [inputCurrency, isExactIn, outputCurrency, quote]); - - return { - trade, - isLoading: quoteLoading, - isValid, - }; -} diff --git a/hooks/swap/useVoltageSwapCallback.ts b/hooks/swap/useVoltageSwapCallback.ts deleted file mode 100644 index ef5c83218..000000000 --- a/hooks/swap/useVoltageSwapCallback.ts +++ /dev/null @@ -1,253 +0,0 @@ -import { useCallback, useMemo, useState } from 'react'; -import { Percent } from '@cryptoalgebra/fuse-sdk'; -import * as Sentry from '@sentry/react-native'; -import { useQueryClient } from '@tanstack/react-query'; -import { Address } from 'abitype'; -import { encodeFunctionData } from 'viem'; -import { fuse } from 'viem/chains'; - -import { useActivityActions } from '@/hooks/useActivityActions'; -import { useApproveCallbackFromVoltageTrade } from '@/hooks/useApprove'; -import { TransactionSuccessInfo, useTransactionAwait } from '@/hooks/useTransactionAwait'; -import useUser from '@/hooks/useUser'; -import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; -import { TransactionType } from '@/lib/types'; -import { SwapCallbackState } from '@/lib/types/swap-state'; -import { selectedRewardsUserId, useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; - -import { SwapFeeCollection, useSwapFeeCollection } from './useSwapFeeCollection'; -import { VoltageTrade } from './useVoltageRouter'; - -export function useVoltageSwapCallback( - trade: VoltageTrade | undefined, - allowedSlippage: Percent, - successInfo?: TransactionSuccessInfo, - swapFee?: SwapFeeCollection, -) { - const { user, safeAA } = useUser(); - const { trackTransaction } = useActivityActions(); - const queryClient = useQueryClient(); - const { needAllowance, approvalConfig } = useApproveCallbackFromVoltageTrade( - trade, - allowedSlippage, - ); - - const account = user?.safeAddress; - const [swapData, setSwapData] = useState(null); - const [isSendingSwap, setIsSendingSwap] = useState(false); - - const { feeTransaction, reportCollectedFee } = useSwapFeeCollection( - swapFee, - trade?.inputAmount?.currency, - ); - - const swapCallback = useCallback(async () => { - if (!trade) throw new Error('Unable to get a quote. Change the amount and try again.'); - if (!account || !user?.suborgId || !user?.signWith) - throw new Error('Your wallet is not ready. Reopen the swap and try again.'); - - try { - const accountSession = useRewardsUpgradeStore.getState().session; - const assertAccount = async () => { - if ( - selectedRewardsUserId() !== user?.userId || - useRewardsUpgradeStore.getState().session !== accountSession - ) - throw new Error('Account changed. Reopen the swap for the selected account.'); - }; - await assertAccount(); - setIsSendingSwap(true); - const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); - - const transactions: { to: Address; data: `0x${string}`; value?: bigint }[] = []; - - if (needAllowance && approvalConfig) { - Sentry.addBreadcrumb({ - message: 'Adding Voltage approval transaction', - category: 'swap', - level: 'debug', - data: { - tokenAddress: approvalConfig.request.address, - needAllowance, - spender: approvalConfig.request.args?.[0], - allowanceTarget: trade?.allowanceTarget, - transactionTarget: trade?.to, - }, - }); - - transactions.push({ - to: approvalConfig.request.address, - data: encodeFunctionData({ - abi: approvalConfig.request.abi, - functionName: approvalConfig.request.functionName, - args: approvalConfig.request.args, - }), - }); - } - - transactions.push({ - to: trade?.to as Address, - data: trade?.data as `0x${string}`, - value: BigInt(trade?.value?.quotient.toString() || '0'), - }); - - // Solid's fee, in the same batch so the user signs once. Appended after - // the swap rather than before it: on a native-currency swap the value the - // router needs is still in the wallet at this point, and taking the fee - // first could leave the swap itself short. - if (feeTransaction) { - transactions.push(feeTransaction); - } - - const result = await trackTransaction( - { - type: TransactionType.SWAP, - title: `Swap ${trade?.inputAmount?.toSignificant()} ${trade?.inputAmount?.currency?.symbol} to ${trade?.outputAmount?.toSignificant()} ${trade?.outputAmount?.currency?.symbol}`, - shortTitle: `${trade?.inputAmount?.currency?.symbol} → ${trade?.outputAmount?.currency?.symbol}`, - amount: trade?.inputAmount?.toSignificant() || '0', - symbol: trade?.inputAmount?.currency?.symbol || 'TOKEN', - chainId: fuse.id, - fromAddress: user.safeAddress, - toAddress: trade?.to as string, - metadata: { - description: `Swap ${trade?.inputAmount?.currency?.symbol} to ${trade?.outputAmount?.currency?.symbol}`, - slippage: allowedSlippage?.toSignificant(2), - needsApproval: needAllowance, - platform: 'voltage', - inputToken: trade?.inputAmount?.currency?.symbol, - outputToken: trade?.outputAmount?.currency?.symbol, - inputAmount: trade?.inputAmount?.toSignificant(6), - outputAmount: trade?.outputAmount?.toSignificant(6), - }, - }, - onUserOpHash => - executeTransactions( - smartAccountClient, - transactions, - 'Voltage swap failed', - fuse, - onUserOpHash, - assertAccount, - ), - ); - - const transaction = - result && typeof result === 'object' && 'transaction' in result - ? result.transaction - : result; - - if (transaction === USER_CANCELLED_TRANSACTION) { - return; - } - - if (!transaction?.transactionHash || transaction.status !== 'success') - throw new Error('Swap has not been confirmed'); - - // Reported only past the confirmation guard, so we never book a fee for a - // swap that did not land. The hash lives on the result object, not on the - // unwrapped `transaction` (which is the receipt); narrowed with `in` - // because TransactionResult is a union with the user-cancelled symbol. - reportCollectedFee( - result && typeof result === 'object' && 'transactionHash' in result - ? result.transactionHash - : undefined, - ); - - // Invalidate all balance queries immediately after successful transaction - queryClient.invalidateQueries({ queryKey: ['balance'] }); - queryClient.invalidateQueries({ queryKey: ['tokenBalances'] }); - - // Call success callback immediately after transaction completes - if (successInfo?.onSuccess) { - successInfo.onSuccess(); - } - - setSwapData(result); - return transaction; - } catch (error) { - const message = error instanceof Error ? error.message.toLowerCase() : ''; - if ( - error === USER_CANCELLED_TRANSACTION || - message.includes('user cancelled') || - message.includes('user denied') || - message.includes('user rejected') - ) - return; - console.error('Voltage swap failed', error); - Sentry.captureException(error, { - tags: { - type: 'voltage_swap_execution_failed', - account, - }, - extra: { - inputAmount: trade?.inputAmount?.toSignificant(), - outputAmount: trade?.outputAmount?.toSignificant(), - allowedSlippage: allowedSlippage?.toSignificant(2), - needAllowance, - to: trade?.to, - value: trade?.value?.quotient.toString(), - }, - }); - throw error; - } finally { - setIsSendingSwap(false); - } - }, [ - trade, - account, - safeAA, - user, - allowedSlippage, - needAllowance, - approvalConfig, - successInfo, - trackTransaction, - queryClient, - feeTransaction, - reportCollectedFee, - ]); - - // useTransactionAwait handles balance invalidation and toast notifications - // We don't use its isLoading state since the transaction is already confirmed - // when executeTransactions returns (it waits for receipt internally) - const { isSuccess } = useTransactionAwait( - swapData?.transactionHash, - successInfo ? { ...successInfo, onSuccess: undefined } : undefined, - ); - - return useMemo(() => { - const error = - !account || !user?.suborgId || !user?.signWith - ? 'Your wallet is not ready. Reopen the swap and try again.' - : !trade - ? 'Unable to get a quote. Change the amount and try again.' - : undefined; - if (error) - return { - state: SwapCallbackState.INVALID, - callback: null, - error, - isLoading: false, - isSuccess: false, - needAllowance, - }; - - return { - state: SwapCallbackState.VALID, - callback: swapCallback, - error: null, - isLoading: isSendingSwap, - isSuccess, - needAllowance, - }; - }, [ - trade, - swapCallback, - isSuccess, - isSendingSwap, - needAllowance, - account, - user?.suborgId, - user?.signWith, - ]); -} diff --git a/hooks/useApprove.ts b/hooks/useApprove.ts index 488aad146..a83ce651d 100644 --- a/hooks/useApprove.ts +++ b/hooks/useApprove.ts @@ -1,18 +1,17 @@ -import { Currency, CurrencyAmount, Percent, Trade, TradeType } from '@cryptoalgebra/fuse-sdk'; import { useCallback, useMemo, useState } from 'react'; +import { Currency, CurrencyAmount, Percent, Trade, TradeType } from '@cryptoalgebra/fuse-sdk'; +import { Address, encodeFunctionData, erc20Abi } from 'viem'; +import { fuse } from 'viem/chains'; +import { useSimulateContract } from 'wagmi'; import { ALGEBRA_ROUTER, PEG_SWAP } from '@/constants/addresses'; import { MarketData } from '@/constants/lend'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; -import { VoltageTrade } from '@/hooks/swap/useVoltageRouter'; import { useNeedAllowance } from '@/hooks/tokens/useNeedAllowance'; import { track } from '@/lib/analytics'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; import { ApprovalState, ApprovalStateType } from '@/lib/types/approve-state'; -import { computeSlippageAdjustedAmounts } from '@/lib/utils/swap/prices'; -import { Address, encodeFunctionData, erc20Abi } from 'viem'; -import { fuse } from 'viem/chains'; -import { useSimulateContract } from 'wagmi'; + import { useTransactionAwait } from './useTransactionAwait'; import useUser from './useUser'; @@ -197,42 +196,6 @@ export function useApproveCallbackFromTrade( // Use custom spender if provided, otherwise default to ALGEBRA_ROUTER const spender = customSpender || ALGEBRA_ROUTER; - const { approvalState, approvalConfig, needAllowance, approvalCallback } = useApprove( - amountToApprove, - spender, - ); - - // Force needAllowance to true for token inputs when no approval config is generated - // This ensures we always generate approval configs for token inputs - const actualNeedAllowance = trade?.inputAmount.currency.isToken - ? needAllowance || !approvalConfig - : needAllowance; - - return { - approvalState, - approvalConfig, - needAllowance: actualNeedAllowance, - approvalCallback, - }; -} - -export function useApproveCallbackFromVoltageTrade( - trade: VoltageTrade | undefined, - allowedSlippage: Percent, -) { - const amountToApprove = useMemo( - () => - trade && trade.inputAmount?.currency.isToken - ? computeSlippageAdjustedAmounts(trade, allowedSlippage).inputAmount - : undefined, - [trade, allowedSlippage], - ); - // Use allowanceTarget from Voltage API, which is the contract that needs approval - // This is different from 'to' which is the transaction target - // Fallback to a zero address if neither is available (this will disable the hook) - const spender = (trade?.allowanceTarget || - trade?.to || - '0x0000000000000000000000000000000000000000') as Address; return useApprove(amountToApprove, spender); } diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index cdc7f93fd..6eb2dadae 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -343,7 +343,7 @@ const useDepositOption = ({ } if (isOnramperWidget) { - return setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH)} />; + return setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD)} />; } if (isDepositUsdMethod) { @@ -853,8 +853,8 @@ const useDepositOption = ({ // The onramp is only ever entered by picking a currency on the cash screen. setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); } else if (isOnramperWidget) { - // Entered from the cash screen's "Buy crypto" row. - setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); + // Entered from the USD methods' "Apple Pay" row. + setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD); } else if (isBuyCryptoCurrency || isBuyCryptoPaymentMethod) { setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT); } else if (isBuyCryptoPayment) { diff --git a/hooks/useOnramperWidget.ts b/hooks/useOnramperWidget.ts index b045e97e3..15b99e307 100644 --- a/hooks/useOnramperWidget.ts +++ b/hooks/useOnramperWidget.ts @@ -1,13 +1,13 @@ import { Platform } from 'react-native'; import { useQuery } from '@tanstack/react-query'; -import { fetchOnramperWidgetSession } from '@/lib/api'; +import { fetchOnramperWidgetSession, OnramperDestination } from '@/lib/api'; import { withRefreshToken } from '@/lib/utils'; const ONRAMPER_WIDGET_SESSION_KEY = 'onramperWidgetSession'; /** - * A signed widget URL for this platform. + * A signed widget URL for this platform, delivering to `destination`. * * Nothing is cached between mounts. Onramper's signature is single-use and * expires after 15 minutes, so a URL kept from a previous visit loads a widget @@ -15,11 +15,15 @@ const ONRAMPER_WIDGET_SESSION_KEY = 'onramperWidgetSession'; * is trying to pay. Minting one per open costs a request and removes the whole * class of problem. */ -export default function useOnramperWidget() { +export default function useOnramperWidget(destination: OnramperDestination) { return useQuery({ - queryKey: [ONRAMPER_WIDGET_SESSION_KEY, Platform.OS], + // The destination is part of the key: a wallet URL and a card URL deliver + // to different addresses, so one must never stand in for the other. + queryKey: [ONRAMPER_WIDGET_SESSION_KEY, Platform.OS, destination], queryFn: () => - withRefreshToken(() => fetchOnramperWidgetSession(Platform.OS === 'web' ? 'web' : 'native')), + withRefreshToken(() => + fetchOnramperWidgetSession(Platform.OS === 'web' ? 'web' : 'native', destination), + ), // A fresh URL every time this mounts, and none left behind afterwards. staleTime: 0, gcTime: 0, diff --git a/hooks/useOrchestra.ts b/hooks/useOrchestra.ts index 83e97e131..bf4bb0062 100644 --- a/hooks/useOrchestra.ts +++ b/hooks/useOrchestra.ts @@ -1,5 +1,6 @@ import { useMutation, useQuery } from '@tanstack/react-query'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; import { createOrchestraOnramp, getOrchestraConfig, getOrchestraStatus } from '@/lib/api/orchestra'; import { OrchestraError } from '@/lib/orchestraErrors'; import { ORCHESTRA_SETTLED_STATUSES } from '@/lib/types/orchestra'; @@ -48,6 +49,23 @@ export function useOrchestraConfig(countryCode?: string, enabled = true) { }); } +/** + * Whether Cash App can be offered here. One rule, decided server-side: + * supported region **or** allowlisted. The country is resolved on the client + * only because the backend has no geoip — the verdict is still theirs, and they + * enforce it again on order creation. + * + * Shared by the cash list's USD chips, the USD methods screen and the card + * funding modals' Cash App row, which ask the same question and share the one + * cached config call. + */ +export function useIsCashAppAvailable() { + const { countryCode, isResolving } = useCashAppDepositAvailability(); + const { data: config } = useOrchestraConfig(countryCode, !isResolving); + + return config?.isAvailable === true; +} + /** * Create the order and its invoice. * @@ -56,8 +74,15 @@ export function useOrchestraConfig(countryCode?: string, enabled = true) { */ export function useCreateOrchestraOnramp() { return useMutation({ - mutationFn: ({ amountFiatUsd, countryCode }: { amountFiatUsd: string; countryCode?: string }) => - withRefreshToken(() => createOrchestraOnramp(amountFiatUsd, countryCode)), + mutationFn: ({ + amountFiatUsd, + countryCode, + destination, + }: { + amountFiatUsd: string; + countryCode?: string; + destination?: 'wallet' | 'card'; + }) => withRefreshToken(() => createOrchestraOnramp(amountFiatUsd, countryCode, destination)), retry: false, }); } diff --git a/hooks/useOrchestraCardEntry.ts b/hooks/useOrchestraCardEntry.ts new file mode 100644 index 000000000..ac724dda3 --- /dev/null +++ b/hooks/useOrchestraCardEntry.ts @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; + +import { OrchestraNavigate } from '@/components/Orchestra/OrchestraNavigation'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useIsCashAppAvailable } from '@/hooks/useOrchestra'; +import { track } from '@/lib/analytics'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +/** + * Opening the Cash App onramp from a card funding modal's USD methods. + * + * Shared by Rain's mobile and desktop modals so the availability gate and the + * destination are set the same way in each — a modal that forgot + * `setDestination('card')` would quietly fund the wallet from a screen titled + * "Fund your card". Wirex's has no USD section, so it offers no Cash App. + * + * `isAvailable` is the server's verdict, the same one the wallet flow gates + * on: region or allowlist. Hosts pass `onCashAppPress` only when it is true, + * which is how the row stays hidden rather than disabled. + */ +export const useOrchestraCardEntry = (navigate: OrchestraNavigate) => { + const reset = useOrchestraStore(state => state.reset); + const setDestination = useOrchestraStore(state => state.setDestination); + const isAvailable = useIsCashAppAvailable(); + + const openCashApp = useCallback(() => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'buy_crypto', + provider: 'orchestra', + currency: 'USD', + destination: 'card', + }); + // A previous order's invoice and read token would otherwise still be in + // the store, and the status screen would track it instead of the new one. + reset(); + setDestination('card'); + navigate(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + }, [navigate, reset, setDestination]); + + return { openCashApp, isAvailable }; +}; + +export default useOrchestraCardEntry; diff --git a/hooks/useRewards.ts b/hooks/useRewards.ts index 1d812664a..80c0e6844 100644 --- a/hooks/useRewards.ts +++ b/hooks/useRewards.ts @@ -3,13 +3,16 @@ import { minutesToMilliseconds, secondsToMilliseconds } from 'date-fns'; import { activateTierTrial, + claimYieldBoost, fetchReferralSummary, fetchRewardsConfig, fetchRewardsUserData, fetchTierBenefits, + fetchYieldBoostSummary, optInToRewards, } from '@/lib/api'; -import { RewardsUserData } from '@/lib/types'; +import { refreshRewardsAfterSavings } from '@/lib/refreshRewardsAfterSavings'; +import { RewardsUserData, YieldBoostSummary } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { selectedRewardsUserId, useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore'; import { useUserStore } from '@/store/useUserStore'; @@ -139,3 +142,53 @@ export const useActivateTierTrial = () => { }, }); }; + +/** How often the yield boost card re-reads a claim that is still being paid. */ +const YIELD_BOOST_PENDING_POLL_MS = secondsToMilliseconds(10); + +/** + * The user's yield boost: their tier's rate, what they have earned, and what + * is waiting to be claimed. + * + * It accrues once a day, so a minute of staleness is invisible. While a claim + * is still being paid it polls, so the card settles on its own once the + * transfer is mined. + */ +export const useYieldBoostSummary = () => { + const userId = useSelectedUserId(); + return useQuery({ + queryKey: [REWARDS, 'yieldBoost', userId], + queryFn: async () => await withRefreshToken(() => fetchYieldBoostSummary()), + enabled: !!userId, + staleTime: secondsToMilliseconds(60), + gcTime: secondsToMilliseconds(300), + refetchInterval: query => + query.state.data?.pendingClaim ? YIELD_BOOST_PENDING_POLL_MS : false, + }); +}; + +/** + * Claim the user's accrued yield boost. The backend sends it in soFUSE, so + * there is no passkey prompt: the request is the claim. + * + * The account is read when the claim is made rather than when the screen last + * rendered, so a switch mid-claim can't file one account's result under the + * other's key. + */ +export const useClaimYieldBoost = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + const user = useUserStore.getState().users.find(candidate => candidate.selected); + if (!user?.userId) throw new Error('Sign in again to claim your yield boost.'); + const result = await withRefreshToken(() => claimYieldBoost()); + return { ...result, userId: user.userId, safeAddress: user.safeAddress }; + }, + onSuccess: ({ summary, userId, safeAddress }) => { + queryClient.setQueryData([REWARDS, 'yieldBoost', userId], summary); + // The payout lands in the soFUSE vault, which is savings — and FUSE held + // there can move the user's tier. + refreshRewardsAfterSavings(queryClient, userId, safeAddress); + }, + }); +}; diff --git a/hooks/useTransfi.ts b/hooks/useTransfi.ts index 5ada97678..99014a150 100644 --- a/hooks/useTransfi.ts +++ b/hooks/useTransfi.ts @@ -10,9 +10,10 @@ import { getTransfiStatus, retryTransfiKyc, shareTransfiKyc, + upgradeTransfiKyc, } from '@/lib/api'; import { TransfiError } from '@/lib/transfiErrors'; -import { TransfiProfileInput } from '@/lib/types'; +import { TransfiKycLevel, TransfiProfileInput } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; export const TRANSFI_STATUS_KEY = 'transfiStatus'; @@ -70,6 +71,23 @@ export function useRetryTransfiKyc() { }); } +/** + * Ask TransFi for its verification page for the next KYC level, after an order + * breached the limits of the current one. + * + * Nothing is written to the status cache: the user stays `ready` whatever + * TransFi answers, because they can still buy within their current limits. + */ +export function useUpgradeTransfiKyc() { + return useMutation({ + mutationFn: async (level: TransfiKycLevel) => { + const data = await withRefreshToken(() => upgradeTransfiKyc(level)); + if (!data) throw new Error('Failed to start the TransFi verification'); + return data; + }, + }); +} + /** * Send the address/phone the user filled in, then re-share their KYC. * diff --git a/hooks/useTransfiCountryAvailability.ts b/hooks/useTransfiCountryAvailability.ts new file mode 100644 index 000000000..2178fd48f --- /dev/null +++ b/hooks/useTransfiCountryAvailability.ts @@ -0,0 +1,22 @@ +import { isTransfiProhibitedCountry } from '@/constants/compliance'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; + +/** + * Whether TransFi's local-currency cash deposits can be offered here. + * + * Reuses the Cash App hook's country resolution — the stored country when there + * is one, otherwise a session-memoised geo lookup. Nothing is offered while the + * lookup is in flight, so a user in a prohibited country never gets a tap in + * before it lands. A lookup that settles with no country is not a reason to + * hide the rows; TransFi still refuses the order server-side. + */ +export const useTransfiCountryAvailability = () => { + const { countryCode, isResolving } = useCashAppDepositAvailability(); + + return { + isAvailable: !isResolving && !isTransfiProhibitedCountry(countryCode), + isResolving, + }; +}; + +export default useTransfiCountryAvailability; diff --git a/lib/__tests__/swapQuotes.test.ts b/lib/__tests__/swapQuotes.test.ts new file mode 100644 index 000000000..07abaa0bb --- /dev/null +++ b/lib/__tests__/swapQuotes.test.ts @@ -0,0 +1,73 @@ +import { TradeType } from '@cryptoalgebra/fuse-sdk'; + +import { quotedAmount, QuoterResult, selectBestQuote } from '@/lib/utils/swap/quotes'; + +/** A quoter answer with the given per-hop amounts; the rest doesn't matter here. */ +const quote = (amountsOut: bigint[], amountsIn: bigint[]): QuoterResult => [ + amountsOut, + amountsIn, + amountsOut.map(() => 1n), + amountsOut.map(() => 0), + 0n, + amountsOut.map(() => 500), +]; +const ok = (result: QuoterResult) => ({ result, status: 'success' as const }); +const failed = { result: undefined, status: 'failure' as const }; + +describe('quotedAmount', () => { + it('reads the last hop’s output on exact-in and the total input on exact-out', () => { + // Two hops: 100 in → 80 → 60 out. + const result = quote([80n, 60n], [100n, 80n]); + expect(quotedAmount(result, TradeType.EXACT_INPUT)).toBe(60n); + // Exact-out walks the path backwards, so the last entry is what goes in first. + expect(quotedAmount(quote([60n, 80n], [80n, 100n]), TradeType.EXACT_OUTPUT)).toBe(100n); + }); +}); + +describe('selectBestQuote', () => { + it('picks the route with the most output on exact-in', () => { + const results = [ok(quote([90n], [100n])), ok(quote([95n], [100n])), ok(quote([92n], [100n]))]; + expect(selectBestQuote(results, TradeType.EXACT_INPUT)).toMatchObject({ + index: 1, + amount: 95n, + }); + }); + + it('picks the route that costs the least input on exact-out', () => { + // Every route delivers the same 1000 out, so ranking on the output list + // can't tell them apart. The cheapest input has to win. + const results = [ + ok(quote([1000n], [50n])), + ok(quote([1000n], [40n])), + ok(quote([1000n], [45n])), + ]; + expect(selectBestQuote(results, TradeType.EXACT_OUTPUT)).toMatchObject({ + index: 1, + amount: 40n, + }); + }); + + it('skips routes the quoter could not price', () => { + const results = [failed, ok(quote([90n], [100n])), failed]; + expect(selectBestQuote(results, TradeType.EXACT_INPUT)).toMatchObject({ index: 1 }); + expect(selectBestQuote([failed], TradeType.EXACT_INPUT)).toBeUndefined(); + expect(selectBestQuote(undefined, TradeType.EXACT_OUTPUT)).toBeUndefined(); + }); + + it('keeps the earlier route on a tie', () => { + const results = [ok(quote([90n], [100n])), ok(quote([90n], [100n]))]; + expect(selectBestQuote(results, TradeType.EXACT_INPUT)).toMatchObject({ index: 0 }); + expect(selectBestQuote(results, TradeType.EXACT_OUTPUT)).toMatchObject({ index: 0 }); + }); + + it('carries the fee and price lists of the route it picked', () => { + const best = quote([95n], [100n]); + const results = [ok(quote([90n], [100n])), ok(best)]; + expect(selectBestQuote(results, TradeType.EXACT_INPUT)).toEqual({ + index: 1, + amount: 95n, + fee: best[5], + priceAfterSwap: best[2], + }); + }); +}); diff --git a/lib/__tests__/tierYieldBoost.test.ts b/lib/__tests__/tierYieldBoost.test.ts index 92439aa34..77d011b5a 100644 --- a/lib/__tests__/tierYieldBoost.test.ts +++ b/lib/__tests__/tierYieldBoost.test.ts @@ -22,11 +22,10 @@ const benefitsFor = (tier: RewardsTier, yieldBoostPercentage?: number): TierBene }); /** - * The tier comparison screen used to print its own yield boost constants, which had - * drifted from the figure the rewards home reads off the API — Ultra advertised "+5%" - * on one screen and "+3%" on the other. Every surface resolves through this now, so - * the live rate is the one answer and the table is only what fills the gap before it - * lands. + * The tier screens used to print their own yield boost constants, which had drifted + * from the figure the rewards home reads off the API, and from each other. Every + * surface resolves through this now, so the live rate is the one answer and the table + * is only what fills the gap before it lands. */ describe('resolveTierYieldBoostRate', () => { it('prefers the rate the tier-benefits endpoint sent', () => { @@ -60,19 +59,20 @@ describe('resolveTierYieldBoostRate', () => { ); }); - it('matches the rewards service table', () => { - // `TIER_YIELD_BOOST_PCT` in the accounts service: Core 0, Prime 2, Ultra 5. + it('matches the rates the accounts service pays from', () => { + // `YIELD_BOOST_DEFAULTS` in the accounts service, as APYs: tier1 0, tier2 0.02, + // tier3 0.03 — percentage points here, so Core 0, Prime 2, Ultra 3. expect(TIER_YIELD_BOOST_RATES).toEqual({ [RewardsTier.CORE]: 0, [RewardsTier.PRIME]: 2, - [RewardsTier.ULTRA]: 5, + [RewardsTier.ULTRA]: 3, }); }); }); describe('formatTierYieldBoost', () => { it('prints the rate the way the tier screens do', () => { - expect(formatTierYieldBoost(5)).toBe('+5%'); + expect(formatTierYieldBoost(3)).toBe('+3%'); expect(formatTierYieldBoost(0)).toBe('+0%'); }); }); diff --git a/lib/__tests__/transfiErrors.test.ts b/lib/__tests__/transfiErrors.test.ts index 3c8692335..5cdee8822 100644 --- a/lib/__tests__/transfiErrors.test.ts +++ b/lib/__tests__/transfiErrors.test.ts @@ -3,6 +3,7 @@ import { asTransfiError, canCompleteProfile, + kycUpgradeLevel, toTransfiError, TRANSFI_ERROR_CODE, TransfiError, @@ -115,6 +116,44 @@ describe('transfiErrorTitle', () => { 'Amount not accepted', ); }); + + it('asks for an upgrade when an order breaches the current KYC level', () => { + for (const code of [ + TRANSFI_ERROR_CODE.STANDARD_KYC_REQUIRED, + TRANSFI_ERROR_CODE.ENHANCED_KYC_REQUIRED, + ]) { + expect(transfiErrorTitle(new TransfiError(code, 'complete_kyc', '', 400))).toBe( + 'Upgrade your verification', + ); + } + }); +}); + +describe('kycUpgradeLevel', () => { + it('names the level a limit refusal asks for', () => { + expect( + kycUpgradeLevel( + new TransfiError(TRANSFI_ERROR_CODE.STANDARD_KYC_REQUIRED, 'complete_kyc', '', 400), + ), + ).toBe('standard'); + expect( + kycUpgradeLevel( + new TransfiError(TRANSFI_ERROR_CODE.ENHANCED_KYC_REQUIRED, 'complete_kyc', '', 400), + ), + ).toBe('advanced'); + }); + + it('is undefined for every other verification failure', () => { + // A user with no verification at all still goes through our identity flow. + expect( + kycUpgradeLevel(new TransfiError(TRANSFI_ERROR_CODE.KYC_REQUIRED, 'complete_kyc', '', 412)), + ).toBeUndefined(); + expect( + kycUpgradeLevel( + new TransfiError(TRANSFI_ERROR_CODE.PROFILE_DATA_INCOMPLETE, 'complete_kyc', '', 412), + ), + ).toBeUndefined(); + }); }); describe('canCompleteProfile', () => { diff --git a/lib/api.ts b/lib/api.ts index 96b4cd768..7a1d05497 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -148,7 +148,9 @@ import { TokenPriceUsd, TotalAPYResponse, TransfiCreateOrderResponse, + TransfiKycLevel, TransfiKycRetryResponse, + TransfiKycUpgradeResponse, TransfiOrderStatusResponse, TransfiPaymentConfig, TransfiPaymentMethodOption, @@ -171,6 +173,8 @@ import { WithdrawCollateralRequest, WithdrawCollateralSignatureResponse, WithdrawFromCardToSavingsResponse, + YieldBoostClaimResult, + YieldBoostSummary, } from './types'; import { generateClientNonceData } from './utils/cardDetailsReveal'; import { decryptSecret, generateSessionId } from './utils/rainCardSecrets'; @@ -1763,6 +1767,27 @@ export const retryTransfiKyc = async (): Promise => { return response.json(); }; +/** + * Ask TransFi for its verification page for the next KYC level, after an order + * was refused with STANDARD_KYC_REQUIRED / ENHANCED_KYC_REQUIRED. Resolves with + * `kycUrl` to open, or `status: 'pending'` when TransFi is already reviewing. + */ +export const upgradeTransfiKyc = async ( + level: TransfiKycLevel, +): Promise => { + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/transfi/kyc/upgrade`, + { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', ...transfiHeaders() }, + body: JSON.stringify({ level }), + }, + ); + if (!response.ok) throw await toTransfiError(response); + return response.json(); +}; + /** * Supply the address/phone TransFi needs when the verified identity didn't * carry them, then re-share. Resolves with the refreshed gating status. @@ -2212,6 +2237,55 @@ export const activateTierTrial = async (): Promise => { return response.json(); }; +/** + * The signed-in user's yield boost: their tier's rate, and what they have + * earned and can claim, in soFUSE and USD. + */ +export const fetchYieldBoostSummary = async (): Promise => { + const jwt = getJWTToken(); + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/rewards/yield-boost`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }, + ); + if (!response.ok) throw response; + return response.json(); +}; + +/** + * Claim the yield boost the user has earned. The backend pays it in soFUSE from + * its payout wallet, so there is nothing for the user to sign. + * + * Answers once the transfer is mined, or with the claim still `submitted` when + * it takes longer. Throws an {@link ApiError} carrying the backend's reason — + * nothing to claim yet, today's limit reached, claims paused — for the card to + * show as is. + */ +export const claimYieldBoost = async (): Promise => { + const jwt = getJWTToken(); + const response = await fetch( + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/rewards/yield-boost/claim`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }, + ); + if (!response.ok) throw await toApiError(response, "Couldn't claim your yield boost"); + 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. @@ -2522,14 +2596,23 @@ export interface OnramperWidgetSession { expiresAt: string; } +/** + * What an Onramper purchase funds: the wallet (the Safe), or the card by way of + * the card deposit address. + */ +export type OnramperDestination = 'wallet' | 'card'; + /** * Mints a signed widget URL for the signed-in user. * * The destination address is not sent — the backend reads it from the * authenticated user, so nothing the client says can redirect the delivery. + * `destination` picks only the route, which the backend resolves to that user's + * own Safe or card deposit address. */ export const fetchOnramperWidgetSession = async ( platform: 'web' | 'native', + destination: OnramperDestination, ): Promise => { const jwt = getJWTToken(); @@ -2543,7 +2626,7 @@ export const fetchOnramperWidgetSession = async ( ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), }, credentials: 'include', - body: JSON.stringify({ platform }), + body: JSON.stringify({ platform, destination }), }, ); diff --git a/lib/api/orchestra.ts b/lib/api/orchestra.ts index 07b31f61d..903f79600 100644 --- a/lib/api/orchestra.ts +++ b/lib/api/orchestra.ts @@ -68,12 +68,17 @@ export const getOrchestraConfig = async ( export const createOrchestraOnramp = async ( amountFiatUsd: string, countryCode?: string, + destination?: 'wallet' | 'card', ): Promise => { const response = await fetch(`${ORCHESTRA_BASE}/onramp`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...orchestraHeaders() }, credentials: 'include', - body: JSON.stringify({ amountFiatUsd, ...(countryCode ? { countryCode } : {}) }), + body: JSON.stringify({ + amountFiatUsd, + ...(countryCode ? { countryCode } : {}), + ...(destination ? { destination } : {}), + }), }); if (!response.ok) throw await toOrchestraError(response); diff --git a/lib/assets.ts b/lib/assets.ts index e16b4677b..ef110c5f3 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -972,6 +972,70 @@ export const ASSETS = { module: require('@/assets/images/rewards-tiers/upgrade-card-yield.svg'), hash: '0bbf9dce', }, + 'images/rewards-tiers/v4/airline.svg': { + module: require('@/assets/images/rewards-tiers/v4/airline.svg'), + hash: '7670516b', + }, + 'images/rewards-tiers/v4/airlines.png': { + module: require('@/assets/images/rewards-tiers/v4/airlines.png'), + hash: '6543b921', + }, + 'images/rewards-tiers/v4/background.png': { + module: require('@/assets/images/rewards-tiers/v4/background.png'), + hash: 'cb166b3d', + }, + 'images/rewards-tiers/v4/cap.svg': { + module: require('@/assets/images/rewards-tiers/v4/cap.svg'), + hash: '15984fce', + }, + 'images/rewards-tiers/v4/card-core-still.png': { + module: require('@/assets/images/rewards-tiers/v4/card-core-still.png'), + hash: '794e4276', + }, + 'images/rewards-tiers/v4/card-core.png': { + module: require('@/assets/images/rewards-tiers/v4/card-core.png'), + hash: 'c9b7a214', + }, + 'images/rewards-tiers/v4/card-prime-still.png': { + module: require('@/assets/images/rewards-tiers/v4/card-prime-still.png'), + hash: '6480bb41', + }, + 'images/rewards-tiers/v4/card-prime.png': { + module: require('@/assets/images/rewards-tiers/v4/card-prime.png'), + hash: 'a81aa408', + }, + 'images/rewards-tiers/v4/card-ultra-still.png': { + module: require('@/assets/images/rewards-tiers/v4/card-ultra-still.png'), + hash: '7d0da229', + }, + 'images/rewards-tiers/v4/card-ultra.png': { + module: require('@/assets/images/rewards-tiers/v4/card-ultra.png'), + hash: '5f66a8bb', + }, + 'images/rewards-tiers/v4/lock.svg': { + module: require('@/assets/images/rewards-tiers/v4/lock.svg'), + hash: '2e7d27d9', + }, + 'images/rewards-tiers/v4/rides.png': { + module: require('@/assets/images/rewards-tiers/v4/rides.png'), + hash: '73c652d6', + }, + 'images/rewards-tiers/v4/stats-core-glass.png': { + module: require('@/assets/images/rewards-tiers/v4/stats-core-glass.png'), + hash: '748974c8', + }, + 'images/rewards-tiers/v4/stats-prime-glass.png': { + module: require('@/assets/images/rewards-tiers/v4/stats-prime-glass.png'), + hash: 'bb2a9503', + }, + 'images/rewards-tiers/v4/stats-ultra-glass.png': { + module: require('@/assets/images/rewards-tiers/v4/stats-ultra-glass.png'), + hash: 'fb6e4d2c', + }, + 'images/rewards-tiers/v4/yield.svg': { + module: require('@/assets/images/rewards-tiers/v4/yield.svg'), + hash: 'f4b0ad37', + }, 'images/rewards-tiers/yield-boost.png': { module: require('@/assets/images/rewards-tiers/yield-boost.png'), hash: '0403ec32', diff --git a/lib/buyCryptoFlow.ts b/lib/buyCryptoFlow.ts index 29654c50a..43820d52c 100644 --- a/lib/buyCryptoFlow.ts +++ b/lib/buyCryptoFlow.ts @@ -43,7 +43,8 @@ export const getBuyCryptoBackTarget = (modal: DepositModal): BuyCryptoBackTarget case DEPOSIT_MODAL.OPEN_BUY_CRYPTO_KYC_PENDING.name: case DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT.name: // Onramper's widget is a single screen: everything inside it is the - // provider's own, so the only way back is out to the funding options. + // provider's own, so the only way back is out of it — to the USD methods it + // is opened from, which the funding modals resolve 'entry' to. case DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name: return 'entry'; case DEPOSIT_MODAL.OPEN_BUY_CRYPTO_CURRENCY.name: diff --git a/lib/config.ts b/lib/config.ts index 7926e9346..7dab7a95d 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -21,6 +21,10 @@ export const EXPO_PUBLIC_ENVIRONMENT = process.env.EXPO_PUBLIC_ENVIRONMENT ?? '' // Sandbox: skip the TransFi buy-crypto KYC gate on the client and go straight to // the amount/quote screen. Pair with backend TRANSFI_SKIP_KYC. Never set in prod. export const EXPO_PUBLIC_TRANSFI_SKIP_KYC = process.env.EXPO_PUBLIC_TRANSFI_SKIP_KYC === 'true'; +// Comma-separated ISO alpha-2 codes TransFi refuses to onboard (UA,RU,BY…). +// Unset falls back to the list in constants/compliance.ts; set it empty to block none. +export const EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES = + process.env.EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES; /** * Card spend v2: the Credit and Smart funding modes, and the borrow position behind them. * diff --git a/lib/orchestraFlow.ts b/lib/orchestraFlow.ts new file mode 100644 index 000000000..248c2e423 --- /dev/null +++ b/lib/orchestraFlow.ts @@ -0,0 +1,55 @@ +import { DEPOSIT_MODAL } from '@/constants/modals'; + +import type { DepositModal } from '@/lib/types'; + +/** + * Host-side description of the Cash App steps, so a funding modal can render + * them in its own shell — title, back target, and which steps belong to the + * flow at all. + * + * Mirrors lib/buyCryptoFlow.ts. The two providers share no code, but a host + * embedding either needs the same three answers. + */ +export type OrchestraBackTarget = DepositModal | 'entry'; + +export const ORCHESTRA_MODAL_NAMES: readonly string[] = [ + DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name, + DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name, + DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name, + DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR.name, +]; + +export const isOrchestraModal = (modal: DepositModal | null | undefined): boolean => + !!modal && ORCHESTRA_MODAL_NAMES.includes(modal.name); + +export const getOrchestraTitle = (modal: DepositModal) => { + switch (modal.name) { + case DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name: + return 'Cash App'; + case DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name: + return 'Pay with Cash App'; + case DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name: + return 'Deposit status'; + // The error screen carries its own headline and icon. + default: + return undefined; + } +}; + +/** + * Where back goes from each step, or null where there is nowhere sensible. + * + * Null past the invoice is deliberate: by the status step an invoice may + * already be paid, and the error screen is where a failure sent the user, so + * returning to the step that raised it would only reproduce it. + */ +export const getOrchestraBackTarget = (modal: DepositModal): OrchestraBackTarget | null => { + switch (modal.name) { + case DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name: + return 'entry'; + case DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name: + return DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT; + default: + return null; + } +}; diff --git a/lib/tierYieldBoost.ts b/lib/tierYieldBoost.ts index 7e6e24f34..f1f4610aa 100644 --- a/lib/tierYieldBoost.ts +++ b/lib/tierYieldBoost.ts @@ -2,8 +2,10 @@ import { RewardsTier, TierBenefits } from '@/lib/types'; /** * Extra APY each tier adds on top of the base savings yield, in percentage - * points — the app's fallback copy of the rewards service's own table - * (`TIER_YIELD_BOOST_PCT` in `rewards.service.ts`). + * points — the app's fallback copy of what the accounts service pays from: + * `YIELD_BOOST_DEFAULTS` in `app-config.constants.ts`, as the rewards service + * reads it through `yieldBoostForTier` and `grantedBoostPercentage` (an APY of + * 0.03 is the 3 quoted here). * * A fallback, not a second source of truth: every surface here prefers the * figure the tier-benefits endpoint sends and only falls back to this when the @@ -18,7 +20,7 @@ import { RewardsTier, TierBenefits } from '@/lib/types'; export const TIER_YIELD_BOOST_RATES: Record = { [RewardsTier.CORE]: 0, [RewardsTier.PRIME]: 2, - [RewardsTier.ULTRA]: 5, + [RewardsTier.ULTRA]: 3, }; /** @@ -40,5 +42,5 @@ export const resolveTierYieldBoostRate = ( return TIER_YIELD_BOOST_RATES[tier] ?? 0; }; -/** "+5%" — the boost as the tier screens print it. */ +/** "+3%" — the boost as the tier screens print it. */ export const formatTierYieldBoost = (rate: number): string => `+${rate}%`; diff --git a/lib/transfiErrors.ts b/lib/transfiErrors.ts index 6c8715ad9..5800f0ed3 100644 --- a/lib/transfiErrors.ts +++ b/lib/transfiErrors.ts @@ -7,6 +7,8 @@ * the headline, and which button the screen offers next. */ +import type { TransfiKycLevel } from '@/lib/types'; + /** Mirrors TransfiErrorAction on the backend. */ export type TransfiErrorAction = | 'retry' @@ -22,6 +24,9 @@ export const TRANSFI_ERROR_CODE = { PROFILE_DATA_INCOMPLETE: 'TRANSFI_PROFILE_DATA_INCOMPLETE', KYC_REQUIRED: 'TRANSFI_KYC_REQUIRED', QUOTES_LIMIT_ERROR: 'QUOTES_LIMIT_ERROR', + /** The order breached the limits of the user's current TransFi KYC level. */ + STANDARD_KYC_REQUIRED: 'STANDARD_KYC_REQUIRED', + ENHANCED_KYC_REQUIRED: 'ENHANCED_KYC_REQUIRED', UNKNOWN: 'TRANSFI_UNKNOWN_ERROR', } as const; @@ -67,6 +72,8 @@ const TITLE_BY_CODE: Record = { [TRANSFI_ERROR_CODE.PROFILE_DATA_INCOMPLETE]: 'A few details missing', MAXIMUM_LIMIT_BREACHED: 'Purchase limit reached', DECLINED_BY_BANK: 'Your bank declined this', + [TRANSFI_ERROR_CODE.STANDARD_KYC_REQUIRED]: 'Upgrade your verification', + [TRANSFI_ERROR_CODE.ENHANCED_KYC_REQUIRED]: 'Upgrade your verification', }; const TITLE_BY_ACTION: Record = { @@ -82,6 +89,23 @@ const TITLE_BY_ACTION: Record = { export const transfiErrorTitle = (error: TransfiError): string => TITLE_BY_CODE[error.code] ?? TITLE_BY_ACTION[error.action]; +/** + * The KYC level an order refusal asks for, when it is a limit refusal: the user + * has bought up to what their current TransFi level allows and needs the next + * one. Only TransFi's own verification page can give them that — our identity + * flow is already behind them, and re-running it just lands back on `ready`. + */ +export const kycUpgradeLevel = (error: TransfiError): TransfiKycLevel | undefined => { + switch (error.code) { + case TRANSFI_ERROR_CODE.STANDARD_KYC_REQUIRED: + return 'standard'; + case TRANSFI_ERROR_CODE.ENHANCED_KYC_REQUIRED: + return 'advanced'; + default: + return undefined; + } +}; + const ACTIONS: readonly TransfiErrorAction[] = [ 'retry', 'adjust_amount', diff --git a/lib/types.ts b/lib/types.ts index 0ffb8924a..6ec862deb 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1289,6 +1289,11 @@ export enum TransactionType { WRAP = 'wrap', UNWRAP = 'unwrap', MERKL_CLAIM = 'merkl_claim', + /** + * A tiered yield boost payout: soFUSE the backend sends from its payout + * wallet, so the row is written server-side and carries no user operation. + */ + YIELD_BOOST_CLAIM = 'yield_boost_claim', CARD_WELCOME_BONUS = 'card_welcome_bonus', DEPOSIT_BONUS = 'deposit_bonus', FUND = 'fund', @@ -1818,6 +1823,16 @@ export interface RewardsUserData { yieldBoostCap?: number; /** Yield boost payouts the user has already received, in USD. */ yieldBoostEarned?: number; + /** + * Savings balance the current tier's boost is paid on, in USD — Prime's + * first $10K, Ultra's first $25K. 0 (or absent) when unboosted. + */ + yieldBoostBalanceCap?: number; + /** + * The boost the next tier up earns, in percentage points. 0 (or absent) at + * the top tier, or when that tier earns none. + */ + nextTierYieldBoostPercentage?: number; /** * Cashback % the current tier earns back on eligible subscriptions. 0 (or * absent) means the tier grants none, which hides the subscription card. @@ -1954,6 +1969,75 @@ export interface TierFees { footnote?: string; } +/** Where one yield boost claim has got to. */ +export enum YieldBoostClaimStatus { + /** Days reserved, transfer not signed yet. */ + PENDING = 'pending', + /** Transfer sent, waiting to be mined. */ + SUBMITTED = 'submitted', + PAID = 'paid', + /** Nothing was paid; the days went back to claimable. */ + FAILED = 'failed', +} + +export interface YieldBoostClaim { + id: string; + status: YieldBoostClaimStatus; + /** soFUSE sent, as a decimal string. */ + soFuseAmount: string; + /** What it was worth when sent, in USD. */ + usdValue: number; + txHash: string | null; + explorerUrl: string | null; + createdAt: string | null; + paidAt: string | null; +} + +/** + * The signed-in user's yield boost, from `GET /accounts/v1/rewards/yield-boost`. + * + * The boost is earned daily on the user's savings across every vault and paid + * in soFUSE. soFUSE amounts are decimal strings, because they are 18-decimal + * shares and a JSON number would drop digits of them. + */ +export interface YieldBoostSummary { + /** Whether the boost is accruing at all right now. */ + enabled: boolean; + /** Whether a claim can be made right now. */ + claimsEnabled: boolean; + tier: RewardsTier; + /** The tier's boost in APY percentage points (2 = +2%). 0 for none. */ + apyPercentage: number; + /** Savings, in USD across every vault, the boost applies to. 0 for none. */ + maxDepositUsd: number; + /** Earned and not yet claimed, in soFUSE. */ + claimableSoFuse: string; + /** The same in USD, at the value of each day it was earned. */ + claimableUsd: number; + /** Everything ever earned, claimed or not, in soFUSE. */ + totalEarnedSoFuse: string; + /** Everything ever earned, in USD at the value of each day it was earned. */ + totalEarnedUsd: number; + /** Everything already paid to the Safe, in soFUSE. */ + totalClaimedSoFuse: string; + lastAccrual: { + dayKey: string; + depositUsd: number; + boostedUsd: number; + usdAmount: number; + soFuseAmount: string; + } | null; + /** A claim still being paid, if there is one. */ + pendingClaim: YieldBoostClaim | null; + /** The most one claim can pay, in USD; anything above waits for the next. */ + maxClaimUsd: number; +} + +export interface YieldBoostClaimResult { + claim: YieldBoostClaim; + summary: YieldBoostSummary; +} + export interface TierBenefits { tier: RewardsTier; depositBoost: TierBenefit; @@ -1961,6 +2045,8 @@ export interface TierBenefits { yieldBoostPercentage?: number; /** Ceiling on yield boost payouts for this tier, in USD. */ yieldBoostCap?: number; + /** Savings balance this tier's boost is paid on, in USD (0 when unboosted). */ + yieldBoostBalanceCap?: number; cardCashback: TierBenefit; /** * `subscriptionDiscount` as a bare percentage (0 when the tier grants none), @@ -2901,6 +2987,21 @@ export interface TransfiKycRetryResponse extends TransfiStatusResponse { kycUrl?: string; } +/** A TransFi KYC level above basic screening, each with its own hosted page. */ +export type TransfiKycLevel = 'standard' | 'advanced'; + +/** + * Result of POST /transfi/kyc/upgrade: TransFi's page for the next KYC level, or + * `pending` when TransFi is already reviewing a submission for it. `level` can + * be `standard` when `advanced` was asked for — TransFi only opens the advanced + * level once standard is approved. + */ +export interface TransfiKycUpgradeResponse { + level: TransfiKycLevel; + status: 'started' | 'pending'; + kycUrl?: string; +} + /** * Address/phone a user fills in to unblock TRANSFI_PROFILE_DATA_INCOMPLETE. * Only the fields they were told were missing need to be sent. diff --git a/lib/utils/__tests__/deposit-steps.test.ts b/lib/utils/__tests__/deposit-steps.test.ts index 5f8a026da..36dafc174 100644 --- a/lib/utils/__tests__/deposit-steps.test.ts +++ b/lib/utils/__tests__/deposit-steps.test.ts @@ -186,6 +186,7 @@ describe('isDepositWithSteps', () => { TransactionType.WRAP, TransactionType.UNWRAP, TransactionType.MERKL_CLAIM, + TransactionType.YIELD_BOOST_CLAIM, TransactionType.CARD_WELCOME_BONUS, TransactionType.DEPOSIT_BONUS, TransactionType.FAST_WITHDRAW, diff --git a/lib/utils/__tests__/savings-activity-filter.test.ts b/lib/utils/__tests__/savings-activity-filter.test.ts index 20dccc7d8..c01b5602e 100644 --- a/lib/utils/__tests__/savings-activity-filter.test.ts +++ b/lib/utils/__tests__/savings-activity-filter.test.ts @@ -137,6 +137,7 @@ describe('isSavingsVaultActivity — other surfaces stay out', () => { [TransactionType.MERCURYO_TRANSACTION, 'Buy crypto'], [TransactionType.SWAP, 'Swap USDC for ETH'], [TransactionType.MERKL_CLAIM, 'Merkl rewards'], + [TransactionType.YIELD_BOOST_CLAIM, 'Yield boost'], [TransactionType.DEPOSIT_BONUS, 'Deposit bonus'], [TransactionType.AGENT_WALLET_DEPOSIT, 'Agent wallet deposit'], [TransactionType.GOODDOLLAR_CLAIM, 'GoodDollar claim'], diff --git a/lib/utils/cardFunding.ts b/lib/utils/cardFunding.ts index 905fa9eb8..05bad48f7 100644 --- a/lib/utils/cardFunding.ts +++ b/lib/utils/cardFunding.ts @@ -139,7 +139,10 @@ export const CARD_FUND_DESTINATION_TYPE = 'RAIN_CARD' as const; */ export type CardFundSections = { stablecoins: boolean; - /** USD (ACH / Wire) — the virtual-account flow. */ + /** + * USD — the virtual account's ACH / Wire, Apple Pay, and Cash App where it is + * offered. All of them are listed under USD, so off means none is offered. + */ cashDeposit: boolean; /** BRL, BDT, MXN, PHP — the buy-crypto onramp. */ localCurrencies: boolean; diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts index 2f0fa9a84..79dcb0a9a 100644 --- a/lib/utils/cardHelpers.ts +++ b/lib/utils/cardHelpers.ts @@ -138,6 +138,18 @@ export const canWithdrawFromCard = ({ isCustomerRestricted, provider }: CardFund export const canDepositToCard = (provider: CardProvider | null | undefined): boolean => provider !== CardProvider.WIREX; +/** + * Whether the USD bank rail (Wire transfer / ACH) is offered as a way to fund. + * + * A Wirex cardholder funds their card through the wallet deposit flow, and that + * flow's USD rail has no leg to their card: the Wirex virtual account supports + * no wire, and what it receives settles into the Wirex balance, not the Safe + * the card spends from. "Fund your card" leaves it off for the same reason + * (`WIREX_CARD_FUND_SECTIONS.cashDeposit`), and the wallet flow now agrees. + */ +export const canFundByUsdBankTransfer = (provider: CardProvider | null | undefined): boolean => + provider !== CardProvider.WIREX; + /** * Whether the savings Deposit button opens the direct-deposit flow — currency, * then chain, then an address to send to — rather than the amount form that diff --git a/lib/utils/swap/prices.ts b/lib/utils/swap/prices.ts index de573ab40..1ea3be929 100644 --- a/lib/utils/swap/prices.ts +++ b/lib/utils/swap/prices.ts @@ -1,4 +1,3 @@ -import { VoltageTrade } from '@/hooks/swap/useVoltageRouter'; import { Currency, CurrencyAmount, @@ -67,37 +66,3 @@ export function warningSeverity(priceImpact: Percent | undefined): WarningSeveri } return 0; } - -export function computeSlippageAdjustedAmounts( - trade: VoltageTrade | undefined, - pct: Percent, -): { [key: string]: CurrencyAmount | undefined } { - if (!trade) { - return { - inputAmount: undefined, - outputAmount: undefined, - }; - } - - const maximumAmountIn = new Fraction(JSBI.BigInt(1)) - .add(pct) - .multiply(trade?.inputAmount?.quotient || JSBI.BigInt(0)).quotient; - const slippageAdjustedAmountIn = CurrencyAmount.fromRawAmount( - trade?.inputAmount?.currency as Currency, - maximumAmountIn, - ); - - const maximumAmountOut = new Fraction(JSBI.BigInt(1)) - .add(pct) - .invert() - .multiply(trade?.outputAmount?.quotient || JSBI.BigInt(0)).quotient; - const slippageAdjustedAmountOut = CurrencyAmount.fromRawAmount( - trade?.outputAmount?.currency as Currency, - maximumAmountOut, - ); - - return { - inputAmount: slippageAdjustedAmountIn, - outputAmount: slippageAdjustedAmountOut, - }; -} diff --git a/lib/utils/swap/quotes.ts b/lib/utils/swap/quotes.ts new file mode 100644 index 000000000..8dc6cc993 --- /dev/null +++ b/lib/utils/swap/quotes.ts @@ -0,0 +1,60 @@ +import { TradeType } from '@cryptoalgebra/fuse-sdk'; + +/** + * One route's answer from Algebra's QuoterV2, in the order it returns them: + * amountOutList, amountInList, sqrtPriceX96AfterList, + * initializedTicksCrossedList, gasEstimate, feeList. Each list has one entry + * per hop. + */ +export type QuoterResult = readonly [ + readonly bigint[], + readonly bigint[], + readonly bigint[], + readonly number[], + bigint, + readonly number[], +]; + +export interface BestQuote { + /** Which of the quoted routes this is. */ + index: number; + /** The side the quote settles: the output on exact-in, the input on exact-out. */ + amount: bigint; + fee: readonly number[]; + priceAfterSwap: readonly bigint[]; +} + +/** + * What a route's quote settles on: the last hop's output for an exact-in swap, + * the total input for an exact-out one. + */ +export function quotedAmount(result: QuoterResult, tradeType: TradeType): bigint | undefined { + const amounts = tradeType === TradeType.EXACT_INPUT ? result[0] : result[1]; + return amounts[amounts.length - 1]; +} + +/** + * The route quote that's best for the user: the most output for an exact-in + * swap, the least input for an exact-out one. Routes the quoter couldn't price + * are skipped, and on a tie the earlier route wins. + */ +export function selectBestQuote( + results: readonly { result?: unknown }[] | undefined, + tradeType: TradeType, +): BestQuote | undefined { + let best: BestQuote | undefined; + + for (const [index, { result }] of (results ?? []).entries()) { + if (!result) continue; + + const quote = result as QuoterResult; + const amount = quotedAmount(quote, tradeType); + if (amount === undefined) continue; + + const isBetter = + !best || (tradeType === TradeType.EXACT_INPUT ? amount > best.amount : amount < best.amount); + if (isBetter) best = { index, amount, fee: quote[5], priceAfterSwap: quote[2] }; + } + + return best; +} diff --git a/store/swapStore.ts b/store/swapStore.ts index e042d4319..86b684109 100644 --- a/store/swapStore.ts +++ b/store/swapStore.ts @@ -1,4 +1,13 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { + createContext, + createElement, + ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useRef, +} from 'react'; import { ADDRESS_ZERO, computePoolAddress, @@ -20,16 +29,16 @@ import { useShallow } from 'zustand/react/shallow'; import { SWAP_MODAL } from '@/constants/modals'; import { soUSDC_TOKEN, USDC_STARGATE_TOKEN } from '@/constants/tokens'; import { useReadAlgebraPoolGlobalState, useReadAlgebraPoolTickSpacing } from '@/generated/wagmi'; -import { useBestTradeExactIn, useBestTradeExactOut } from '@/hooks/swap/useBestTrade'; +import { BestTrade, useBestTrade } from '@/hooks/swap/useBestTrade'; +import { useQuoteInput } from '@/hooks/swap/useQuoteInput'; import useSwapSlippageTolerance from '@/hooks/swap/useSwapSlippageTolerance'; -import { useVoltageRouter, VoltageTrade } from '@/hooks/swap/useVoltageRouter'; import { useCurrency } from '@/hooks/tokens/useCurrency'; import { useSwapFeeRate } from '@/hooks/useProductFees'; import useUser from '@/hooks/useUser'; import { getSwapFundingError } from '@/lib/swapFunding'; import { RewardsTier, SwapModal, TransactionStatusModal } from '@/lib/types'; import { SwapField, SwapFieldType } from '@/lib/types/swap-field'; -import { TradeState, TradeStateType } from '@/lib/types/trade-state'; +import { TradeState } from '@/lib/types/trade-state'; import { computeSwapFee, noSwapFee, SwapFee, SwapFeeBasis } from '@/lib/utils/swapFee'; interface SwapState { @@ -186,10 +195,11 @@ export function useSwapActionHandlers(): { }; } +/** A typed amount that hasn't been quoted yet reads as a quote in flight. */ +const PENDING_QUOTE: Omit = { state: TradeState.LOADING, trade: null }; + /** - * Hook to derive swap information for a given swap state. - * - * @returns An object containing the following properties: + * Everything the swap form shows and acts on: * - `currencies`: An object mapping swap fields to their respective currencies. * - `currencyBalances`: An object mapping swap fields to their respective currency balances. * - `parsedAmount`: The parsed amount of currency. @@ -203,20 +213,14 @@ export function useSwapActionHandlers(): { * - `tickSpacing`: The tick spacing value. * - `poolAddress`: The address of the pool. */ -export function useDerivedSwapInfo(): { +export interface DerivedSwapInfo { currencies: { [field in SwapFieldType]?: Currency }; currencyBalances: { [field in SwapFieldType]?: CurrencyAmount }; + /** What's typed, parsed. The quote trails it by a moment while typing. */ parsedAmount: CurrencyAmount | undefined; inputError?: string; - tradeState: { - trade: Trade | null; - state: TradeStateType; - fee?: bigint[] | null; - }; + tradeState: Omit; toggledTrade: Trade | undefined; - voltageTrade: { trade: VoltageTrade | undefined; isLoading: boolean }; - isVoltageTrade: boolean; - isVoltageTradeLoading: boolean; tickAfterSwap: number | null | undefined; allowedSlippage: Percent; poolFee: number | undefined; @@ -233,10 +237,37 @@ export function useDerivedSwapInfo(): { swapFee: SwapFee; /** Where the fee must be sent. Undefined means "don't collect one". */ revenueWalletAddress: string | undefined; -} { + /** + * Re-quotes the trade on screen just before it's signed. Resolves false when + * the price has moved past the slippage limit, so the swap would revert; by + * then the form is showing the fresh quote. + */ + refreshQuote: () => Promise; +} + +const DerivedSwapInfoContext = createContext(undefined); + +/** + * Works out the swap form's quote, balances and fees once, for every component + * inside it that calls `useDerivedSwapInfo`. + */ +export function DerivedSwapInfoProvider({ children }: { children: ReactNode }) { + const info = useSwapInfo(); + return createElement(DerivedSwapInfoContext.Provider, { value: info }, children); +} + +/** The swap form's derived state, from the nearest `DerivedSwapInfoProvider`. */ +export function useDerivedSwapInfo(): DerivedSwapInfo { + const info = useContext(DerivedSwapInfoContext); + if (!info) { + throw new Error('useDerivedSwapInfo must be used inside a DerivedSwapInfoProvider'); + } + return info; +} + +function useSwapInfo(): DerivedSwapInfo { const { user } = useUser(); const account = user?.safeAddress; - const [isVoltageTrade, setIsVoltageTrade] = useState(false); const { independentField, typedValue, inputCurrencyId, outputCurrencyId } = useSwapState( useShallow(state => ({ independentField: state.independentField, @@ -249,10 +280,26 @@ export function useDerivedSwapInfo(): { const inputCurrency = useCurrency(inputCurrencyId); const outputCurrency = useCurrency(outputCurrencyId); - const isExactIn: boolean = independentField === SwapField.INPUT; + // What's typed drives whatever should keep up with the keyboard... const parsedAmount = useMemo( - () => tryParseAmount(typedValue, (isExactIn ? inputCurrency : outputCurrency) ?? undefined), - [typedValue, isExactIn, inputCurrency, outputCurrency], + () => + tryParseAmount( + typedValue, + (independentField === SwapField.INPUT ? inputCurrency : outputCurrency) ?? undefined, + ), + [typedValue, independentField, inputCurrency, outputCurrency], + ); + + // ...while the quote, and the fee sized with it, follow once typing pauses. + const quoteInput = useQuoteInput(independentField, typedValue); + const isExactIn: boolean = quoteInput.independentField === SwapField.INPUT; + const amountToQuote = useMemo( + () => + tryParseAmount( + quoteInput.typedValue, + (isExactIn ? inputCurrency : outputCurrency) ?? undefined, + ), + [quoteInput.typedValue, isExactIn, inputCurrency, outputCurrency], ); const { rate: swapFeeRate, revenueWalletAddress } = useSwapFeeRate(); @@ -269,94 +316,52 @@ export function useDerivedSwapInfo(): { * its fee is added on top once the trade resolves, below. */ const inputSideFee = useMemo(() => { - if (!isExactIn || !parsedAmount) return undefined; + if (!isExactIn || !amountToQuote) return undefined; return computeSwapFee({ - amount: BigInt(parsedAmount.quotient.toString()), + amount: BigInt(amountToQuote.quotient.toString()), rate: swapFeeRate, basis: SwapFeeBasis.DeductedFromInput, }); - }, [isExactIn, parsedAmount, swapFeeRate]); + }, [isExactIn, amountToQuote, swapFeeRate]); - /** What the routers quote: the net amount on exact-in, the target on exact-out. */ + /** What the router quotes: the net amount on exact-in, the target on exact-out. */ const amountToRoute = useMemo(() => { - if (!parsedAmount) return undefined; - if (!inputSideFee || inputSideFee.feeAmount <= 0n) return parsedAmount; - return CurrencyAmount.fromRawAmount(parsedAmount.currency, inputSideFee.swapAmount.toString()); - }, [parsedAmount, inputSideFee]); - - const bestTradeExactIn = useBestTradeExactIn( - isExactIn ? amountToRoute : undefined, - outputCurrency ?? undefined, - ); - const bestTradeExactOut = useBestTradeExactOut( - inputCurrency ?? undefined, - !isExactIn ? amountToRoute : undefined, - ); - - const currentTrade = useMemo( - () => (isExactIn ? bestTradeExactIn : bestTradeExactOut), - [isExactIn, bestTradeExactIn, bestTradeExactOut], - ); + if (!amountToQuote) return undefined; + if (!inputSideFee || inputSideFee.feeAmount <= 0n) return amountToQuote; + return CurrencyAmount.fromRawAmount(amountToQuote.currency, inputSideFee.swapAmount.toString()); + }, [amountToQuote, inputSideFee]); - const slippage = useSwapSlippageTolerance().divide(100); - const voltageTrade = useVoltageRouter( - inputCurrency, - outputCurrency, + const bestTrade = useBestTrade( + isExactIn ? TradeType.EXACT_INPUT : TradeType.EXACT_OUTPUT, amountToRoute, - isExactIn, - slippage.toFixed(), + inputCurrency ?? undefined, + outputCurrency ?? undefined, ); + const currentTrade = quoteInput.pending ? PENDING_QUOTE : bestTrade; - console.log('voltageTrade', voltageTrade); - - // Track if component has mounted to avoid state updates during hydration - const [hasMounted, setHasMounted] = useState(false); + // Logged once per distinct quote, not on every render that rebuilds the same one. + const lastLoggedQuote = useRef(''); useEffect(() => { - setHasMounted(true); - }, []); + if (!amountToQuote || currentTrade.state === TradeState.LOADING) return; + + const trade = currentTrade.trade; + const summary = { + direction: isExactIn ? 'exact-in' : 'exact-out', + amount: `${amountToQuote.toExact()} ${amountToQuote.currency.symbol}`, + routedAmount: amountToRoute?.toExact(), + state: currentTrade.state, + ...(trade && { + path: trade.route.tokenPath.map(token => token.symbol).join(' → '), + in: trade.inputAmount.toExact(), + out: trade.outputAmount.toExact(), + }), + }; - useEffect(() => { - // Skip state updates until after initial mount/hydration - if (!hasMounted) return; - if (voltageTrade.isLoading) return; - - // Use setTimeout to avoid setState during render - const timeoutId = setTimeout(() => { - if (currentTrade.state === TradeState.NO_ROUTE_FOUND) { - setIsVoltageTrade(false); - } else if (voltageTrade.isValid) { - if ( - isExactIn && - currentTrade?.trade?.outputAmount && - voltageTrade.trade?.outputAmount && - currentTrade.trade.outputAmount.lessThan(voltageTrade.trade.outputAmount) - ) { - setIsVoltageTrade(true); - } else if ( - !isExactIn && - currentTrade?.trade?.inputAmount && - voltageTrade.trade?.inputAmount && - currentTrade.trade.inputAmount.lessThan(voltageTrade.trade.inputAmount) - ) { - setIsVoltageTrade(true); - } else { - setIsVoltageTrade(false); - } - } - }, 0); - - return () => clearTimeout(timeoutId); - }, [ - hasMounted, - voltageTrade.isLoading, - voltageTrade.isValid, - voltageTrade.trade?.outputAmount, - voltageTrade.trade?.inputAmount, - isExactIn, - currentTrade.state, - currentTrade.trade?.outputAmount, - currentTrade.trade?.inputAmount, - ]); + const key = JSON.stringify(summary); + if (key === lastLoggedQuote.current) return; + lastLoggedQuote.current = key; + console.log('[swap-quote] algebra quote', summary); + }, [amountToQuote, amountToRoute, isExactIn, currentTrade.state, currentTrade.trade]); const [addressA, addressB] = [ inputCurrency?.isNative ? undefined : inputCurrency?.address || '', @@ -420,6 +425,25 @@ export function useDerivedSwapInfo(): { const allowedSlippage = useSwapSlippageTolerance(toggledTrade); + const { requote } = bestTrade; + const refreshQuote = useCallback(async () => { + const trade = currentTrade.trade; + if (!trade) return false; + + let freshAmount: bigint | undefined; + try { + freshAmount = await requote(); + } catch { + // Couldn't re-check; the slippage limit still guards the swap on-chain. + return true; + } + if (freshAmount === undefined) return true; + + return trade.tradeType === TradeType.EXACT_INPUT + ? !trade.minimumAmountOut(allowedSlippage).greaterThan(freshAmount.toString()) + : !trade.maximumAmountIn(allowedSlippage).lessThan(freshAmount.toString()); + }, [currentTrade.trade, requote, allowedSlippage]); + /** * The fee on this swap, whichever side it comes from. * @@ -432,7 +456,7 @@ export function useDerivedSwapInfo(): { return inputSideFee ?? noSwapFee(0n); } - const tradeInput = toggledTrade?.inputAmount ?? voltageTrade.trade?.inputAmount; + const tradeInput = toggledTrade?.inputAmount; if (!tradeInput) return noSwapFee(0n); return computeSwapFee({ @@ -440,13 +464,11 @@ export function useDerivedSwapInfo(): { rate: swapFeeRate, basis: SwapFeeBasis.AddedToInput, }); - }, [isExactIn, inputSideFee, toggledTrade, voltageTrade.trade, swapFeeRate]); + }, [isExactIn, inputSideFee, toggledTrade, swapFeeRate]); const [balanceIn, amountIn] = [ currencyBalances[SwapField.INPUT], - isVoltageTrade - ? voltageTrade.trade?.inputAmount - : toggledTrade?.maximumAmountIn(allowedSlippage), + toggledTrade?.maximumAmountIn(allowedSlippage), ]; // The fee leaves the same wallet in the same batch, so the balance has to @@ -508,14 +530,12 @@ export function useDerivedSwapInfo(): { toggledTrade, tickAfterSwap, allowedSlippage, - isVoltageTrade, - isVoltageTradeLoading: voltageTrade?.isLoading || false, - voltageTrade: isVoltageTrade ? voltageTrade : { trade: undefined, isLoading: false }, poolFee: globalState && globalState[2], tick: globalState && globalState[1], tickSpacing: tickSpacing, poolAddress, swapFee, revenueWalletAddress, + refreshQuote, }; } diff --git a/store/useOrchestraStore.ts b/store/useOrchestraStore.ts index 3e5b7552d..b1db3a9f1 100644 --- a/store/useOrchestraStore.ts +++ b/store/useOrchestraStore.ts @@ -15,15 +15,25 @@ import type { OrchestraOnrampOrder } from '@/lib/types/orchestra'; * exactly once by /onramp, and losing it on an unmount would leave a paid * invoice the app can no longer track. */ +/** Which balance the deposit funds. Set by whichever entry point opened it. */ +export type OrchestraDestination = 'wallet' | 'card'; + interface OrchestraState { /** USD the user typed, as a string — "50.00". */ amountUsd: string; + /** + * Wallet or card. Held here rather than inferred on the amount screen, + * because only the entry point knows which balance the user asked to fund + * and the screen itself is identical either way. + */ + destination: OrchestraDestination; order: OrchestraOnrampOrder | null; /** The failure the error step renders; survives the step transition. */ error: OrchestraError | null; /** The step the failure came from, so "Try again" returns there. */ errorOrigin: DepositModal | null; setAmountUsd: (amountUsd: string) => void; + setDestination: (destination: OrchestraDestination) => void; setOrder: (order: OrchestraOnrampOrder) => void; setError: (error: OrchestraError | null, origin?: DepositModal) => void; reset: () => void; @@ -31,11 +41,16 @@ interface OrchestraState { export const useOrchestraStore = create(set => ({ amountUsd: '', + destination: 'wallet', order: null, error: null, errorOrigin: null, setAmountUsd: amountUsd => set({ amountUsd }), + setDestination: destination => set({ destination }), setOrder: order => set({ order }), setError: (error, origin) => set({ error, errorOrigin: origin ?? null }), - reset: () => set({ amountUsd: '', order: null, error: null, errorOrigin: null }), + // `destination` deliberately resets too: a reset happens when an entry point + // starts a new deposit, and it sets the destination immediately after. + reset: () => + set({ amountUsd: '', destination: 'wallet', order: null, error: null, errorOrigin: null }), }));