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 ? (
-
- {isAlgebraRouteMissing && bestQuote?.source === 'voltage' && (
- <>
-
-
- Best route is on Voltage, but repay-from-collateral can only execute via
- Algebra. Try a different amount or use wallet funds.
-
- >
- )}
>
)}
diff --git a/components/Card/__tests__/CardFundOptions.test.tsx b/components/Card/__tests__/CardFundOptions.test.tsx
new file mode 100644
index 000000000..e0d1d4d56
--- /dev/null
+++ b/components/Card/__tests__/CardFundOptions.test.tsx
@@ -0,0 +1,115 @@
+import React from 'react';
+
+import CardFundOptions from '@/components/Card/CardFund/CardFundOptions';
+import { RAIN_CARD_FUND_SECTIONS, WIREX_CARD_FUND_SECTIONS } from '@/lib/utils/cardFunding';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { act, create } = require('react-test-renderer');
+
+/**
+ * "Fund your card", step 1.
+ *
+ * Cash App is a USD method, so it is listed under USD, beside Apple Pay, rather
+ * than as a row of its own at the bottom of the list — and an issuer with no
+ * USD section (Wirex) does not offer it at all.
+ */
+
+jest.mock('expo-image', () => ({ Image: 'Image' }));
+jest.mock('lucide-react-native', () => ({
+ Building2: 'Building2',
+ CreditCard: 'CreditCard',
+ Zap: 'Zap',
+}));
+jest.mock('@/assets/images/apple-pay-circle', () => 'ApplePayCircle');
+jest.mock('@/assets/images/fund-external-wallet', () => 'FundExternalWallet');
+jest.mock('@/assets/images/fund-move-savings', () => 'FundMoveSavings');
+jest.mock('@/components/Card/CardFund/CardFundGroup', () => 'CardFundGroup');
+jest.mock('@/components/Card/CardFund/CardFundRow', () => 'CardFundRow');
+jest.mock('@/components/NeedHelp', () => 'NeedHelp');
+// The real module pulls `lib/assets`, which does not load under jest-expo; the
+// sections and copy come from the leaf module it re-exports them from.
+jest.mock('@/components/Card/CardFund/constants', () => ({
+ ...jest.requireActual('@/lib/utils/cardFunding'),
+ CARD_FUND_CASH_DEPOSIT_VISIBLE_ROWS: 4,
+ CARD_FUND_TOKENS: [
+ { symbol: 'USDC', icon: 'usdc' },
+ { symbol: 'USDT', icon: 'usdt' },
+ ],
+ CARD_FUND_USD_ICON: 'usd',
+ getCardFundNetworkChips: () => [],
+}));
+jest.mock('@/components/Card/CardFund/localCurrencies', () => ({
+ CARD_FUND_LOCAL_CURRENCIES: [
+ { code: 'EUR', icon: null },
+ { code: 'BRL', icon: null },
+ ],
+ getCardFundLocalPaymentMethods: () => [],
+}));
+// DepositUsdOptions is loaded for the USD chips it shares with the wallet's cash
+// list. Its own screen's hooks and stores are never called here, only stubbed so
+// the module loads.
+jest.mock(
+ '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog',
+ () => 'VirtualAccountApplyDialog',
+);
+jest.mock('@/hooks/useCardProvider', () => ({}));
+jest.mock('@/hooks/useOrchestra', () => ({}));
+jest.mock('@/hooks/useVirtualAccountEntry', () => ({}));
+jest.mock('@/lib/analytics', () => ({}));
+jest.mock('@/store/useDepositStore', () => ({}));
+jest.mock('@/store/useOrchestraStore', () => ({}));
+
+type Props = React.ComponentProps;
+
+const render = (props: Partial) => {
+ let root: any;
+ act(() => {
+ root = create();
+ });
+ return root;
+};
+
+const rowsOf = (root: any) => root.root.findAllByType('CardFundRow');
+const titlesOf = (root: any) => rowsOf(root).map((row: any) => row.props.title);
+const rowTitled = (root: any, title: string) =>
+ rowsOf(root).find((row: any) => row.props.title === title);
+
+/** What the Rain modals pass, less Cash App. */
+const RAIN_PROPS: Partial = {
+ sections: RAIN_CARD_FUND_SECTIONS,
+ onUsdPress: jest.fn(),
+ onLocalCurrencyPress: jest.fn(),
+ onMoveFromSavingsPress: jest.fn(),
+ onExternalWalletPress: jest.fn(),
+};
+
+beforeEach(() => {
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+});
+
+it('names Cash App on the USD row instead of giving it a row at the bottom', () => {
+ const root = render({ ...RAIN_PROPS, isCashAppAvailable: true });
+ expect(titlesOf(root)).not.toContain('Cash App');
+ expect(rowTitled(root, 'USD').props.chips).toEqual(['ACH', 'Wire', 'Cash App', 'Apple Pay']);
+ act(() => root.unmount());
+});
+
+it('leaves Cash App off the USD row where it is not offered', () => {
+ const root = render(RAIN_PROPS);
+ expect(titlesOf(root)).not.toContain('Cash App');
+ expect(rowTitled(root, 'USD').props.chips).toEqual(['ACH', 'Wire', 'Apple Pay']);
+ act(() => root.unmount());
+});
+
+it('offers Wirex no Cash App, even where it is available, having no USD section', () => {
+ const root = render({
+ sections: WIREX_CARD_FUND_SECTIONS,
+ isCashAppAvailable: true,
+ onLocalCurrencyPress: jest.fn(),
+ onMoveFromSavingsPress: jest.fn(),
+ });
+ const titles = titlesOf(root);
+ expect(titles).not.toContain('USD');
+ expect(titles).not.toContain('Cash App');
+ act(() => root.unmount());
+});
diff --git a/components/DepositOption/DepositCashOptions.tsx b/components/DepositOption/DepositCashOptions.tsx
index 418f37a3f..848173654 100644
--- a/components/DepositOption/DepositCashOptions.tsx
+++ b/components/DepositOption/DepositCashOptions.tsx
@@ -1,4 +1,4 @@
-import { useMemo, useState } from 'react';
+import { useState } from 'react';
import { Pressable } from 'react-native';
import { Image } from 'expo-image';
import { Minus, Plus } from 'lucide-react-native';
@@ -10,49 +10,56 @@ import {
CARD_FUND_LOCAL_CURRENCIES,
getCardFundLocalPaymentMethods,
} from '@/components/Card/CardFund/localCurrencies';
+import { getUsdMethodChips } from '@/components/DepositOption/DepositUsdOptions';
import { Text } from '@/components/ui/text';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry';
-import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability';
+import { useCardProvider } from '@/hooks/useCardProvider';
import useGeoCompliance from '@/hooks/useGeoCompliance';
-import { useOrchestraConfig } from '@/hooks/useOrchestra';
-import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry';
+import { useIsCashAppAvailable } from '@/hooks/useOrchestra';
+import { useTransfiCountryAvailability } from '@/hooks/useTransfiCountryAvailability';
import { track } from '@/lib/analytics';
import { getAsset } from '@/lib/assets';
+import { canFundByUsdBankTransfer } from '@/lib/utils/cardHelpers';
import { useDepositStore } from '@/store/useDepositStore';
import { useTransfiStore } from '@/store/useTransfiStore';
-import VirtualAccountApplyDialog from './VirtualAccountDetails/VirtualAccountApplyDialog';
-
const ICON_SIZE = 36;
/** Matches the muted row text the "Show more" footer sits beside. */
const SHOW_MORE_ICON_COLOR = 'rgba(255,255,255,0.7)';
+const FEATURED_LOCAL_CURRENCY_CODES: readonly string[] = ['EUR', 'BRL', 'BDT', 'PHP'];
+
+/** The featured corridors, in the order the design puts them on the first screen. */
+const FEATURED_LOCAL_CURRENCIES = FEATURED_LOCAL_CURRENCY_CODES.map(code =>
+ CARD_FUND_LOCAL_CURRENCIES.find(currency => currency.code === code),
+).filter((currency): currency is (typeof CARD_FUND_LOCAL_CURRENCIES)[number] => !!currency);
+
/**
- * USD is funded by the virtual account, which only takes ACH and wire. The
- * local currencies show their committed corridor list (localCurrencies.tsx).
+ * "Show more" lists every other corridor in localCurrencies.tsx, so a new one
+ * appears here without touching this screen.
*/
-const USD_PAYMENT_METHOD_CHIPS = ['ACH', 'Wire'];
-/** In the US the same row also leads to Cash App, so the chips say so. */
-const USD_PAYMENT_METHOD_CHIPS_US = ['ACH', 'Wire', 'Cash App'];
-const FEATURED_LOCAL_CURRENCY_CODES = ['EUR', 'BRL', 'BDT', 'PHP'] as const;
-const ADDITIONAL_LOCAL_CURRENCY_CODES = ['MXN'] as const;
+const ALL_LOCAL_CURRENCIES = [
+ ...FEATURED_LOCAL_CURRENCIES,
+ ...CARD_FUND_LOCAL_CURRENCIES.filter(
+ currency => !FEATURED_LOCAL_CURRENCY_CODES.includes(currency.code),
+ ),
+];
/**
* How many currencies "Cash" accepts, USD included — the number the chooser's
* icon cluster counts down from for its "+N" circle, so the two stay in step
* when a corridor is added.
*/
-export const DEPOSIT_CASH_CURRENCY_COUNT =
- 1 + FEATURED_LOCAL_CURRENCY_CODES.length + ADDITIONAL_LOCAL_CURRENCY_CODES.length;
+export const DEPOSIT_CASH_CURRENCY_COUNT = 1 + ALL_LOCAL_CURRENCIES.length;
/** The flags the chooser's "Cash" row shows, in the order this screen lists them. */
export const DEPOSIT_CASH_CLUSTER_ICONS = [CARD_FUND_USD_ICON, getAsset('images/flag-eur.png')];
/**
- * "Deposit with cash" — the cash branch of the deposit chooser. USD opens the
- * virtual account (its own bank details, funded by ACH/wire); every other
- * currency opens the TransFi onramp preseeded with it.
+ * "Deposit with cash" — the cash branch of the deposit chooser. USD opens its
+ * methods (the virtual account's ACH/wire, Apple Pay, and Cash App in the US);
+ * every other currency opens the TransFi onramp preseeded with it.
*/
const DepositCashOptions = () => {
const setModal = useDepositStore(state => state.setModal);
@@ -60,36 +67,26 @@ const DepositCashOptions = () => {
const setTransfiCurrency = useTransfiStore(state => state.setFiatCurrency);
const [showAllCurrencies, setShowAllCurrencies] = useState(false);
- const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry();
- // One rule, decided server-side: supported region **or** allowlisted. The
- // country is resolved here only because the backend has no geoip — the verdict
- // is still theirs, and they enforce it again on order creation.
- const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability();
- const { data: orchestraConfig } = useOrchestraConfig(countryCode, !isResolvingCountry);
- const isCashAppAvailable = orchestraConfig?.isAvailable === true;
+ const isCashAppAvailable = useIsCashAppAvailable();
+ const { provider: cardProvider } = useCardProvider();
const { isBuyCryptoAvailable } = useGeoCompliance();
const { handleBuyCryptoPress } = useBuyCryptoEntry();
-
- const localCurrencies = useMemo(() => {
- const visibleCodes = showAllCurrencies
- ? [...FEATURED_LOCAL_CURRENCY_CODES, ...ADDITIONAL_LOCAL_CURRENCY_CODES]
- : FEATURED_LOCAL_CURRENCY_CODES;
-
- return visibleCodes
- .map(code => CARD_FUND_LOCAL_CURRENCIES.find(currency => currency.code === code))
- .filter((currency): currency is (typeof CARD_FUND_LOCAL_CURRENCIES)[number] => !!currency);
- }, [showAllCurrencies]);
+ // The local currencies are all TransFi; USD is not, so it stays.
+ const { isAvailable: isTransfiAvailable } = useTransfiCountryAvailability();
+
+ const localCurrencies = showAllCurrencies ? ALL_LOCAL_CURRENCIES : FEATURED_LOCAL_CURRENCIES;
+
+ // USD opens the chooser of its methods. The bank rail and Apple Pay are
+ // usually there, but a Wirex cardholder has no bank rail and production has no
+ // Apple Pay yet, so outside the US (no Cash App) USD can have none — and then
+ // its row is hidden rather than opening an empty chooser.
+ const usdMethodChips = getUsdMethodChips(
+ isCashAppAvailable,
+ canFundByUsdBankTransfer(cardProvider),
+ );
const handleUsdPress = () => {
- // Two USD rails in the US, one everywhere else. Showing a chooser outside
- // the US would ask a question with a single answer, so USD goes straight to
- // the bank rail there — which is exactly what it did before Cash App
- // existed.
- if (isCashAppAvailable) {
- setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD);
- return;
- }
- openVirtualAccount();
+ setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD);
};
const handleLocalCurrencyPress = (code: string) => {
@@ -109,8 +106,8 @@ const DepositCashOptions = () => {
};
return (
- <>
-
+
+ {usdMethodChips.length > 0 ? (
{
/>
}
title="USD"
- chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS}
+ chips={usdMethodChips}
onPress={handleUsdPress}
/>
- {localCurrencies.map(currency => (
- handleLocalCurrencyPress(currency.code)}
- />
- ))}
- {/* Owned here rather than by CardFundGroup's own footer: the toggle also
- decides which corridors are in the list at all, and so whether their
- payment methods are fetched. */}
+ ) : null}
+ {isTransfiAvailable
+ ? localCurrencies.map(currency => (
+ handleLocalCurrencyPress(currency.code)}
+ />
+ ))
+ : null}
+ {/* Owned here rather than by CardFundGroup's own footer: the toggle
+ switches between two lists rather than revealing hidden rows. */}
+ {isTransfiAvailable ? (
setShowAllCurrencies(current => !current)}
@@ -150,10 +150,8 @@ const DepositCashOptions = () => {
{showAllCurrencies ? 'Show less' : 'Show more'}
-
-
-
- >
+ ) : null}
+
);
};
diff --git a/components/DepositOption/DepositUsdOptions.tsx b/components/DepositOption/DepositUsdOptions.tsx
index 068632a64..64227da71 100644
--- a/components/DepositOption/DepositUsdOptions.tsx
+++ b/components/DepositOption/DepositUsdOptions.tsx
@@ -2,12 +2,17 @@ import { useEffect } from 'react';
import { View } from 'react-native';
import { Building2, Zap } from 'lucide-react-native';
+import ApplePayCircle from '@/assets/images/apple-pay-circle';
import CardFundGroup from '@/components/Card/CardFund/CardFundGroup';
import CardFundRow from '@/components/Card/CardFund/CardFundRow';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
+import { useCardProvider } from '@/hooks/useCardProvider';
+import { useIsCashAppAvailable } from '@/hooks/useOrchestra';
import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry';
import { track } from '@/lib/analytics';
+import { isDevFeatureEnabled } from '@/lib/config';
+import { canFundByUsdBankTransfer } from '@/lib/utils/cardHelpers';
import { useDepositStore } from '@/store/useDepositStore';
import { useOrchestraStore } from '@/store/useOrchestraStore';
@@ -19,17 +24,110 @@ const BANK_CHIPS = ['Wire', 'ACH'];
// The row is already titled Cash App; the chip says how fast, not how.
const CASH_APP_CHIPS = ['Instant'];
+type UsdMethodListProps = {
+ /** Omit to hide the row — a Wirex cardholder has no bank rail to their card. */
+ onBankTransferPress?: () => void;
+ /** Omit to hide the row — Cash App is only offered where the server allows it. */
+ onCashAppPress?: () => void;
+ /** Its row shows only on qa/preview builds — see {@link isApplePayEnabled}. */
+ onApplePayPress: () => void;
+};
+
+/**
+ * Apple Pay (Onramper's widget) is still being tested, so it is offered on
+ * qa/preview builds and hidden in production. A function rather than a
+ * constant so tests can flip the flag. Remove it at launch.
+ */
+export const isApplePayEnabled = () => isDevFeatureEnabled;
+
/**
- * How to fund in USD: the bank rail, or Cash App over Lightning.
+ * The USD methods as rows. Shared by this screen and the card funding modals'
+ * USD step, so the two lists cannot drift apart; each caller decides what a
+ * press does, because each owns a different navigator.
+ */
+export const UsdMethodList = ({
+ onBankTransferPress,
+ onCashAppPress,
+ onApplePayPress,
+}: UsdMethodListProps) => (
+
+ {onBankTransferPress ? (
+
+
+
+ }
+ title="Wire transfer, ACH"
+ subtitle="Your own US account details"
+ onPress={onBankTransferPress}
+ chips={BANK_CHIPS}
+ />
+ ) : null}
+ {onCashAppPress ? (
+
+
+
+ }
+ title="Cash App"
+ subtitle="Pay from your Cash App balance"
+ onPress={onCashAppPress}
+ chips={CASH_APP_CHIPS}
+ />
+ ) : null}
+ {/* Onramper's hosted widget. Apple Pay is the name the row goes by, but the
+ widget also takes cards, so the subtitle says so. */}
+ {isApplePayEnabled() ? (
+ }
+ title="Apple Pay"
+ subtitle="Pay with Apple Pay or a card"
+ onPress={onApplePayPress}
+ />
+ ) : null}
+
+);
+
+/**
+ * The chips for a USD row that opens {@link UsdMethodList}, naming the methods
+ * it lists — Cash App only where it is offered, and ACH / Wire only where the
+ * bank rail is, and Apple Pay only where it is enabled, since only there does
+ * the list show them. An empty list means USD has no method. Shared by the
+ * wallet's cash list and the card funding options, so the two USD rows cannot
+ * drift apart.
+ */
+export const getUsdMethodChips = (isCashAppAvailable: boolean, hasBankTransfer = true) => [
+ ...(hasBankTransfer ? ['ACH', 'Wire'] : []),
+ ...(isCashAppAvailable ? ['Cash App'] : []),
+ ...(isApplePayEnabled() ? ['Apple Pay'] : []),
+];
+
+/**
+ * How to fund in USD: the bank rail, Apple Pay through Onramper's widget, or
+ * Cash App over Lightning.
*
- * Only reached when Cash App is available — outside the US the cash list sends
- * USD straight to the virtual account, because a chooser with one option is a
- * tap that asks a question with one answer.
+ * Apple Pay is offered everywhere it is enabled (qa/preview builds only, for
+ * now). The bank rail is too, except to a Wirex cardholder, who has no wire and no ACH leg to their
+ * card (`canFundByUsdBankTransfer`). Cash App is US-only, and its row appears
+ * only where the server says it is available.
*/
const DepositUsdOptions = () => {
const setModal = useDepositStore(state => state.setModal);
const resetOrchestra = useOrchestraStore(state => state.reset);
const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry();
+ const isCashAppAvailable = useIsCashAppAvailable();
+ const { provider: cardProvider } = useCardProvider();
useEffect(() => {
track(TRACKING_EVENTS.DEPOSIT_USD_METHOD_VIEWED);
@@ -47,40 +145,24 @@ const DepositUsdOptions = () => {
setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT);
};
+ const handleApplePayPress = () => {
+ track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ deposit_method: 'buy_crypto',
+ provider: 'onramper',
+ currency: 'USD',
+ });
+ setModal(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET);
+ };
+
return (
<>
-
-
-
-
- }
- title="Wire transfer, ACH"
- subtitle="Your own US account details"
- onPress={openVirtualAccount}
- chips={BANK_CHIPS}
- />
-
-
-
- }
- title="Cash App"
- subtitle="Pay from your Cash App balance"
- onPress={handleCashAppPress}
- chips={CASH_APP_CHIPS}
- />
-
+
>
diff --git a/components/DepositOption/__tests__/depositUsdOptions.test.tsx b/components/DepositOption/__tests__/depositUsdOptions.test.tsx
new file mode 100644
index 000000000..eb3ca7774
--- /dev/null
+++ b/components/DepositOption/__tests__/depositUsdOptions.test.tsx
@@ -0,0 +1,138 @@
+import React from 'react';
+
+import DepositUsdOptions, { getUsdMethodChips } from '@/components/DepositOption/DepositUsdOptions';
+import { DEPOSIT_MODAL } from '@/constants/modals';
+import { CardProvider } from '@/lib/types';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { act, create } = require('react-test-renderer');
+
+jest.mock('lucide-react-native', () => ({ Building2: 'Building2', Zap: 'Zap' }));
+jest.mock('@/assets/images/apple-pay-circle', () => 'ApplePayCircle');
+jest.mock('@/components/Card/CardFund/CardFundGroup', () => 'CardFundGroup');
+jest.mock('@/components/Card/CardFund/CardFundRow', () => 'CardFundRow');
+jest.mock(
+ '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog',
+ () => 'VirtualAccountApplyDialog',
+);
+jest.mock('@/hooks/useCardProvider', () => ({
+ useCardProvider: () => ({ provider: mockCard.provider, isLoading: false }),
+}));
+jest.mock('@/hooks/useOrchestra', () => ({
+ useIsCashAppAvailable: () => mockCashApp.isAvailable,
+}));
+jest.mock('@/hooks/useVirtualAccountEntry', () => ({
+ useVirtualAccountEntry: () => ({
+ open: mockOpenVirtualAccount,
+ isApplyOpen: false,
+ closeApply: jest.fn(),
+ }),
+}));
+jest.mock('@/lib/analytics', () => ({ track: jest.fn() }));
+jest.mock('@/lib/config', () => ({
+ get isDevFeatureEnabled() {
+ return mockConfig.isDevFeatureEnabled;
+ },
+}));
+jest.mock('@/store/useDepositStore', () => ({
+ useDepositStore: (selector: (state: any) => unknown) => selector(mockDeposit),
+}));
+jest.mock('@/store/useOrchestraStore', () => ({
+ useOrchestraStore: (selector: (state: any) => unknown) => selector({ reset: jest.fn() }),
+}));
+
+const mockConfig = { isDevFeatureEnabled: true };
+const mockCashApp = { isAvailable: false };
+const mockCard: { provider: CardProvider | null } = { provider: null };
+const mockOpenVirtualAccount = jest.fn();
+const mockDeposit = { setModal: jest.fn() };
+
+const render = () => {
+ let root: any;
+ act(() => {
+ root = create();
+ });
+ return root;
+};
+
+const rowsOf = (root: any) => root.root.findAllByType('CardFundRow');
+const titlesOf = (root: any) => rowsOf(root).map((row: any) => row.props.title);
+
+beforeEach(() => {
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+ jest.clearAllMocks();
+ mockCashApp.isAvailable = false;
+ mockCard.provider = null;
+ mockConfig.isDevFeatureEnabled = true;
+});
+
+it('offers Apple Pay beside the bank rail where Cash App is not available', () => {
+ const root = render();
+ expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Apple Pay']);
+ act(() => root.unmount());
+});
+
+it('adds Cash App between them where it is available', () => {
+ mockCashApp.isAvailable = true;
+ const root = render();
+ expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Cash App', 'Apple Pay']);
+ act(() => root.unmount());
+});
+
+it('opens the Onramper widget from Apple Pay', () => {
+ const root = render();
+ const applePay = rowsOf(root).find((row: any) => row.props.title === 'Apple Pay');
+ act(() => applePay.props.onPress());
+ expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET);
+ act(() => root.unmount());
+});
+
+it('still opens the virtual account from the bank rail', () => {
+ const root = render();
+ act(() => rowsOf(root)[0].props.onPress());
+ expect(mockOpenVirtualAccount).toHaveBeenCalledTimes(1);
+ expect(mockDeposit.setModal).not.toHaveBeenCalled();
+ act(() => root.unmount());
+});
+
+it('keeps the bank rail for a Rain cardholder', () => {
+ mockCard.provider = CardProvider.RAIN;
+ const root = render();
+ expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Apple Pay']);
+ act(() => root.unmount());
+});
+
+it('hides the bank rail from a Wirex cardholder, who has no wire leg to their card', () => {
+ mockCard.provider = CardProvider.WIREX;
+ mockCashApp.isAvailable = true;
+ const root = render();
+ expect(titlesOf(root)).toEqual(['Cash App', 'Apple Pay']);
+ expect(mockOpenVirtualAccount).not.toHaveBeenCalled();
+ act(() => root.unmount());
+});
+
+it('drops the ACH and Wire chips where the bank rail is hidden', () => {
+ expect(getUsdMethodChips(true)).toEqual(['ACH', 'Wire', 'Cash App', 'Apple Pay']);
+ expect(getUsdMethodChips(false)).toEqual(['ACH', 'Wire', 'Apple Pay']);
+ expect(getUsdMethodChips(true, false)).toEqual(['Cash App', 'Apple Pay']);
+ expect(getUsdMethodChips(false, false)).toEqual(['Apple Pay']);
+});
+
+describe('in production, where Apple Pay is not offered yet', () => {
+ beforeEach(() => {
+ mockConfig.isDevFeatureEnabled = false;
+ });
+
+ it('hides the Apple Pay row', () => {
+ mockCashApp.isAvailable = true;
+ const root = render();
+ expect(titlesOf(root)).toEqual(['Wire transfer, ACH', 'Cash App']);
+ act(() => root.unmount());
+ });
+
+ it('drops the Apple Pay chip, leaving none where USD has no other method', () => {
+ expect(getUsdMethodChips(true)).toEqual(['ACH', 'Wire', 'Cash App']);
+ expect(getUsdMethodChips(false)).toEqual(['ACH', 'Wire']);
+ expect(getUsdMethodChips(false, false)).toEqual([]);
+ });
+});
diff --git a/components/Earn/EarnScreen.tsx b/components/Earn/EarnScreen.tsx
index cee0305e2..4928187ea 100644
--- a/components/Earn/EarnScreen.tsx
+++ b/components/Earn/EarnScreen.tsx
@@ -3,6 +3,7 @@ import { View } from 'react-native';
import { Href, router } from 'expo-router';
import { BalanceHeadline, BalancePillRow } from '@/components/BalanceHeadline';
+import EarnYieldBoostCard from '@/components/Earn/EarnYieldBoostCard';
import LockedFuseTile from '@/components/Earn/LockedFuseTile';
import HeaderHelpButton from '@/components/Navbar/HeaderHelpButton';
import PageLayout from '@/components/PageLayout';
@@ -152,6 +153,10 @@ export default function EarnScreen() {
+ {/* Earned on the portfolio as a whole, so it sits with the portfolio
+ total rather than on any one vault. */}
+
+
Earn interest, Withdraw anytime
diff --git a/components/Earn/EarnYieldBoostCard.tsx b/components/Earn/EarnYieldBoostCard.tsx
new file mode 100644
index 000000000..c2d2e7f30
--- /dev/null
+++ b/components/Earn/EarnYieldBoostCard.tsx
@@ -0,0 +1,152 @@
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import Toast from 'react-native-toast-message';
+
+import Skeleton from '@/components/ui/skeleton';
+import { Text } from '@/components/ui/text';
+import { useClaimYieldBoost, useYieldBoostSummary } from '@/hooks/useRewards';
+import { YieldBoostClaim, YieldBoostClaimStatus } from '@/lib/types';
+import { cn, formatBalanceUSD, formatNumber } from '@/lib/utils';
+
+/** "1,234.57 soFUSE", from the backend's decimal string. */
+const formatSoFuse = (amount: string) => `${formatNumber(Number(amount), 2)} soFUSE`;
+
+/** What to tell the user once the claim request comes back. */
+const claimToast = (claim: YieldBoostClaim) => {
+ switch (claim.status) {
+ case YieldBoostClaimStatus.PAID:
+ return {
+ type: 'success',
+ text1: 'Yield boost claimed',
+ text2: formatSoFuse(claim.soFuseAmount),
+ };
+ case YieldBoostClaimStatus.FAILED:
+ return {
+ type: 'error',
+ text1: "Couldn't claim yield boost",
+ text2: 'Nothing was sent. Your boost is still here to claim.',
+ };
+ default:
+ // Sent but not mined within the request; the summary polls until it is.
+ return {
+ type: 'success',
+ text1: 'Yield boost on its way',
+ text2: `${formatSoFuse(claim.soFuseAmount)} is being sent to your savings`,
+ };
+ }
+};
+
+interface EarnYieldBoostCardProps {
+ className?: string;
+}
+
+/**
+ * Figma 26134:23293 — "Yield boost": the extra APY the user's rewards tier adds
+ * on top of every vault's own yield, what it has earned so far, and the claim.
+ *
+ * It sits on the Earn page rather than on a vault because it is earned on the
+ * user's savings across all three vaults together, and paid in soFUSE whichever
+ * vault the money is in. It renders nothing for a user with no boost and
+ * nothing to claim, so Core users don't see an empty box for a perk they
+ * haven't unlocked — while someone who dropped a tier can still collect what
+ * they earned at the old one.
+ *
+ * The rate is always the user's own, as the backend reports it — never the
+ * rewards screen's off-production preview. This card shows a real balance and
+ * a Claim button, so a borrowed rate would tell a Core user they are earning a
+ * boost they are not.
+ */
+const EarnYieldBoostCard = ({ className }: EarnYieldBoostCardProps) => {
+ const { data: summary, isLoading } = useYieldBoostSummary();
+ const { mutateAsync: claim, isPending: isClaiming } = useClaimYieldBoost();
+
+ const boostPercentage = summary?.apyPercentage ?? 0;
+
+ const hasClaimable = Number(summary?.claimableSoFuse ?? 0) > 0;
+ const isPaying = isClaiming || Boolean(summary?.pendingClaim);
+ if (boostPercentage <= 0 && !hasClaimable && !isPaying) return null;
+
+ const isPaused = hasClaimable && summary?.claimsEnabled === false;
+ const canClaim = hasClaimable && !isPaused && !isPaying;
+
+ const handleClaim = async () => {
+ try {
+ const { claim: result } = await claim();
+ Toast.show({ ...claimToast(result), props: { badgeText: 'Onchain' } });
+ } catch (error) {
+ Toast.show({
+ type: 'error',
+ text1: "Couldn't claim yield boost",
+ text2: error instanceof Error ? error.message : undefined,
+ props: { badgeText: 'Onchain' },
+ });
+ }
+ };
+
+ return (
+
+
+ Yield boost
+
+
+
+
+
+ Boost amount
+ {boostPercentage > 0 ? (
+
+
+ +{formatNumber(boostPercentage, 2, 0)}% Boost
+
+
+ ) : (
+ —
+ )}
+
+
+ Total Earned
+ {isLoading ? (
+
+ ) : (
+
+ {formatBalanceUSD(summary?.totalEarnedUsd ?? 0)}
+
+ )}
+
+
+ {isPaying ? (
+
+ ) : (
+
+ Claim
+
+ )}
+
+
+
+ {isPaused ? (
+
+ Claims are paused for now. Your boost keeps accruing and nothing is lost.
+
+ ) : null}
+
+ );
+};
+
+export default EarnYieldBoostCard;
diff --git a/components/Earn/__tests__/EarnYieldBoostCard.test.tsx b/components/Earn/__tests__/EarnYieldBoostCard.test.tsx
new file mode 100644
index 000000000..4c06d66d4
--- /dev/null
+++ b/components/Earn/__tests__/EarnYieldBoostCard.test.tsx
@@ -0,0 +1,190 @@
+import React from 'react';
+
+import EarnYieldBoostCard from '@/components/Earn/EarnYieldBoostCard';
+import { YieldBoostClaimStatus } from '@/lib/types';
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { act, create } = require('react-test-renderer');
+
+jest.mock('react-native-toast-message', () => ({ __esModule: true, default: { show: jest.fn() } }));
+jest.mock('@/components/ui/text', () => ({ Text: 'Text' }));
+jest.mock('@/components/ui/skeleton', () => ({ __esModule: true, default: 'Skeleton' }));
+// A non-production build, where the rewards screen previews locked perks at
+// stock rates. This card must not.
+jest.mock('@/lib/config', () => ({ isDevFeatureEnabled: true }));
+// The real module pulls in wagmi, which Jest can't parse; these mirror it.
+jest.mock('@/lib/utils', () => {
+ const formatNumber = (value: number, max = 6, min = 2) =>
+ new Intl.NumberFormat('en-us', {
+ maximumFractionDigits: max,
+ minimumFractionDigits: Math.min(value >= 1 ? min : 0, max),
+ }).format(value);
+ return {
+ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '),
+ formatNumber,
+ formatBalanceUSD: (value: number) => (value > 0 ? `$${formatNumber(value, 2)}` : '$0.00'),
+ };
+});
+jest.mock('@/hooks/useRewards', () => ({
+ useYieldBoostSummary: () => mockSummaryQuery,
+ useClaimYieldBoost: () => ({ mutateAsync: mockClaim, isPending: false }),
+}));
+
+const Toast = jest.requireMock('react-native-toast-message').default as { show: jest.Mock };
+
+let mockSummaryQuery: { data?: Record; isLoading: boolean } = {
+ isLoading: false,
+};
+const mockClaim = jest.fn();
+
+const summary = (overrides: Record = {}) => ({
+ enabled: true,
+ claimsEnabled: true,
+ tier: 'prime',
+ apyPercentage: 2,
+ maxDepositUsd: 10_000,
+ claimableSoFuse: '51.25',
+ claimableUsd: 1.64,
+ totalEarnedSoFuse: '420.5',
+ totalEarnedUsd: 13.46,
+ totalClaimedSoFuse: '369.25',
+ lastAccrual: null,
+ pendingClaim: null,
+ maxClaimUsd: 250,
+ ...overrides,
+});
+
+let root: any;
+const render = () => {
+ act(() => {
+ root = create();
+ });
+ return root;
+};
+const texts = () =>
+ root.root
+ .findAllByType('Text')
+ .map((node: any) => [node.props.children].flat().join(''))
+ .join('|');
+// NativeWind wraps Pressable, so find the claim by what a screen reader sees.
+const claimButton = () =>
+ root.root.findAll(
+ (node: any) =>
+ node.props.accessibilityLabel === 'Claim yield boost' &&
+ typeof node.props.onPress === 'function',
+ )[0];
+
+beforeEach(() => {
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+ jest.clearAllMocks();
+ mockSummaryQuery = { isLoading: false, data: summary() };
+});
+afterEach(() => act(() => root?.unmount()));
+
+it('shows the tier boost and everything it has earned', () => {
+ render();
+
+ expect(texts()).toContain('+2% Boost');
+ expect(texts()).toContain('$13.46');
+});
+
+it('claims, and says what was paid', async () => {
+ mockClaim.mockResolvedValue({
+ claim: { status: YieldBoostClaimStatus.PAID, soFuseAmount: '51.25' },
+ summary: summary(),
+ });
+ render();
+
+ expect(claimButton().props.disabled).toBe(false);
+ await act(async () => claimButton().props.onPress());
+
+ expect(mockClaim).toHaveBeenCalledTimes(1);
+ expect(Toast.show).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'success',
+ text1: 'Yield boost claimed',
+ text2: '51.25 soFUSE',
+ }),
+ );
+});
+
+it('says a claim that is still being sent is on its way', async () => {
+ mockClaim.mockResolvedValue({
+ claim: { status: YieldBoostClaimStatus.SUBMITTED, soFuseAmount: '51.25' },
+ summary: summary(),
+ });
+ render();
+
+ await act(async () => claimButton().props.onPress());
+
+ expect(Toast.show).toHaveBeenCalledWith(
+ expect.objectContaining({ text1: 'Yield boost on its way' }),
+ );
+});
+
+it('shows the reason the backend gave when a claim is refused', async () => {
+ mockClaim.mockRejectedValue(new Error("You've reached today's limit."));
+ render();
+
+ await act(async () => claimButton().props.onPress());
+
+ expect(Toast.show).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: 'error',
+ text2: "You've reached today's limit.",
+ }),
+ );
+});
+
+it('disables the claim when nothing is claimable yet', () => {
+ mockSummaryQuery = { isLoading: false, data: summary({ claimableSoFuse: '0.0' }) };
+ render();
+
+ expect(claimButton().props.disabled).toBe(true);
+});
+
+it('disables the claim while an earlier one is still being paid', () => {
+ mockSummaryQuery = {
+ isLoading: false,
+ data: summary({ pendingClaim: { id: 'c1', status: YieldBoostClaimStatus.SUBMITTED } }),
+ };
+ render();
+
+ expect(claimButton().props.disabled).toBe(true);
+ expect(claimButton().props.accessibilityState).toEqual({ disabled: true, busy: true });
+});
+
+it('explains a paused claim instead of offering it', () => {
+ mockSummaryQuery = { isLoading: false, data: summary({ claimsEnabled: false }) };
+ render();
+
+ expect(claimButton().props.disabled).toBe(true);
+ expect(texts()).toContain('Claims are paused for now');
+});
+
+it('never previews a boost for a Core user, even off production', () => {
+ mockSummaryQuery = {
+ isLoading: false,
+ data: summary({ tier: 'core', apyPercentage: 0, claimableSoFuse: '0.0', totalEarnedUsd: 0 }),
+ };
+ render();
+
+ expect(root.toJSON()).toBeNull();
+});
+
+it('renders nothing for a user with no boost and nothing to claim', () => {
+ mockSummaryQuery = {
+ isLoading: false,
+ data: summary({ apyPercentage: 0, claimableSoFuse: '0.0' }),
+ };
+ render();
+
+ expect(root.toJSON()).toBeNull();
+});
+
+it('still lets a user who lost the boost collect what they earned', () => {
+ mockSummaryQuery = { isLoading: false, data: summary({ apyPercentage: 0 }) };
+ render();
+
+ expect(texts()).not.toContain('% Boost');
+ expect(claimButton().props.disabled).toBe(false);
+});
diff --git a/components/Orchestra/OrchestraAmount.tsx b/components/Orchestra/OrchestraAmount.tsx
index 2b95df88a..f9251ec95 100644
--- a/components/Orchestra/OrchestraAmount.tsx
+++ b/components/Orchestra/OrchestraAmount.tsx
@@ -42,6 +42,7 @@ export const OrchestraAmount = () => {
const setAmountUsd = useOrchestraStore(state => state.setAmountUsd);
const setOrder = useOrchestraStore(state => state.setOrder);
const setError = useOrchestraStore(state => state.setError);
+ const destination = useOrchestraStore(state => state.destination);
useEffect(() => {
track(TRACKING_EVENTS.ORCHESTRA_AMOUNT_VIEWED);
@@ -109,12 +110,13 @@ export const OrchestraAmount = () => {
// Two decimal places, because the box holds whatever is being typed and
// "10." or "10.999" should not reach the wire.
createOrder(
- { amountFiatUsd: amountNum.toFixed(2), countryCode },
+ { amountFiatUsd: amountNum.toFixed(2), countryCode, destination },
{
onSuccess: order => {
track(TRACKING_EVENTS.ORCHESTRA_ORDER_CREATED, {
order_id: order.orderId,
amount_usd: amountNum,
+ destination,
amount_mode: order.amountMode,
has_cash_app_link: Boolean(order.paymentLinks?.cashApp),
});
@@ -173,7 +175,8 @@ export const OrchestraAmount = () => {
You'll receive {symbol}
- {network ? ` on ${network}` : ''} in your wallet
+ {network ? ` on ${network}` : ''}{' '}
+ {destination === 'card' ? 'in your card balance' : 'in your wallet'}
diff --git a/components/Orchestra/OrchestraFlow.tsx b/components/Orchestra/OrchestraFlow.tsx
new file mode 100644
index 000000000..c4c769cc5
--- /dev/null
+++ b/components/Orchestra/OrchestraFlow.tsx
@@ -0,0 +1,41 @@
+import { OrchestraAmount } from '@/components/Orchestra/OrchestraAmount';
+import { OrchestraError } from '@/components/Orchestra/OrchestraError';
+import { OrchestraInvoice } from '@/components/Orchestra/OrchestraInvoice';
+import {
+ OrchestraNavigate,
+ OrchestraNavigationProvider,
+} from '@/components/Orchestra/OrchestraNavigation';
+import { OrchestraOrderStatus } from '@/components/Orchestra/OrchestraOrderStatus';
+import { DEPOSIT_MODAL } from '@/constants/modals';
+
+import type { DepositModal } from '@/lib/types';
+
+/**
+ * The Cash App steps, rendered inside a host's own modal.
+ *
+ * The Add-funds modal renders these through useDepositOption instead; this is
+ * for the card funding modals, which run the same steps in a different shell
+ * and supply their own navigator. Every step calls useOrchestraNavigation
+ * rather than the deposit store, which is what makes that substitution work.
+ */
+export const OrchestraFlowContent = ({
+ modal,
+ navigate,
+}: {
+ modal: DepositModal;
+ navigate: OrchestraNavigate;
+}) => (
+
+ {modal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name ? (
+
+ ) : modal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name ? (
+
+ ) : modal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR.name ? (
+
+ ) : (
+
+ )}
+
+);
+
+export default OrchestraFlowContent;
diff --git a/components/PageLayout.tsx b/components/PageLayout.tsx
index a1da18d86..7cd692670 100644
--- a/components/PageLayout.tsx
+++ b/components/PageLayout.tsx
@@ -82,6 +82,8 @@ interface PageLayoutProps {
// Lets a child temporarily suspend scrolling (e.g. while it's running its own
// horizontal swipe gesture, so the two don't fight over the same touch).
scrollEnabled?: boolean;
+ showsVerticalScrollIndicator?: boolean;
+ sidebarTopGutter?: number;
onRefresh?: () => void;
refreshing?: boolean;
edges?: readonly Edge[]; // SafeAreaView edges
@@ -175,6 +177,8 @@ export default function PageLayout({
animateCardHeroExit = false,
scrollable = true,
scrollEnabled = true,
+ showsVerticalScrollIndicator = true,
+ sidebarTopGutter = SIDEBAR_BODY_TOP_GUTTER,
onRefresh,
refreshing = false,
edges = ['right', 'left', 'bottom', 'top'],
@@ -220,7 +224,7 @@ export default function PageLayout({
// Headerless pages in the sidebar shell get the design's top gutter here, so the
// content clears the top of the window the way the mobile navbar's offset does.
const contentTopOffset =
- mobileContentOffset || (isSidebarShell && !customMobileHeader ? SIDEBAR_BODY_TOP_GUTTER : 0);
+ mobileContentOffset || (isSidebarShell && !customMobileHeader ? sidebarTopGutter : 0);
useRegisterTabBarBlurTarget(mobileBlurTargetRef, shouldOverlayMobileNavbar && !isLoading);
@@ -316,6 +320,7 @@ export default function PageLayout({
className={`flex-1 ${contentClassName}`}
contentContainerStyle={contentTopOffset ? { paddingTop: contentTopOffset } : undefined}
scrollEnabled={scrollEnabled}
+ showsVerticalScrollIndicator={showsVerticalScrollIndicator}
onRefresh={onRefresh}
refreshing={refreshing}
refreshIndicatorOffset={contentTopOffset}
diff --git a/components/Rewards/NewRewards/CategoryCashbackBrandBadge.tsx b/components/Rewards/NewRewards/CategoryCashbackBrandBadge.tsx
new file mode 100644
index 000000000..89e4448b8
--- /dev/null
+++ b/components/Rewards/NewRewards/CategoryCashbackBrandBadge.tsx
@@ -0,0 +1,63 @@
+import { View } from 'react-native';
+import { Image } from 'expo-image';
+
+import { Text } from '@/components/ui/text';
+
+import type { CategoryCashbackBrand } from './categoryCashbackBrands';
+
+const CategoryCashbackBrandBadge = ({ brand }: { brand: CategoryCashbackBrand }) => (
+
+ {brand.background && (
+
+ )}
+ {brand.layers.map(({ source, flipY, ...geometry }, index) => (
+
+ ))}
+ {brand.ring && (
+
+ )}
+ {brand.badgeText && (
+
+ {brand.badgeText}
+
+ )}
+
+);
+
+export default CategoryCashbackBrandBadge;
diff --git a/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx b/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
index 7c4769587..bc99d0755 100644
--- a/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
+++ b/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx
@@ -1,7 +1,8 @@
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
-import { Pressable, View } from 'react-native';
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Platform, Pressable, View } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, {
+ cancelAnimation,
Easing,
useAnimatedStyle,
useReducedMotion,
@@ -10,16 +11,15 @@ import Animated, {
} from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { scheduleOnRN } from 'react-native-worklets';
-import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { router } from 'expo-router';
import {
- CoreCardPerkIcon,
- CoreGlobePerkIcon,
- CoreRocketPerkIcon,
-} from '@/assets/images/rewards-tiers/core-tier-icons';
-import { SIDEBAR_BODY_WIDTH, useIsSidebarShell, usePageWidth } from '@/components/Navbar/Sidebar';
+ SIDEBAR_BODY_TOP_GUTTER,
+ SIDEBAR_BODY_WIDTH,
+ useIsSidebarShell,
+ usePageWidth,
+} from '@/components/Navbar/Sidebar';
import PageLayout from '@/components/PageLayout';
import { BackButton } from '@/components/ui/back-button';
import { Text } from '@/components/ui/text';
@@ -28,13 +28,7 @@ import { useRewardsUserData, useTierBenefits } from '@/hooks/useRewards';
import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow';
import { useTierMembership } from '@/hooks/useTierMembership';
import { isHigherTier } from '@/lib/rewardsUpgrade';
-import { formatTierCashbackRate } from '@/lib/tierCashback';
import { availableRoutes, findOffer } from '@/lib/tierUpgrade';
-import {
- formatTierYieldBoost,
- resolveTierYieldBoostRate,
- TIER_YIELD_BOOST_RATES,
-} from '@/lib/tierYieldBoost';
import { RewardsTier } from '@/lib/types';
import { useSwapState } from '@/store/swapStore';
import { useDepositStore } from '@/store/useDepositStore';
@@ -42,478 +36,19 @@ import { useRewardsUpgradeStore } from '@/store/useRewardsUpgradeStore';
import { useTierUpgradeStore } from '@/store/useTierUpgradeStore';
import { useUserStore } from '@/store/useUserStore';
-import SubscriptionBrandBadge from './SubscriptionBrandBadge';
-import { SUBSCRIPTION_CATEGORIES } from './subscriptionBrands';
-import { resolveTierFees } from './tierFees';
-import TierHero from './TierHero';
+import { TierBenefitsBackground, TierBenefitsPage } from './TierBenefitsPage';
+import { TIER_LABELS, tierOfferSubtitle } from './tierBenefitsPresentation';
import TierPointsSheet from './TierPointsSheet';
-import TierSparkleIcon from './TierSparkleIcon';
-import TierStatsBand from './TierStatsBand';
import TierSwitcher from './TierSwitcher';
import { type TierUpgradeCta, tierUpgradeCta } from './tierUpgradeCta';
import UpgradeTierSheet from './UpgradeTierSheet';
const TIERS = [RewardsTier.CORE, RewardsTier.PRIME, RewardsTier.ULTRA];
-const TIER_LABELS: Record = {
- [RewardsTier.CORE]: 'Core',
- [RewardsTier.PRIME]: 'Prime',
- [RewardsTier.ULTRA]: 'Ultra',
-};
-
-const TIER_INFO = require('@/assets/images/rewards-tiers/tier-info.png');
-const YIELD_BOOST_ICON = require('@/assets/images/rewards-tiers/yield-boost.png');
-const CASHBACK_CAP_ICON = require('@/assets/images/rewards-tiers/cashback-cap.png');
-
-interface TierStat {
- label: string;
- value: string;
-}
-
-interface TierPerk {
- title: string;
- description: string;
-}
-
-const BADGE_SIZE = 22;
-// The design steps adjacent badges ~19px apart; the card-colored ring baked
-// into every badge keeps the overlapping stack readable.
-const BADGE_OVERLAP = -3;
-// Rows a tier hasn't unlocked yet keep their logos but sit at 40% — same
-// treatment the design gives the row's label and "Prime and up" text.
-const LOCKED_OPACITY = 0.4;
-
-interface TierContent {
- headline: string;
- unlockCopy: string;
- stats: TierStat[];
- perks: TierPerk[];
- /**
- * `subscriptionRate` is the cashback this tier earns back on every
- * subscription category, or null when the tier hasn't unlocked them. The
- * categories themselves come from SUBSCRIPTION_CATEGORIES.
- */
- cashback: { everyPurchase: string; subscriptionRate: string | null };
-}
-
-/**
- * The stat column and the perk row a yield boost is quoted in, both keyed on this
- * label so the live rate can be substituted into them by name rather than by index.
- */
-const YIELD_BOOST_LABEL = 'Yield boost';
-
-const yieldBoostStat = (rate: number): TierStat => ({
- label: YIELD_BOOST_LABEL,
- value: formatTierYieldBoost(rate),
-});
-
-const yieldBoostPerk = (rate: number): TierPerk => ({
- title: YIELD_BOOST_LABEL,
- description: `${formatTierYieldBoost(rate)} APY boost on your savings`,
-});
-
-// Every tier's cashback figure on this screen comes from the shared rates table,
-// so the comparison here and the "N% Cashback" benefit card on the rewards
-// screen can't drift apart. The yield boost figures below are the same table's
-// job: they are the pre-request fallback, and `PremiumSummaryAndPerks` swaps in
-// the live rate as soon as the tier-benefits request lands.
-const TIER_CONTENT: Record = {
- [RewardsTier.CORE]: {
- headline: 'The Solid Foundation',
- unlockCopy: 'Starting tier',
- stats: [
- { label: 'Cashback', value: formatTierCashbackRate(RewardsTier.CORE) },
- { label: '24/7 Fast support', value: '' },
- ],
- 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' },
- ],
- cashback: {
- everyPurchase: formatTierCashbackRate(RewardsTier.CORE),
- subscriptionRate: null,
- },
- },
- [RewardsTier.PRIME]: {
- headline: 'Enhanced Daily Rewards',
- unlockCopy: 'Unlocks at 5M points',
- stats: [
- { label: 'Cashback', value: formatTierCashbackRate(RewardsTier.PRIME) },
- yieldBoostStat(TIER_YIELD_BOOST_RATES[RewardsTier.PRIME]),
- { label: 'Back on AI', value: '25%' },
- ],
- perks: [
- yieldBoostPerk(TIER_YIELD_BOOST_RATES[RewardsTier.PRIME]),
- {
- title: 'Subscription discounts',
- description: '25% back on AI, streaming, music',
- },
- { title: 'Higher cashback caps', description: 'Up to $100 monthly cap' },
- ],
- cashback: {
- everyPurchase: formatTierCashbackRate(RewardsTier.PRIME),
- subscriptionRate: '25%',
- },
- },
- [RewardsTier.ULTRA]: {
- headline: 'Unmatched Spending Power',
- unlockCopy: 'Unlocks at 35M Points',
- stats: [
- { label: 'Cashback', value: formatTierCashbackRate(RewardsTier.ULTRA) },
- yieldBoostStat(TIER_YIELD_BOOST_RATES[RewardsTier.ULTRA]),
- { label: 'Back on AI', value: '50%' },
- ],
- perks: [
- yieldBoostPerk(TIER_YIELD_BOOST_RATES[RewardsTier.ULTRA]),
- {
- title: 'Subscription discounts',
- description: '50% back on AI, streaming, music',
- },
- { title: 'Higher cashback caps', description: 'Up to $200 monthly cap' },
- ],
- cashback: {
- everyPurchase: formatTierCashbackRate(RewardsTier.ULTRA),
- subscriptionRate: '50%',
- },
- },
-};
-
-const CoreDivider = () => ;
-
-const CORE_MEDIUM_16 = {
- fontFamily: 'MonaSans_500Medium',
- fontSize: 16,
- lineHeight: 18,
-} as const;
-
-const CORE_REGULAR_14 = {
- fontFamily: 'MonaSans_400Regular',
- fontSize: 14,
- lineHeight: 16,
-} as const;
-
-const CorePerkIcon = ({ index }: { index: number }) => (
-
- {index === 0 ? (
-
- ) : index === 1 ? (
-
- ) : (
-
- )}
-
-);
-
-const CoreSummaryAndPerks = () => {
- const content = TIER_CONTENT[RewardsTier.CORE];
-
- return (
-
-
-
-
- {content.perks.map((perk, index) => (
-
- {index > 0 && }
-
-
-
- {perk.title}
-
-
- {perk.description}
-
-
-
-
-
- ))}
-
-
- );
-};
-
-const CoreCashbackCard = () => {
- const { cashback } = TIER_CONTENT[RewardsTier.CORE];
-
- return (
-
-
- Cashback
-
-
-
-
-
- Every purchase
-
-
-
- {cashback.everyPurchase}
-
-
- {SUBSCRIPTION_CATEGORIES.map((category, index) => (
-
-
- {category.label}
-
-
- {category.brands.map((brand, brandIndex) => (
- 0 ? BADGE_OVERLAP : undefined}
- ring
- />
- ))}
-
-
- Prime and up
-
-
- ))}
-
- );
-};
-
-/**
- * The "Fees & Caps" table for one tier.
- *
- * Every row is driven by the live fee config, so what this shows and what the
- * charge engine bills can't drift apart. One component for all three tiers: the
- * Core and premium versions were byte-identical apart from which hardcoded
- * block they read, and keeping two copies is how a fee ends up correct on one
- * tab and stale on another.
- *
- * Laid out in flow rather than at fixed offsets, which the previous version
- * used. There are now seven rows rather than four, they are driven by config
- * that can grow a product, and an absolute layout silently overlaps its rows
- * the moment there is one more than it was measured for.
- */
-const TierFeesCard = ({ tier }: { tier: RewardsTier }) => {
- const { data: tierBenefits } = useTierBenefits();
- const fees = resolveTierFees(tier, tierBenefits?.find(benefit => benefit.tier === tier)?.fees);
-
- return (
-
-
- Fees & Caps
-
-
-
-
- {fees.lines
- .filter(line => line.key !== 'fx')
- .map(line => (
-
-
- {line.label}
-
-
-
- {line.value}
-
-
-
- ))}
-
-
-
- Cashback cap
-
-
- {fees.cashbackCap}
-
-
-
-
-
- FUSE unlock
-
-
- {fees.fuseUnlock}
-
-
-
-
- {fees.footnote ? (
-
- {fees.footnote}
-
- ) : null}
-
- );
-};
-
-const PremiumPerkIcon = ({ index }: { index: number }) => {
- if (index === 1) {
- return (
-
-
- 25%
-
-
- );
- }
-
- return (
-
- );
-};
-
-const PremiumSummaryAndPerks = ({ tier }: { tier: RewardsTier.PRIME | RewardsTier.ULTRA }) => {
- const content = TIER_CONTENT[tier];
- const discount = tier === RewardsTier.PRIME ? '25%' : '50%';
- // The live boost, from the same endpoint the rewards home reads. Both surfaces used
- // to print their own constant, and the two had drifted: the "+N% Yield Boost" card on
- // the rewards tab showed the backend's figure while the stats band and the perk row
- // here still quoted the number they had been written with.
- const { data: tierBenefits } = useTierBenefits();
- const boostRate = resolveTierYieldBoostRate(tier, tierBenefits);
-
- // Substituted by label rather than by position: the band's columns and the perk rows
- // are ordered by the design, and an index would silently relabel the wrong one the
- // first time a tier gains a benefit.
- const stats = useMemo(
- () =>
- content.stats.map(stat =>
- stat.label === YIELD_BOOST_LABEL ? yieldBoostStat(boostRate) : stat,
- ),
- [content.stats, boostRate],
- );
- const perks = useMemo(
- () =>
- content.perks.map(perk =>
- perk.title === YIELD_BOOST_LABEL ? yieldBoostPerk(boostRate) : perk,
- ),
- [content.perks, boostRate],
- );
-
- return (
-
-
-
-
- {perks.map((perk, index) => (
-
- {index > 0 && }
-
-
-
- {perk.title}
-
-
- {perk.description}
-
-
- {index === 1 ? (
-
-
- {discount}
-
-
- ) : (
-
- )}
-
-
- ))}
-
-
- );
-};
-
-const PremiumCashbackCard = ({ tier }: { tier: RewardsTier.PRIME | RewardsTier.ULTRA }) => {
- const { cashback } = TIER_CONTENT[tier];
-
- return (
-
-
- Cashback
-
-
-
-
-
- Every purchase
-
-
-
- {cashback.everyPurchase}
-
-
-
- {SUBSCRIPTION_CATEGORIES.map((category, index) => (
-
-
- {category.label}
-
-
- {category.brands.map((brand, brandIndex) => (
- 0 ? BADGE_OVERLAP : undefined}
- ring
- />
- ))}
-
-
-
- {cashback.subscriptionRate}
-
-
-
- ))}
-
- );
-};
-
-/**
- * Redesigned "Explore tiers" screen (Apple "glass" style companion to
- * RewardsScreenNew). Reachable from the "Explore tiers" button on the
- * redesigned rewards home screen. Shown only on qa/preview mobile builds via
- * the dispatcher in rewards/benefits.tsx — desktop keeps the legacy
- * CompareTiersTable-based screen.
- */
-const HEADER_ROW_HEIGHT = 56;
+const HEADER_ROW_HEIGHT = 33;
/** Figma leaves 20px above the tier tabs. */
const HEADER_TOP_SPACING = 20;
-const DESKTOP_HERO_TOP_REDUCTION = 30;
-const SLIDE_DURATION = 260;
+const SLIDE_DURATION = 420;
const SLIDE_EASING = Easing.out(Easing.cubic);
const PREMIUM_FOOTER_SLIDE_DURATION = 360;
const PREMIUM_FOOTER_SLIDE_EASING = Easing.bezier(0.22, 1, 0.36, 1);
@@ -523,33 +58,14 @@ const SWIPE_VELOCITY_THRESHOLD = 400;
const RUBBER_BAND_FACTOR = 0.3;
// bg-background (--background), used as the solid end of the top/bottom fades.
const BACKGROUND = '#0F0F10';
-const PAGE_BOTTOM_SPACING = 48;
-const PREMIUM_FOOTER_FADE_HEIGHT = 82;
-const PREMIUM_FOOTER_OVERLAP = 94;
-const PREMIUM_FOOTER_BUTTON_TOP = 130;
+const PREMIUM_FOOTER_FADE_HEIGHT = 32;
const PREMIUM_FOOTER_BUTTON_HEIGHT = 50;
const PREMIUM_FOOTER_MIN_BOTTOM_SPACING = 19;
const PREMIUM_FOOTER_VERTICAL_OFFSET = 5;
// Extra height the fades extend beyond their bar's own content, so scrolled
// content dims out smoothly under the header / off the bottom edge instead of
// getting a hard clip (mirrors CardWaitingModal's FADE_EXTENT).
-const FADE_EXTENT = 120;
-
-interface TierPageProps {
- tier: RewardsTier;
- isCurrentTier: boolean;
- isDesktopLayout: boolean;
- /** Width of the page column — the window on mobile, the body column on desktop. */
- pageWidth: number;
- /**
- * Whether points can still take this user to a higher tier — the membership
- * state's per-user verdict. False for everyone the grandfather list leaves
- * nothing to, and once the ladder is switched off.
- */
- pointsUnlockTiers: boolean;
- /** Whether this tier is on sale through v3 — a lock or an annual charge. */
- purchasable: boolean;
-}
+const FADE_EXTENT = 60;
const PremiumUpgradeFooter = ({
selectedTier,
@@ -563,14 +79,15 @@ const PremiumUpgradeFooter = ({
}) => {
const insets = useSafeAreaInsets();
const reduceMotion = useReducedMotion();
- const { label, subtitle: ctaSubtitle, enabled: canUpgrade, held } = cta;
+ const { label: actionLabel, subtitle: ctaSubtitle, enabled: canUpgrade, held } = cta;
+ const label = actionLabel === 'Upgrade' ? `Upgrade to ${TIER_LABELS[selectedTier]}` : actionLabel;
// Gone, not greyed out, for any tier the user already has.
//
// Was `selectedTier !== currentTier`, which only ever hid the footer on the
// one tab that matched exactly — so someone on Ultra swiping to Prime, or to
// Core, still got a full-width dead button over the benefits they had just
// paid for. `held` covers the tier they hold and every tier under it.
- const isVisible = !held;
+ const isVisible = !held && (selectedTier !== RewardsTier.CORE || actionLabel === 'Try again');
const lastPremiumTier = useRef(RewardsTier.PRIME);
if (selectedTier === RewardsTier.PRIME || selectedTier === RewardsTier.ULTRA) {
@@ -580,7 +97,14 @@ const PremiumUpgradeFooter = ({
const tier = lastPremiumTier.current;
const bottomSpacing =
Math.max(insets.bottom, PREMIUM_FOOTER_MIN_BOTTOM_SPACING) + PREMIUM_FOOTER_VERTICAL_OFFSET;
- const footerHeight = PREMIUM_FOOTER_BUTTON_TOP + PREMIUM_FOOTER_BUTTON_HEIGHT + bottomSpacing;
+ const [subtitleHeight, setSubtitleHeight] = useState(23);
+ const footerHeight =
+ PREMIUM_FOOTER_FADE_HEIGHT +
+ 11 +
+ subtitleHeight +
+ 14 +
+ PREMIUM_FOOTER_BUTTON_HEIGHT +
+ bottomSpacing;
const footerTranslateY = useSharedValue(isVisible ? 0 : footerHeight);
useEffect(() => {
@@ -632,7 +156,8 @@ const PremiumUpgradeFooter = ({
style={{ top: PREMIUM_FOOTER_FADE_HEIGHT }}
>
setSubtitleHeight(event.nativeEvent.layout.height)}
style={{
fontFamily: 'MonaSans_400Regular',
fontSize: 16,
@@ -669,179 +194,6 @@ const PremiumUpgradeFooter = ({
);
};
-/**
- * One tier's full page of content — all three are mounted side by side (see
- * the pager row in the main component below) so a swipe genuinely drags
- * between real, already-rendered pages instead of faking it with a fade/slide
- * of a single swapped-out content block.
- */
-const TierPage = ({
- tier,
- isCurrentTier,
- isDesktopLayout,
- pageWidth,
- pointsUnlockTiers,
- purchasable,
-}: TierPageProps) => {
- const insets = useSafeAreaInsets();
- const content = TIER_CONTENT[tier];
- // "Unlocks at 5M points" is only true while points can still take this user
- // there. Past that the tier is bought, so the line says so when it is on
- // sale and says nothing when it is not, and the points explainer goes too.
- const subtitle = isCurrentTier
- ? 'Your current tier'
- : pointsUnlockTiers || tier === RewardsTier.CORE
- ? content.unlockCopy
- : purchasable
- ? 'Unlocks with a membership'
- : null;
- const pageTopSpacing =
- insets.top + HEADER_ROW_HEIGHT - (isDesktopLayout ? DESKTOP_HERO_TOP_REDUCTION : 0);
-
- if (tier === RewardsTier.CORE) {
- return (
-
-
-
-
-
-
-
- Core
-
-
-
-
- {content.headline}
-
-
-
- {subtitle}
-
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
-
-
- {TIER_LABELS[tier]}
-
-
-
-
- {content.headline}
-
-
- {pointsUnlockTiers ? (
-
-
- {subtitle}
-
-
-
- }
- />
- ) : subtitle ? (
-
- {subtitle}
-
- ) : null}
-
-
-
-
-
-
- );
-};
-
export default function RewardsBenefitsScreenNew() {
const userId = useUserStore(state => state.users.find(user => user.selected)?.userId);
return ;
@@ -854,6 +206,7 @@ function RewardsBenefitsForAccount() {
isFetching: isRewardsFetching,
refetch: refetchRewards,
} = useRewardsUserData();
+ const { data: tierBenefits } = useTierBenefits();
const confirmed = useRewardsUpgradeStore(state => state.confirmed);
const pending = useRewardsUpgradeStore(state => !!state.pendingUntil && state.savingsConfirmed);
const timedOut = useRewardsUpgradeStore(state => state.timedOut);
@@ -873,12 +226,17 @@ function RewardsBenefitsForAccount() {
);
const selectedTier = selectedTierOverride ?? rewardsData?.currentTier ?? RewardsTier.CORE;
const insets = useSafeAreaInsets();
+ const isSidebarShell = useIsSidebarShell();
+ // Keep the hero's spacing while letting its scrolling backdrop reach the web header.
+ const heroTopInset =
+ insets.top + (Platform.OS === 'web' && isSidebarShell ? SIDEBAR_BODY_TOP_GUTTER : 0);
const { selectToken: selectSavingsFundToken } = useSavingsFundFlow();
const openBuyFuse = useSwapState(state => state.actions.openBuyFuse);
// The pager's pages are as wide as the column the page gets, which on desktop is
// the body column beside the sidebar rather than the whole window.
const pageWidth = usePageWidth();
- const isSidebarShell = useIsSidebarShell();
+ const selectorBlurTarget = useRef(null);
+ const reduceMotion = useReducedMotion();
// Suspends the page's vertical ScrollView while a horizontal swipe is active,
// so the two gestures (a plain RN ScrollView isn't gesture-handler-aware)
// don't both react to the same touch and fight over the drag.
@@ -889,16 +247,24 @@ function RewardsBenefitsForAccount() {
// neighboring pages follow the finger instead of faking a swipe with a
// fade/slide of a single swapped-out content block.
const translateX = useSharedValue(-TIERS.indexOf(selectedTier) * pageWidth);
+ const pagerTargetX = useSharedValue(-TIERS.indexOf(selectedTier) * pageWidth);
+ const dragOriginX = useSharedValue(-TIERS.indexOf(selectedTier) * pageWidth);
+ const dragStartIndex = useSharedValue(TIERS.indexOf(selectedTier));
+ const didRelease = useSharedValue(false);
const isDragging = useSharedValue(false);
useEffect(() => {
if (isDragging.value) return;
const index = TIERS.indexOf(selectedTier);
- translateX.value = withTiming(-index * pageWidth, {
- duration: SLIDE_DURATION,
- easing: SLIDE_EASING,
- });
- }, [selectedTier, translateX, isDragging, pageWidth]);
+ const targetX = -index * pageWidth;
+ // A gesture already started this snap on the UI thread. Committing its
+ // selected tab must not restart the same animation on the next render.
+ if (pagerTargetX.value === targetX && !reduceMotion) return;
+ pagerTargetX.value = targetX;
+ translateX.value = reduceMotion
+ ? targetX
+ : withTiming(targetX, { duration: SLIDE_DURATION, easing: SLIDE_EASING });
+ }, [selectedTier, translateX, pagerTargetX, isDragging, pageWidth, reduceMotion]);
const rowStyle = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value }],
@@ -919,7 +285,7 @@ function RewardsBenefitsForAccount() {
const ctaFor = (tier: RewardsTier) => {
const offer = findOffer(membership, tier);
- return tierUpgradeCta({
+ const cta = tierUpgradeCta({
selectedTier: tier,
currentTier,
unavailable: !currentTier || isError,
@@ -933,6 +299,15 @@ function RewardsBenefitsForAccount() {
// rewards endpoint cannot leave an upgrade CTA on a tier the user has.
offerHeld: offer?.held,
});
+ const offerCopy = membership?.enabled ? tierOfferSubtitle(offer) : null;
+ return cta.enabled && !isError && !pending && upgradeRoutes(tier).length > 0 && offerCopy
+ ? {
+ ...cta,
+ subtitle: offerCopy.includes('locked')
+ ? offerCopy.replace(/^Requires /, 'Lock ').replace(' locked', ' to upgrade')
+ : `${offerCopy} to upgrade`,
+ }
+ : cta;
};
/**
@@ -987,14 +362,14 @@ function RewardsBenefitsForAccount() {
const overlays = (
<>
@@ -1005,7 +380,10 @@ function RewardsBenefitsForAccount() {
style={{ height: HEADER_ROW_HEIGHT, marginTop: insets.top + HEADER_TOP_SPACING }}
>
{/* Stretched top-to-bottom so it centres on the tabs beside it. */}
-
+
router.push(path.REWARDS)} />
@@ -1055,20 +438,32 @@ function RewardsBenefitsForAccount() {
.failOffsetY([-10, 10])
.onStart(() => {
'worklet';
+ cancelAnimation(translateX);
isDragging.value = true;
+ didRelease.value = false;
+ // Start exactly where the previous snap currently is, even if a second
+ // swipe interrupts it before React has committed the selected tier.
+ dragOriginX.value = translateX.value;
+ dragStartIndex.value = Math.max(
+ 0,
+ Math.min(TIERS.length - 1, Math.round(-translateX.value / pageWidth)),
+ );
scheduleOnRN(setIsSwiping, true);
})
.onUpdate(event => {
'worklet';
- const index = TIERS.indexOf(selectedTier);
- let dx = event.translationX;
- if (index === 0 && dx > 0) dx *= RUBBER_BAND_FACTOR;
- if (index === TIERS.length - 1 && dx < 0) dx *= RUBBER_BAND_FACTOR;
- translateX.value = -index * pageWidth + dx;
+ const nextX = dragOriginX.value + event.translationX;
+ const minimumX = -(TIERS.length - 1) * pageWidth;
+ translateX.value =
+ nextX > 0
+ ? nextX * RUBBER_BAND_FACTOR
+ : nextX < minimumX
+ ? minimumX + (nextX - minimumX) * RUBBER_BAND_FACTOR
+ : nextX;
})
.onEnd(event => {
'worklet';
- const index = TIERS.indexOf(selectedTier);
+ const index = dragStartIndex.value;
const isSwipeLeft =
event.translationX < -SWIPE_DISTANCE_THRESHOLD ||
event.velocityX < -SWIPE_VELOCITY_THRESHOLD;
@@ -1079,56 +474,120 @@ function RewardsBenefitsForAccount() {
if (isSwipeLeft && index < TIERS.length - 1) targetIndex = index + 1;
else if (isSwipeRight && index > 0) targetIndex = index - 1;
- translateX.value = withTiming(
- -targetIndex * pageWidth,
- { duration: SLIDE_DURATION, easing: SLIDE_EASING },
- finished => {
- if (finished && targetIndex !== index) {
- scheduleOnRN(setSelectedTierOverride, TIERS[targetIndex]);
- }
- },
- );
+ didRelease.value = true;
+ const targetX = -targetIndex * pageWidth;
+ pagerTargetX.value = targetX;
+ translateX.value = reduceMotion
+ ? targetX
+ : withTiming(targetX, { duration: SLIDE_DURATION, easing: SLIDE_EASING });
+ scheduleOnRN(setSelectedTierOverride, TIERS[targetIndex]);
})
.onFinalize(() => {
'worklet';
+ // Native cancellation (for example another gesture taking ownership)
+ // must not leave the pager stranded between two tiers.
+ if (isDragging.value && !didRelease.value) {
+ const targetX = -dragStartIndex.value * pageWidth;
+ pagerTargetX.value = targetX;
+ translateX.value = reduceMotion
+ ? targetX
+ : withTiming(targetX, { duration: SLIDE_DURATION, easing: SLIDE_EASING });
+ scheduleOnRN(setSelectedTierOverride, TIERS[dragStartIndex.value]);
+ }
isDragging.value = false;
scheduleOnRN(setIsSwiping, false);
});
return (
-
- {timedOut && (
-
- Savings refreshed. No higher tier has been confirmed yet. Check your FUSE Savings balance
- and tier requirement before adding more.
-
- )}
-
- {/* Desktop: the row is three columns wide, so clip the neighbouring tiers at
+
+
+ {timedOut && (
+
+ Savings refreshed. No higher tier has been confirmed yet. Check your FUSE Savings
+ balance and tier requirement before adding more.
+
+ )}
+
+ {/* Desktop: the row is three columns wide, so clip the neighbouring tiers at
the column's edge — on mobile they simply hang off-screen. */}
-
-
- {TIERS.map(tier => (
- 0}
- />
- ))}
-
-
-
-
+
+
+
+ {TIERS.map(tier => (
+ benefit.tier === tier)}
+ fees={tierBenefits?.find(benefit => benefit.tier === tier)?.fees}
+ offer={membership?.enabled ? findOffer(membership, tier) : undefined}
+ showUpgradeSpace={!ctaFor(tier).held && tier !== RewardsTier.CORE}
+ subtitle={(() => {
+ const pointsEnabled = membership?.pointsUnlockEnabled ?? true;
+ const offerCopy = membership?.enabled
+ ? tierOfferSubtitle(findOffer(membership, tier))
+ : null;
+ const copy =
+ tier === RewardsTier.CORE
+ ? 'Starting tier'
+ : (offerCopy ??
+ (pointsEnabled
+ ? tier === RewardsTier.PRIME
+ ? 'Unlocks at 5M points'
+ : 'Unlocks at 35M points'
+ : null));
+ if (!copy) return null;
+ const text = (
+
+ {copy}
+
+ );
+ return selectedTier === tier &&
+ pointsEnabled &&
+ !offerCopy &&
+ tier !== RewardsTier.CORE ? (
+
+ {text}
+
+ }
+ />
+ ) : (
+ text
+ );
+ })()}
+ />
+ ))}
+
+
+
+
+
);
}
diff --git a/components/Rewards/NewRewards/RewardsDiamondIcon.tsx b/components/Rewards/NewRewards/RewardsDiamondIcon.tsx
index 32ba2da35..d15667d83 100644
--- a/components/Rewards/NewRewards/RewardsDiamondIcon.tsx
+++ b/components/Rewards/NewRewards/RewardsDiamondIcon.tsx
@@ -1,10 +1,13 @@
import { useEffect } from 'react';
import Animated, {
+ cancelAnimation,
Easing,
useAnimatedProps,
useAnimatedStyle,
+ useReducedMotion,
useSharedValue,
withDelay,
+ withRepeat,
withSequence,
withTiming,
} from 'react-native-reanimated';
@@ -15,6 +18,27 @@ const DIAMOND_PATH =
const SLASH_PATH =
'M36.7081 24.3057L34.4018 27.5856C33.0339 29.531 33.1785 32.0977 34.7572 33.8938L43.6269 43.9852';
const SLASH_LENGTH = 23;
+// Exact vectors from the v4 category cashback sheet; other sheets retain their mark.
+const CATEGORY_DIAMOND_PATH =
+ 'M9.36153 8.12458C12.0609 4.63345 13.4106 2.88788 15.4132 1.94394C17.4157 1.00004 19.7693 1.00004 24.4765 1.00004H42.5457H60.6149C65.3221 1.00004 67.6758 1.00004 69.6782 1.94394C71.6809 2.88788 73.0306 4.63345 75.7298 8.12458L78.4845 11.687C82.2801 16.5964 84.1782 19.0511 84.0883 21.8183C83.9983 24.5856 81.9439 26.9347 77.835 31.6333L53.5005 59.4613C50.1181 63.3293 48.4267 65.2636 46.4535 66.0987C43.977 67.1473 41.1144 67.1473 38.6379 66.0987C36.6647 65.2636 34.9732 63.3293 31.5908 59.4613L7.25622 31.6333C3.14749 26.9347 1.09315 24.5856 1.00306 21.8183C0.913026 19.0511 2.811 16.5964 6.607 11.687L9.36153 8.12458Z';
+const CATEGORY_SLASH_PATH =
+ 'M12.2416 9.00007L9.93528 12.1319C8.56738 13.9895 8.71198 16.4403 10.2907 18.1554L19.1604 27.7912';
+
+// The sampled spring easing exported by Figma's category-sheet timeline.
+const SCALE_SPRING_SAMPLES = [
+ 0, 0.0147, 0.0529, 0.1072, 0.1716, 0.2415, 0.3136, 0.3852, 0.4545, 0.5201, 0.5812, 0.6374, 0.6884,
+ 0.7342, 0.7749, 0.8108, 0.8422, 0.8695, 0.893, 0.913, 0.93, 0.9444, 0.9564, 0.9663, 0.9745,
+ 0.9811, 0.9865, 0.9908, 0.9941, 0.9968, 0.9988, 1.0002, 1.0013, 1.0021, 1.0025, 1.0028, 1.0029,
+ 1.003, 1.0029, 1.0027, 1.0026, 1.0024, 1.0022, 1.0019, 1.0017, 1.0015, 1.0013, 1.0012, 1.001,
+ 1.0009, 1.0007,
+];
+const figmaScaleEasing = (value: number) => {
+ 'worklet';
+ const position = value * (SCALE_SPRING_SAMPLES.length - 1);
+ const index = Math.min(Math.floor(position), SCALE_SPRING_SAMPLES.length - 2);
+ const start = SCALE_SPRING_SAMPLES[index];
+ return start + (SCALE_SPRING_SAMPLES[index + 1] - start) * (position - index);
+};
const AnimatedPath = Animated.createAnimatedComponent(Path);
@@ -27,18 +51,75 @@ const AnimatedPath = Animated.createAnimatedComponent(Path);
* Remount it to replay the animation — the sheets bump an `animationSession`
* key each time they open.
*/
-const RewardsDiamondIcon = () => {
+const RewardsDiamondIcon = ({ loop = false }: { loop?: boolean }) => {
+ const reduceMotion = useReducedMotion();
+ const diamondPath = loop ? CATEGORY_DIAMOND_PATH : DIAMOND_PATH;
+ const slashPath = loop ? CATEGORY_SLASH_PATH : SLASH_PATH;
+ const diamondTransform = loop ? 'translate(8 8.7)' : undefined;
+ const slashTransform = loop ? 'translate(24.5155 15.32)' : undefined;
const opacity = useSharedValue(0);
const scale = useSharedValue(0.92);
const glowOpacity = useSharedValue(0);
const slashProgress = useSharedValue(0);
useEffect(() => {
+ if (reduceMotion) {
+ opacity.value = 1;
+ scale.value = 1;
+ glowOpacity.value = 0;
+ slashProgress.value = 1;
+ return;
+ }
opacity.value = 0;
scale.value = 0.92;
glowOpacity.value = 0;
slashProgress.value = 0;
+ if (loop) {
+ const easeOut = Easing.bezier(0, 0, 0.58, 1);
+ const easeInOut = Easing.bezier(0.42, 0, 0.58, 1);
+ // All four tracks share the two-second loop exported by Figma.
+ opacity.value = withRepeat(
+ withSequence(
+ withTiming(0, { duration: 0 }),
+ withDelay(80, withTiming(1, { duration: 440, easing: easeOut })),
+ withDelay(1480, withTiming(1, { duration: 0 })),
+ ),
+ -1,
+ );
+ scale.value = withRepeat(
+ withSequence(
+ withTiming(0.92, { duration: 0 }),
+ withDelay(80, withTiming(1, { duration: 870, easing: figmaScaleEasing })),
+ withDelay(1050, withTiming(1, { duration: 0 })),
+ ),
+ -1,
+ );
+ glowOpacity.value = withRepeat(
+ withSequence(
+ withTiming(0, { duration: 0 }),
+ withDelay(620, withTiming(0.5, { duration: 380, easing: easeOut })),
+ withTiming(0, { duration: 600, easing: easeInOut }),
+ withDelay(400, withTiming(0, { duration: 0 })),
+ ),
+ -1,
+ );
+ slashProgress.value = withRepeat(
+ withSequence(
+ withTiming(0, { duration: 0 }),
+ withDelay(520, withTiming(1, { duration: 420, easing: easeOut })),
+ withDelay(1060, withTiming(1, { duration: 0 })),
+ ),
+ -1,
+ );
+ return () => {
+ cancelAnimation(opacity);
+ cancelAnimation(scale);
+ cancelAnimation(glowOpacity);
+ cancelAnimation(slashProgress);
+ };
+ }
+
opacity.value = withDelay(
80,
withTiming(1, { duration: 440, easing: Easing.out(Easing.cubic) }),
@@ -61,7 +142,7 @@ const RewardsDiamondIcon = () => {
520,
withTiming(1, { duration: 420, easing: Easing.out(Easing.cubic) }),
);
- }, [glowOpacity, opacity, scale, slashProgress]);
+ }, [glowOpacity, opacity, scale, slashProgress, loop, reduceMotion]);
const wrapperStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
@@ -94,16 +175,24 @@ const RewardsDiamondIcon = () => {
-
+
{
/>
router.push(path.REWARDS_BENEFITS)}
onReferralsPress={() => setIsReferralModalOpen(true)}
+ onUpgradeSubscriptionTier={handleUpgradeTier}
/>
diff --git a/components/Rewards/NewRewards/SubscriptionCashbackContent.tsx b/components/Rewards/NewRewards/SubscriptionCashbackContent.tsx
index a92e0f913..3e619ab41 100644
--- a/components/Rewards/NewRewards/SubscriptionCashbackContent.tsx
+++ b/components/Rewards/NewRewards/SubscriptionCashbackContent.tsx
@@ -1,116 +1,191 @@
-import { View } from 'react-native';
+import { useState } from 'react';
+import { Linking, Pressable, ScrollView, View } from 'react-native';
+import { Image } from 'expo-image';
import { Button } from '@/components/ui/button';
import { Text } from '@/components/ui/text';
+import { RewardsTier } from '@/lib/types';
import { cn, formatNumber } from '@/lib/utils';
-import RewardsDiamondIcon from './RewardsDiamondIcon';
-import SubscriptionBrandBadge from './SubscriptionBrandBadge';
import {
- SUBSCRIPTION_CATEGORIES,
- subscriptionCategoriesSentence,
- type SubscriptionCategory,
-} from './subscriptionBrands';
+ type CashbackCategoryKey,
+ CATEGORY_CASHBACK_TABS,
+ categoryCashbackPresentation,
+} from './categoryCashback';
+import CategoryCashbackBrandBadge from './CategoryCashbackBrandBadge';
+import { CATEGORY_CASHBACK_BRANDS } from './categoryCashbackBrands';
+import RewardsDiamondIcon from './RewardsDiamondIcon';
import type { SubscriptionCashbackData } from './SubscriptionCashbackSheet.types';
interface SubscriptionCashbackContentProps extends SubscriptionCashbackData {
onGetMoreCashback: () => void;
+ onDismiss: () => void;
animationSession: number;
- /**
- * Bottom-sheet presentation: adds the top padding that clears the sheet's drag
- * handle. False inside a modal, which brings its own padding.
- */
+ /** Clear the drag handle when presented as a bottom sheet. */
isSheet?: boolean;
+ /** Native sheets lay their handle above the scroll body. */
+ sheetTopPadding?: number;
}
-/** Logo badge diameter in the merchant rows. */
-const BADGE_SIZE = 30;
+const REWARDS_TERMS_URL =
+ 'https://support.solid.xyz/en/articles/15613716-solid-rewards-terms-and-conditions';
-const CategoryCard = ({ category, rate }: { category: SubscriptionCategory; rate: string }) => (
-
-
- {category.label}
-
-
- {category.brands.map(brand => (
-
-
-
- {brand.name}
-
-
- {rate}
+const CategoryCard = ({ category, rate }: { category: CashbackCategoryKey; rate: number }) => {
+ const locked = rate <= 0;
+ const label = locked
+ ? category === 'airlines'
+ ? 'Ultra'
+ : 'Prime'
+ : `${formatNumber(rate, 2, 0)}%`;
+
+ return (
+
+
+ Cashback rate
+
+ {locked && (
+
+ )}
+
+ {label}
+
- ))}
-
-);
+
+
+ {CATEGORY_CASHBACK_BRANDS[category].map(brand => (
+
+
+
+ {brand.name}
+
+
+ ))}
+
+
+ );
+};
-/**
- * Subscription cashback details: every eligible service, grouped by category,
- * each showing the rate the user's tier earns back on it.
- */
const SubscriptionCashbackContent = ({
+ currentTier,
subscriptionDiscountRate,
onGetMoreCashback,
+ onDismiss,
animationSession,
isSheet = true,
+ sheetTopPadding = 60,
}: SubscriptionCashbackContentProps) => {
- const rate = `${formatNumber(subscriptionDiscountRate || 0, 2, 0)}%`;
+ const [selectedCategory, setSelectedCategory] = useState('ai');
+ const presentation = categoryCashbackPresentation(currentTier, subscriptionDiscountRate);
return (
-
-
-
+
+
-
- {rate}
- {' '}
- Subscription Cashback
+ Up to{' '}
+
+ {formatNumber(presentation.headlineRate, 2, 0)}%
+
+ {'\n'}Cashback
- on {subscriptionCategoriesSentence()}
+ {presentation.subtitle}
-
- {SUBSCRIPTION_CATEGORIES.map(category => (
-
- ))}
+
+ {CATEGORY_CASHBACK_TABS.map(category => {
+ const selected = selectedCategory === category.key;
+ return (
+ setSelectedCategory(category.key)}
+ className={cn(
+ 'h-7 items-center justify-center rounded-full px-3',
+ selected && 'bg-white',
+ )}
+ >
+
+ {category.label}
+
+
+ );
+ })}
+
+
+
- Cashback is credited 14 days after the transaction settles and paid straight into your
- Savings{' '}
+ Cashback is credited 14 days after the transaction settles.{' '}
void Linking.openURL(REWARDS_TERMS_URL)}
>
Learn more
-
- Get more cashback
+
+ {presentation.actionLabel}
+
);
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 }),
}));