diff --git a/.env.example b/.env.example
index 77bbe0cf1..0583018e6 100644
--- a/.env.example
+++ b/.env.example
@@ -25,6 +25,8 @@ EXPO_PUBLIC_FIREBASE_DATABASE_URL=
EXPO_PUBLIC_AMPLITUDE_API_KEY=
EXPO_PUBLIC_AMPLITUDE_PROXY_URL=
EXPO_PUBLIC_MERKL_CAMPAIGN_ID=
+# Comma-separated Merkl campaign ids of the WFUSE yield-boost campaigns (soUSD, soETH, soFUSE)
+EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS=
EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL=http://localhost:5003
EXPO_PUBLIC_COINGECKO_API_KEY=
@@ -33,6 +35,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/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 ? (
-
+ }
+ 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/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/Rewards/NewRewards/RewardsScreenNew.tsx b/components/Rewards/NewRewards/RewardsScreenNew.tsx
index 93a98425c..6904db02f 100644
--- a/components/Rewards/NewRewards/RewardsScreenNew.tsx
+++ b/components/Rewards/NewRewards/RewardsScreenNew.tsx
@@ -303,6 +303,7 @@ export default function RewardsScreenNew() {
maxCashbackMonthly={rewardsData?.maxCashbackMonthly ?? 0}
allTimeCashback={allTimeCashback}
{...benefitRates}
+ yieldBoostBalanceCap={rewardsData?.yieldBoostBalanceCap ?? 0}
onGetMoreCashback={() => router.push(path.REWARDS_BENEFITS)}
onReferralsPress={() => setIsReferralModalOpen(true)}
/>
diff --git a/components/Rewards/NewRewards/TierBenefitsGrid.tsx b/components/Rewards/NewRewards/TierBenefitsGrid.tsx
index b6b47fdf5..a30621edf 100644
--- a/components/Rewards/NewRewards/TierBenefitsGrid.tsx
+++ b/components/Rewards/NewRewards/TierBenefitsGrid.tsx
@@ -77,6 +77,7 @@ const TierBenefitsGrid = ({
yieldBoostPercentage,
yieldBoostCap,
yieldBoostEarned,
+ yieldBoostBalanceCap,
onGetMoreCashback,
onReferralsPress,
...cashbackData
@@ -121,6 +122,7 @@ const TierBenefitsGrid = ({
yieldBoostPercentage={yieldBoostPercentage}
yieldBoostCap={yieldBoostCap}
yieldBoostEarned={yieldBoostEarned}
+ yieldBoostBalanceCap={yieldBoostBalanceCap}
/>
),
subscription: (
diff --git a/components/Rewards/NewRewards/YieldBoostContent.tsx b/components/Rewards/NewRewards/YieldBoostContent.tsx
index 7d439824f..7cb8eca23 100644
--- a/components/Rewards/NewRewards/YieldBoostContent.tsx
+++ b/components/Rewards/NewRewards/YieldBoostContent.tsx
@@ -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 FUSE 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'}
diff --git a/components/Rewards/NewRewards/YieldBoostSheet.types.ts b/components/Rewards/NewRewards/YieldBoostSheet.types.ts
index 49b49ab11..f64aa8c8d 100644
--- a/components/Rewards/NewRewards/YieldBoostSheet.types.ts
+++ b/components/Rewards/NewRewards/YieldBoostSheet.types.ts
@@ -7,6 +7,12 @@ export interface YieldBoostData {
yieldBoostCap: number;
/** Boost payouts the user has received so far, in USD. */
yieldBoostEarned: number;
+ /**
+ * Savings balance the boost is paid on, in USD (Prime $10K, Ultra $25K).
+ * When set, the copy describes the FUSE 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/Savings/NewSavings/SavingsScreenNew.tsx b/components/Savings/NewSavings/SavingsScreenNew.tsx
index 7298e2c69..8cb4a8b0e 100644
--- a/components/Savings/NewSavings/SavingsScreenNew.tsx
+++ b/components/Savings/NewSavings/SavingsScreenNew.tsx
@@ -15,6 +15,7 @@ import { VaultType } from '@/lib/types';
import SavingsFundedActions from './SavingsFundedActions';
import SavingsHelpModal from './SavingsHelpModal';
import SavingsVaultHero from './SavingsVaultHero';
+import SavingsYieldBoostCard from './SavingsYieldBoostCard';
import SimulateSavingsCard from './SimulateSavingsCard';
import StartEarningButton from './StartEarningButton';
import VaultApyHistoryCard from './VaultApyHistoryCard';
@@ -80,6 +81,7 @@ export default function SavingsScreenNew() {
<>
+
>
) : (
diff --git a/components/Savings/NewSavings/SavingsYieldBoostCard.tsx b/components/Savings/NewSavings/SavingsYieldBoostCard.tsx
index a8bfcdd7d..92cdaf70f 100644
--- a/components/Savings/NewSavings/SavingsYieldBoostCard.tsx
+++ b/components/Savings/NewSavings/SavingsYieldBoostCard.tsx
@@ -1,24 +1,37 @@
-import { View } from 'react-native';
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import Toast from 'react-native-toast-message';
+import { formatUnits } from 'viem';
+import { fuse } from 'viem/chains';
import { resolveTierBenefitRates } from '@/components/Rewards/NewRewards/tierBenefitCards';
+import Skeleton from '@/components/ui/skeleton';
import { Text } from '@/components/ui/text';
+import { useNativePriceUsd } from '@/hooks/useNativePriceUsd';
import { useRewardsUserData } from '@/hooks/useRewards';
+import { useClaimYieldBoost, useYieldBoostRewards } from '@/hooks/useYieldBoostRewards';
import { isDevFeatureEnabled } from '@/lib/config';
+import { YIELD_BOOST_REWARD_DECIMALS } from '@/lib/merklYieldBoost';
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.
+ * Figma 26134:23293 — "Yield boost": the extra APY the user's rewards tier adds
+ * on top of the base savings yield, what it has paid so far, and the claim.
*
- * 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
+ * The boost is paid in FUSE through Merkl, across all three vaults, so the card
+ * is the same whichever vault the screen shows. It renders nothing for a user
+ * with no boost and nothing left 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. 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.
+ * card is reviewable from a Core test account.
*/
const SavingsYieldBoostCard = () => {
const { data: rewardsData } = useRewardsUserData();
+ const { data: boostRewards, isLoading: isRewardsLoading } = useYieldBoostRewards();
+ const { mutateAsync: claim, isPending: isClaiming } = useClaimYieldBoost();
+ const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', true);
- const { yieldBoostPercentage: boostPercentage, yieldBoostEarned } = resolveTierBenefitRates(
+ const { yieldBoostPercentage: boostPercentage } = resolveTierBenefitRates(
{
yieldBoostPercentage: rewardsData?.yieldBoostPercentage ?? 0,
yieldBoostCap: rewardsData?.yieldBoostCap ?? 0,
@@ -28,7 +41,33 @@ const SavingsYieldBoostCard = () => {
isDevFeatureEnabled,
);
- if (boostPercentage <= 0) return null;
+ const claimable = boostRewards?.claimable ?? 0n;
+ if (boostPercentage <= 0 && claimable === 0n) return null;
+
+ const priceUsd = fusePriceUsd > 0 ? fusePriceUsd : (boostRewards?.priceUsd ?? 0);
+ const earnedUsd =
+ Number(formatUnits(boostRewards?.earned ?? 0n, YIELD_BOOST_REWARD_DECIMALS)) * priceUsd;
+ const canClaim = claimable > 0n && !isClaiming;
+
+ const handleClaim = async () => {
+ try {
+ const result = await claim();
+ if (!result) return;
+ Toast.show({
+ type: 'success',
+ text1: 'Yield boost claimed',
+ text2: `${formatNumber(Number(result.amount), 2)} FUSE`,
+ 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 (
@@ -37,24 +76,55 @@ const SavingsYieldBoostCard = () => {
-
+
Boost amount
-
-
- +{formatNumber(boostPercentage, 2, 0)}% Boost
-
-
+ {boostPercentage > 0 ? (
+
+
+ +{formatNumber(boostPercentage, 2, 0)}% Boost
+
+
+ ) : (
+ —
+ )}
Total Earned
-
- {formatBalanceUSD(yieldBoostEarned)}
-
+ {isRewardsLoading ? (
+
+ ) : (
+
+ {formatBalanceUSD(earnedUsd)}
+
+ )}
+
+ {isClaiming ? (
+
+ ) : (
+
+ Claim
+
+ )}
+
);
diff --git a/components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx b/components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx
new file mode 100644
index 000000000..689724d97
--- /dev/null
+++ b/components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx
@@ -0,0 +1,108 @@
+import React from 'react';
+
+import SavingsYieldBoostCard from '@/components/Savings/NewSavings/SavingsYieldBoostCard';
+// 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' }));
+jest.mock('@/lib/config', () => ({ isDevFeatureEnabled: false }));
+jest.mock('@/lib/merklYieldBoost', () => ({ YIELD_BOOST_REWARD_DECIMALS: 18 }));
+// 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 {
+ formatNumber,
+ formatBalanceUSD: (value: number) => (value > 0 ? `$${formatNumber(value, 2)}` : '$0.00'),
+ };
+});
+jest.mock('@/hooks/useNativePriceUsd', () => ({ useNativePriceUsd: () => 0.02 }));
+jest.mock('@/hooks/useRewards', () => ({
+ useRewardsUserData: () => ({ data: mockRewardsData }),
+}));
+jest.mock('@/hooks/useYieldBoostRewards', () => ({
+ useYieldBoostRewards: () => mockBoostQuery,
+ useClaimYieldBoost: () => ({ mutateAsync: mockClaim, isPending: false }),
+}));
+
+let mockRewardsData: Record = {};
+let mockBoostQuery: { data?: Record; isLoading: boolean } = { isLoading: false };
+const mockClaim = jest.fn();
+
+const ONE_FUSE = 10n ** 18n;
+
+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();
+ mockRewardsData = { yieldBoostPercentage: 2 };
+ mockBoostQuery = {
+ isLoading: false,
+ data: { claimable: 5n * ONE_FUSE, earned: 21_032n * ONE_FUSE, priceUsd: null },
+ };
+});
+afterEach(() => act(() => root?.unmount()));
+
+it('shows the tier boost and what the boost has earned in USD', () => {
+ render();
+
+ expect(texts()).toContain('+2% Boost');
+ // 21,032 FUSE × $0.02
+ expect(texts()).toContain('$420.64');
+});
+
+it('claims through the mutation when there is something to claim', async () => {
+ mockClaim.mockResolvedValue({ amount: '5' });
+ render();
+
+ expect(claimButton().props.disabled).toBe(false);
+ await act(async () => claimButton().props.onPress());
+ expect(mockClaim).toHaveBeenCalledTimes(1);
+});
+
+it('disables the claim when nothing is claimable yet', () => {
+ mockBoostQuery = { isLoading: false, data: { claimable: 0n, earned: 0n, priceUsd: null } };
+ render();
+
+ expect(claimButton().props.disabled).toBe(true);
+});
+
+it('renders nothing for a user with no boost and nothing to claim', () => {
+ mockRewardsData = { yieldBoostPercentage: 0 };
+ mockBoostQuery = { isLoading: false, data: { claimable: 0n, earned: 0n, priceUsd: null } };
+ render();
+
+ expect(root.toJSON()).toBeNull();
+});
+
+it('still lets a user who lost the boost collect what they earned', () => {
+ mockRewardsData = { yieldBoostPercentage: 0 };
+ render();
+
+ expect(texts()).not.toContain('% Boost');
+ expect(claimButton().props.disabled).toBe(false);
+});
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/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/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/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/hooks/useYieldBoostRewards.ts b/hooks/useYieldBoostRewards.ts
new file mode 100644
index 000000000..067244d97
--- /dev/null
+++ b/hooks/useYieldBoostRewards.ts
@@ -0,0 +1,144 @@
+import * as Sentry from '@sentry/react-native';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { Address, formatUnits } from 'viem';
+import { fuse } from 'viem/chains';
+
+import { useActivityActions } from '@/hooks/useActivityActions';
+import useUser from '@/hooks/useUser';
+import MerklDistributorABI from '@/lib/abis/MerklDistributor';
+import { ADDRESSES } from '@/lib/config';
+import { executeTransactions, getTransaction, USER_CANCELLED_TRANSACTION } from '@/lib/execute';
+import {
+ buildYieldBoostClaimTransactions,
+ fetchMerklYieldBoostRewards,
+ YIELD_BOOST_REWARD_DECIMALS,
+ YIELD_BOOST_REWARD_TOKEN,
+ yieldBoostClaimPayout,
+} from '@/lib/merklYieldBoost';
+import { TransactionType } from '@/lib/types';
+import { publicClient } from '@/lib/wagmi';
+
+export const yieldBoostRewardsQueryKey = (safeAddress?: string) =>
+ ['merkl', 'yieldBoost', safeAddress?.toLowerCase()] as const;
+
+/**
+ * The Safe's Merkl yield-boost rewards (WFUSE on Fuse).
+ *
+ * Merkl publishes a new root every few hours and `pending` moves roughly every
+ * two, so a minute of staleness is invisible and keeps this off Merkl's rate
+ * limit however often the savings screen is opened.
+ */
+export const useYieldBoostRewards = () => {
+ const { user } = useUser();
+ const safeAddress = user?.safeAddress;
+
+ return useQuery({
+ queryKey: yieldBoostRewardsQueryKey(safeAddress),
+ queryFn: () => fetchMerklYieldBoostRewards(safeAddress as string),
+ enabled: Boolean(safeAddress),
+ staleTime: 60_000,
+ });
+};
+
+/**
+ * Claim every yield-boost reward the Safe has and unwrap it to FUSE, in one
+ * user operation.
+ *
+ * Reads fresh (cache-bypassing) proofs and the Distributor's own claimed record
+ * right before signing, so a root Merkl pushed while the screen sat open, or a
+ * claim from another device, can't turn into a reverted batch. Resolves to
+ * `null` when the user cancels the passkey prompt.
+ */
+export const useClaimYieldBoost = () => {
+ const { user, safeAA } = useUser();
+ const { trackTransaction } = useActivityActions();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async () => {
+ if (!user?.safeAddress || !user?.suborgId || !user?.signWith) {
+ throw new Error('Your wallet is still setting up. Please try again shortly.');
+ }
+
+ const safeAddress = user.safeAddress as Address;
+ const distributor = ADDRESSES.fuse.merklDistributor;
+
+ const [rewards, [claimedOnchain]] = await Promise.all([
+ fetchMerklYieldBoostRewards(safeAddress, { reload: true }),
+ publicClient(fuse.id).readContract({
+ address: distributor,
+ abi: MerklDistributorABI,
+ functionName: 'claimed',
+ args: [safeAddress, YIELD_BOOST_REWARD_TOKEN],
+ }),
+ ]);
+
+ const transactions = buildYieldBoostClaimTransactions({
+ safeAddress,
+ rewards,
+ claimedOnchain,
+ });
+ if (transactions.length === 0) {
+ throw new Error('There is nothing to claim yet.');
+ }
+
+ const amount = formatUnits(
+ yieldBoostClaimPayout(rewards.amount, claimedOnchain),
+ YIELD_BOOST_REWARD_DECIMALS,
+ );
+
+ const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith);
+
+ const result = await trackTransaction(
+ {
+ type: TransactionType.MERKL_CLAIM,
+ title: 'Claim yield boost',
+ shortTitle: 'Yield boost',
+ amount,
+ symbol: 'FUSE',
+ chainId: fuse.id,
+ fromAddress: distributor,
+ toAddress: safeAddress,
+ metadata: {
+ description: `Claim ${amount} FUSE yield boost from Merkl`,
+ tokenAddress: ADDRESSES.fuse.nativeFeeToken,
+ },
+ },
+ onUserOpHash =>
+ executeTransactions(
+ smartAccountClient,
+ transactions,
+ 'Failed to claim yield boost',
+ fuse,
+ onUserOpHash,
+ ),
+ );
+
+ if (getTransaction(result) === USER_CANCELLED_TRANSACTION) return null;
+
+ return { amount };
+ },
+ onSuccess: async result => {
+ if (!result || !user?.safeAddress) return;
+
+ // Merkl takes a few minutes to index the claim, so write the fresh,
+ // cache-bypassing read into the query rather than waiting for it.
+ try {
+ queryClient.setQueryData(
+ yieldBoostRewardsQueryKey(user.safeAddress),
+ await fetchMerklYieldBoostRewards(user.safeAddress, { reload: true }),
+ );
+ } catch {
+ await queryClient.invalidateQueries({
+ queryKey: yieldBoostRewardsQueryKey(user.safeAddress),
+ });
+ }
+ },
+ onError: error => {
+ Sentry.captureException(error, {
+ tags: { operation: 'yield_boost_claim' },
+ extra: { userAddress: user?.safeAddress },
+ });
+ },
+ });
+};
diff --git a/lib/__tests__/merklYieldBoost.test.ts b/lib/__tests__/merklYieldBoost.test.ts
new file mode 100644
index 000000000..5236cfbae
--- /dev/null
+++ b/lib/__tests__/merklYieldBoost.test.ts
@@ -0,0 +1,146 @@
+import { decodeFunctionData } from 'viem';
+import { fuse, mainnet } from 'viem/chains';
+
+import MerklDistributorABI from '@/lib/abis/MerklDistributor';
+import { wNativeABI } from '@/lib/abis/tokens';
+import { ADDRESSES } from '@/lib/config';
+import {
+ buildYieldBoostClaimTransactions,
+ EMPTY_YIELD_BOOST_REWARDS,
+ parseMerklYieldBoostRewards,
+ YIELD_BOOST_REWARD_TOKEN,
+ yieldBoostClaimPayout,
+} from '@/lib/merklYieldBoost';
+
+const SAFE = '0x1111111111111111111111111111111111111111';
+const BOOST_USD = '0xaaaa';
+const BOOST_ETH = '0xbbbb';
+const OTHER = '0xcccc';
+const PROOF = `0x${'ab'.repeat(32)}` as const;
+
+const wfuseReward = (overrides: Record = {}) => ({
+ amount: '5000',
+ claimed: '1000',
+ pending: '300',
+ proofs: [PROOF],
+ token: { address: YIELD_BOOST_REWARD_TOKEN.toLowerCase(), price: 0.02 },
+ breakdowns: [
+ { campaignId: BOOST_USD, amount: '3000', pending: '200' },
+ { campaignId: BOOST_ETH.toUpperCase().replace('0X', '0x'), amount: '1500', pending: '100' },
+ { campaignId: OTHER, amount: '500', pending: '0' },
+ ],
+ ...overrides,
+});
+
+const response = (rewards: unknown[], chainId: number = fuse.id) => [
+ { chain: { id: chainId }, rewards },
+];
+
+describe('parseMerklYieldBoostRewards', () => {
+ it('reads the WFUSE leaf on Fuse', () => {
+ const parsed = parseMerklYieldBoostRewards(response([wfuseReward()]), []);
+
+ expect(parsed).toEqual({
+ amount: 5000n,
+ claimed: 1000n,
+ claimable: 4000n,
+ earned: 5300n,
+ proofs: [PROOF],
+ priceUsd: 0.02,
+ });
+ });
+
+ it('scopes earned to the boost campaigns, whatever their case', () => {
+ const parsed = parseMerklYieldBoostRewards(response([wfuseReward()]), [BOOST_USD, BOOST_ETH]);
+
+ // 3000 + 200 + 1500 + 100 — the unrelated campaign's 500 is left out.
+ expect(parsed.earned).toBe(4800n);
+ // Claims are per token, so what can be claimed is never scoped.
+ expect(parsed.claimable).toBe(4000n);
+ });
+
+ it('ignores other tokens and other chains', () => {
+ const otherToken = wfuseReward({
+ token: { address: '0x28C3d1cD466Ba22f6cae51b1a4692a831696391A' },
+ });
+
+ expect(parseMerklYieldBoostRewards(response([otherToken]), [])).toBe(EMPTY_YIELD_BOOST_REWARDS);
+ expect(parseMerklYieldBoostRewards(response([wfuseReward()], mainnet.id), [])).toBe(
+ EMPTY_YIELD_BOOST_REWARDS,
+ );
+ });
+
+ it('never reports a negative claimable', () => {
+ const parsed = parseMerklYieldBoostRewards(
+ response([wfuseReward({ amount: '1000', claimed: '1000' })]),
+ [],
+ );
+
+ expect(parsed.claimable).toBe(0n);
+ });
+
+ it('survives malformed payloads', () => {
+ expect(parseMerklYieldBoostRewards(null, [])).toBe(EMPTY_YIELD_BOOST_REWARDS);
+ expect(parseMerklYieldBoostRewards({ error: 'x' }, [])).toBe(EMPTY_YIELD_BOOST_REWARDS);
+
+ const parsed = parseMerklYieldBoostRewards(
+ response([
+ wfuseReward({
+ amount: 'NaN',
+ claimed: undefined,
+ token: { address: YIELD_BOOST_REWARD_TOKEN },
+ }),
+ ]),
+ [],
+ );
+ expect(parsed.amount).toBe(0n);
+ expect(parsed.priceUsd).toBeNull();
+ });
+});
+
+describe('buildYieldBoostClaimTransactions', () => {
+ const rewards = { amount: 5000n, proofs: [PROOF] };
+
+ it('claims the cumulative amount and unwraps exactly what it pays', () => {
+ const [claim, unwrap] = buildYieldBoostClaimTransactions({
+ safeAddress: SAFE,
+ rewards,
+ claimedOnchain: 1200n,
+ });
+
+ expect(claim.to).toBe(ADDRESSES.fuse.merklDistributor);
+ expect(decodeFunctionData({ abi: MerklDistributorABI, data: claim.data })).toEqual({
+ functionName: 'claim',
+ args: [[SAFE], [YIELD_BOOST_REWARD_TOKEN], [5000n], [[PROOF]]],
+ });
+
+ expect(unwrap.to).toBe(YIELD_BOOST_REWARD_TOKEN);
+ expect(decodeFunctionData({ abi: wNativeABI, data: unwrap.data })).toEqual({
+ functionName: 'withdraw',
+ args: [3800n],
+ });
+ });
+
+ it('builds nothing when everything is already claimed', () => {
+ expect(
+ buildYieldBoostClaimTransactions({ safeAddress: SAFE, rewards, claimedOnchain: 5000n }),
+ ).toEqual([]);
+ });
+
+ it('builds nothing without proofs', () => {
+ expect(
+ buildYieldBoostClaimTransactions({
+ safeAddress: SAFE,
+ rewards: { amount: 5000n, proofs: [] },
+ claimedOnchain: 0n,
+ }),
+ ).toEqual([]);
+ });
+});
+
+describe('yieldBoostClaimPayout', () => {
+ it('is the unclaimed remainder, floored at zero', () => {
+ expect(yieldBoostClaimPayout(5000n, 1000n)).toBe(4000n);
+ expect(yieldBoostClaimPayout(1000n, 5000n)).toBe(0n);
+ });
+});
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 95d61e677..f854e1962 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -143,7 +143,9 @@ import {
TokenPriceUsd,
TotalAPYResponse,
TransfiCreateOrderResponse,
+ TransfiKycLevel,
TransfiKycRetryResponse,
+ TransfiKycUpgradeResponse,
TransfiOrderStatusResponse,
TransfiPaymentConfig,
TransfiPaymentMethodOption,
@@ -1551,6 +1553,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.
@@ -2310,14 +2333,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();
@@ -2331,7 +2363,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/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 a8bb14988..21c51daf8 100644
--- a/lib/config.ts
+++ b/lib/config.ts
@@ -16,6 +16,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.
*
@@ -69,6 +73,18 @@ export const EXPO_PUBLIC_FIREBASE_DATABASE_URL =
export const EXPO_PUBLIC_AMPLITUDE_API_KEY = process.env.EXPO_PUBLIC_AMPLITUDE_API_KEY ?? '';
export const EXPO_PUBLIC_AMPLITUDE_PROXY_URL = process.env.EXPO_PUBLIC_AMPLITUDE_PROXY_URL ?? '';
export const EXPO_PUBLIC_MERKL_CAMPAIGN_ID = process.env.EXPO_PUBLIC_MERKL_CAMPAIGN_ID ?? '';
+/**
+ * Merkl campaign ids (0x… hashes) of the tiered yield-boost campaigns — one per
+ * vault (soUSD, soETH, soFUSE), all paying WFUSE on Fuse. Comma-separated.
+ * They scope "Total Earned" on the savings yield-boost card to the boost;
+ * claiming always takes the whole WFUSE balance, since Merkl claims per token.
+ */
+export const EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS: readonly string[] = (
+ process.env.EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS ?? ''
+)
+ .split(',')
+ .map((id: string) => id.trim().toLowerCase())
+ .filter(Boolean);
export const EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL =
process.env.EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL ?? '';
export const EXPO_PUBLIC_BRIDGE_CARD_API_BASE_URL =
diff --git a/lib/merklYieldBoost.ts b/lib/merklYieldBoost.ts
new file mode 100644
index 000000000..78f909a08
--- /dev/null
+++ b/lib/merklYieldBoost.ts
@@ -0,0 +1,205 @@
+import { Address, encodeFunctionData, Hex } from 'viem';
+import { fuse } from 'viem/chains';
+
+import { WRAPPED_FUSE } from '@/constants/addresses';
+import MerklDistributorABI from '@/lib/abis/MerklDistributor';
+import { wNativeABI } from '@/lib/abis/tokens';
+import { ADDRESSES, EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS } from '@/lib/config';
+
+/**
+ * The tiered yield boost, paid through Merkl.
+ *
+ * One campaign per savings vault (soUSD, soETH, soFUSE), all on Fuse and all
+ * paying WFUSE; the backend's API Boost endpoint sets each Safe's share from its
+ * tier (Prime +2% on the first $10K, Ultra +3% on the first $25K). Merkl claims
+ * are per token and cumulative, so the three campaigns collapse into one WFUSE
+ * leaf and one claim.
+ *
+ * Deliberately free of `@merkl/api`: the endpoints used here are plain REST
+ * (https://developers.merkl.xyz/integrate-merkl/user-rewards), and the typed
+ * client drags an Elysia runtime into every screen that imports it.
+ */
+
+export const MERKL_API_URL = 'https://api.merkl.xyz';
+
+/** Reward token of every yield-boost campaign. */
+export const YIELD_BOOST_REWARD_TOKEN: Address = WRAPPED_FUSE;
+export const YIELD_BOOST_REWARD_DECIMALS = 18;
+
+export interface MerklYieldBoostRewards {
+ /** Cumulative WFUSE in the live Merkle root, claimed or not (wei). */
+ amount: bigint;
+ /** Cumulative WFUSE already pulled onchain (wei), as Merkl last indexed it. */
+ claimed: bigint;
+ /** `amount - claimed`: what a claim transfers right now (wei). */
+ claimable: bigint;
+ /**
+ * Everything the boost campaigns have credited, claimed or not, including
+ * rewards not yet in a root (wei). Drives "Total Earned".
+ */
+ earned: bigint;
+ /** Proofs for `amount` against the live root. Passed to the Distributor as-is. */
+ proofs: Hex[];
+ /** Merkl's USD quote for the token, when it sent one. */
+ priceUsd: number | null;
+}
+
+export const EMPTY_YIELD_BOOST_REWARDS: MerklYieldBoostRewards = {
+ amount: 0n,
+ claimed: 0n,
+ claimable: 0n,
+ earned: 0n,
+ proofs: [],
+ priceUsd: null,
+};
+
+const toBigInt = (value: unknown): bigint => {
+ if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'bigint') {
+ return 0n;
+ }
+ try {
+ const parsed = BigInt(value);
+ return parsed > 0n ? parsed : 0n;
+ } catch {
+ return 0n;
+ }
+};
+
+const sameAddress = (a: unknown, b: string) =>
+ typeof a === 'string' && a.toLowerCase() === b.toLowerCase();
+
+interface MerklRewardBreakdownJson {
+ campaignId?: string;
+ amount?: string;
+ pending?: string;
+}
+
+interface MerklRewardJson {
+ amount?: string;
+ claimed?: string;
+ pending?: string;
+ proofs?: string[];
+ token?: { address?: string; price?: number | null };
+ breakdowns?: MerklRewardBreakdownJson[];
+}
+
+interface MerklChainRewardsJson {
+ chain?: { id?: number };
+ rewards?: MerklRewardJson[];
+}
+
+/**
+ * The WFUSE-on-Fuse leaf out of a `/v4/users/{address}/rewards` response.
+ *
+ * `campaignIds` scopes `earned` to the boost campaigns, so an unrelated WFUSE
+ * campaign the Safe also earns from doesn't inflate "Total Earned". Empty means
+ * unscoped: every WFUSE reward on Fuse counts. `claimable` is never scoped —
+ * the Distributor only claims whole tokens.
+ */
+export const parseMerklYieldBoostRewards = (
+ data: unknown,
+ campaignIds: readonly string[] = EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS,
+): MerklYieldBoostRewards => {
+ if (!Array.isArray(data)) return EMPTY_YIELD_BOOST_REWARDS;
+
+ const reward = (data as MerklChainRewardsJson[])
+ .filter(entry => entry?.chain?.id === fuse.id)
+ .flatMap(entry => entry.rewards ?? [])
+ .find(item => sameAddress(item?.token?.address, YIELD_BOOST_REWARD_TOKEN));
+
+ if (!reward) return EMPTY_YIELD_BOOST_REWARDS;
+
+ const amount = toBigInt(reward.amount);
+ const claimed = toBigInt(reward.claimed);
+ const scope = new Set(campaignIds.map(id => id.toLowerCase()));
+
+ const earned =
+ scope.size === 0
+ ? amount + toBigInt(reward.pending)
+ : (reward.breakdowns ?? [])
+ .filter(breakdown => scope.has(String(breakdown?.campaignId ?? '').toLowerCase()))
+ .reduce(
+ (total, breakdown) => total + toBigInt(breakdown.amount) + toBigInt(breakdown.pending),
+ 0n,
+ );
+
+ const price = reward.token?.price;
+
+ return {
+ amount,
+ claimed,
+ claimable: amount > claimed ? amount - claimed : 0n,
+ earned,
+ proofs: (reward.proofs ?? []).filter((proof): proof is Hex => typeof proof === 'string'),
+ priceUsd: typeof price === 'number' && Number.isFinite(price) && price > 0 ? price : null,
+ };
+};
+
+/**
+ * The Safe's yield-boost rewards from Merkl.
+ *
+ * `reload` bypasses Merkl's cache for Fuse, which otherwise keeps reporting the
+ * old `claimed` for up to ~5 minutes after a claim. It is more expensive for
+ * Merkl to serve, so it is only for the read right before and right after a
+ * claim.
+ */
+export const fetchMerklYieldBoostRewards = async (
+ address: string,
+ { reload = false }: { reload?: boolean } = {},
+): Promise => {
+ const params = new URLSearchParams({ chainId: String(fuse.id) });
+ if (reload) params.set('reloadChainId', String(fuse.id));
+
+ const response = await fetch(`${MERKL_API_URL}/v4/users/${address}/rewards?${params}`);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch yield boost rewards (${response.status})`);
+ }
+
+ return parseMerklYieldBoostRewards(await response.json());
+};
+
+/** What a claim of `amount` transfers, given the Distributor's own record. */
+export const yieldBoostClaimPayout = (amount: bigint, claimedOnchain: bigint) =>
+ amount > claimedOnchain ? amount - claimedOnchain : 0n;
+
+/**
+ * Claim the WFUSE leaf and unwrap what it pays into native FUSE, in one batch.
+ *
+ * The unwrap amount is measured against the Distributor's own `claimed` record
+ * rather than Merkl's indexed one: the claim transfers exactly
+ * `amount - claimedOnchain`, and unwrapping a stale larger figure would revert
+ * the whole batch. Returns no calls when there is nothing to claim.
+ */
+export const buildYieldBoostClaimTransactions = ({
+ safeAddress,
+ rewards,
+ claimedOnchain,
+}: {
+ safeAddress: Address;
+ rewards: Pick;
+ claimedOnchain: bigint;
+}): { to: Address; data: Hex; value: bigint }[] => {
+ const payout = yieldBoostClaimPayout(rewards.amount, claimedOnchain);
+ if (payout === 0n || rewards.proofs.length === 0) return [];
+
+ return [
+ {
+ to: ADDRESSES.fuse.merklDistributor,
+ data: encodeFunctionData({
+ abi: MerklDistributorABI,
+ functionName: 'claim',
+ args: [[safeAddress], [YIELD_BOOST_REWARD_TOKEN], [rewards.amount], [rewards.proofs]],
+ }),
+ value: 0n,
+ },
+ {
+ to: YIELD_BOOST_REWARD_TOKEN,
+ data: encodeFunctionData({
+ abi: wNativeABI,
+ functionName: 'withdraw',
+ args: [payout],
+ }),
+ value: 0n,
+ },
+ ];
+};
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/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 3b33ede3d..242c5b120 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -1803,6 +1803,11 @@ 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;
/**
* Cashback % the current tier earns back on eligible subscriptions. 0 (or
* absent) means the tier grants none, which hides the subscription card.
@@ -1946,6 +1951,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),
@@ -2886,6 +2893,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/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/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 }),
}));