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/__tests__/buyCryptoFlowContent.test.tsx b/components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx
new file mode 100644
index 000000000..2dd47540e
--- /dev/null
+++ b/components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx
@@ -0,0 +1,53 @@
+import React from 'react';
+
+import { BuyCryptoFlowContent } from '@/components/BuyCrypto/Transfi/BuyCryptoFlow';
+import { DEPOSIT_MODAL } from '@/constants/modals';
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const { act, create } = require('react-test-renderer');
+
+jest.mock('@/components/BuyCrypto/OnramperWidget/OnramperWidget', () => ({
+ OnramperWidget: 'OnramperWidget',
+}));
+jest.mock('@/components/BuyCrypto/Transfi/BuyCryptoNavigation', () => ({
+ BuyCryptoNavigationProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiAmount', () => ({ TransfiAmount: 'Screen' }));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiCurrencySelector', () => ({
+ TransfiCurrencySelector: 'Screen',
+}));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiError', () => ({ TransfiError: 'Screen' }));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiKycConsent', () => ({
+ TransfiKycConsent: 'Screen',
+}));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiKycPending', () => ({
+ TransfiKycPending: 'Screen',
+}));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiOrderStatus', () => ({
+ TransfiOrderStatus: 'Screen',
+}));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiPayment', () => ({ TransfiPayment: 'Screen' }));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiPaymentMethodSelector', () => ({
+ TransfiPaymentMethodSelector: 'Screen',
+}));
+jest.mock('@/components/BuyCrypto/Transfi/TransfiProfileForm', () => ({
+ TransfiProfileForm: 'Screen',
+}));
+
+beforeEach(() => {
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
+});
+
+// Only the card funding modals embed this content, so the widget it opens must
+// fund the card — a wallet URL here would deliver the purchase to the Safe.
+it('opens the Onramper widget delivering to the card', () => {
+ let root: any;
+ act(() => {
+ root = create(
+ ,
+ );
+ });
+
+ expect(root.root.findByType('OnramperWidget').props.destination).toBe('card');
+ act(() => root.unmount());
+});
diff --git a/components/Card/CardDirectDepositModalDesktop.tsx b/components/Card/CardDirectDepositModalDesktop.tsx
index 0439177ec..61cadb843 100644
--- a/components/Card/CardDirectDepositModalDesktop.tsx
+++ b/components/Card/CardDirectDepositModalDesktop.tsx
@@ -16,6 +16,7 @@ import {
} from '@/components/Card/CardFund/constants';
import DepositNetwork from '@/components/DepositNetwork/DepositNetwork';
import AddFundsToWalletForm from '@/components/DepositOption/AddFundsToWalletForm';
+import { UsdMethodList } from '@/components/DepositOption/DepositUsdOptions';
import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
@@ -44,12 +45,13 @@ import { useCardDepositStore } from '@/store/useCardDepositStore';
import { useDepositStore } from '@/store/useDepositStore';
import { useTransfiStore } from '@/store/useTransfiStore';
-type Step = 'options' | 'networks' | 'address' | 'walletNetworks' | 'form';
+type Step = 'options' | 'usdMethods' | 'networks' | 'address' | 'walletNetworks' | 'form';
const CLOSE_STATE: ModalState = { name: 'close', number: -1 };
const MODAL_STATES: Record = {
options: { name: 'options', number: 0 },
+ usdMethods: { name: 'usd-methods', number: 1 },
networks: { name: 'networks', number: 1 },
walletNetworks: { name: 'wallet-networks', number: 1 },
address: { name: 'address', number: 2 },
@@ -198,9 +200,13 @@ export default function CardDirectDepositModal({
[],
);
- // First-time setup stacks above this funding dialog so closing it returns to
- // "Fund your card". Existing accounts still hand off to the global details flow.
- const handleUsdPress = useCallback(() => {
+ // USD lists its methods — the bank rail and Apple Pay — as the wallet's cash
+ // flow does, rather than going straight to the bank rail.
+ const handleUsdPress = useCallback(() => goToStep('usdMethods'), [goToStep]);
+
+ // First-time setup stacks above this funding dialog so closing it returns here.
+ // Existing accounts still hand off to the global details flow.
+ const handleBankTransferPress = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'bank_transfer',
provider: virtualAccountProvider,
@@ -248,8 +254,17 @@ export default function CardDirectDepositModal({
);
// Onramper's hosted widget, rendered inside this modal by the same embedded
- // navigator the TransFi screens use — so back and the title come from
+ // navigator the TransFi screens use — so its title comes from
// lib/buyCryptoFlow, not from a step of our own.
+ const handleApplePayPress = useCallback(() => {
+ track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ deposit_method: 'buy_crypto',
+ provider: 'onramper',
+ currency: 'USD',
+ });
+ navigateBuyCrypto(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET);
+ }, [navigateBuyCrypto]);
+
// "Deposit from an external wallet" — connect a crypto wallet, then send.
const handleConnectWallet = useCallback(async () => {
try {
@@ -347,7 +362,10 @@ export default function CardDirectDepositModal({
if (buyCryptoModal) {
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
- goToStep('options');
+ // The widget is opened from the USD methods; the TransFi and Cash App
+ // screens from the options themselves.
+ const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name;
+ goToStep(isWidget ? 'usdMethods' : 'options');
} else if (target) {
navigateBuyCrypto(target);
}
@@ -370,6 +388,7 @@ export default function CardDirectDepositModal({
const title = (() => {
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
+ if (step === 'usdMethods') return 'Deposit US Dollars';
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
return 'Fund your card';
@@ -406,6 +425,15 @@ export default function CardDirectDepositModal({
);
}
+ if (step === 'usdMethods') {
+ return (
+
+ );
+ }
+
if (step === 'networks') {
return ;
}
diff --git a/components/Card/CardDirectDepositModalMobile.tsx b/components/Card/CardDirectDepositModalMobile.tsx
index 3f321a491..33e4149ed 100644
--- a/components/Card/CardDirectDepositModalMobile.tsx
+++ b/components/Card/CardDirectDepositModalMobile.tsx
@@ -13,6 +13,7 @@ import {
getCardFundTokenIcon,
} from '@/components/Card/CardFund/constants';
import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
+import { UsdMethodList } from '@/components/DepositOption/DepositUsdOptions';
import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog';
import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow';
import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal';
@@ -37,12 +38,13 @@ import { useCardDepositStore } from '@/store/useCardDepositStore';
import { useDepositStore } from '@/store/useDepositStore';
import { useTransfiStore } from '@/store/useTransfiStore';
-type Step = 'options' | 'networks' | 'address' | 'externalAddress';
+type Step = 'options' | 'usdMethods' | 'networks' | 'address' | 'externalAddress';
const CLOSE_STATE: ModalState = { name: 'close', number: -1 };
const MODAL_STATES: Record = {
options: { name: 'options', number: 0 },
+ usdMethods: { name: 'usd-methods', number: 1 },
networks: { name: 'networks', number: 1 },
externalAddress: { name: 'external-address', number: 1 },
address: { name: 'address', number: 2 },
@@ -176,9 +178,13 @@ export default function CardDirectDepositModalMobile({
[],
);
- // First-time setup stacks above this funding dialog so closing it returns to
- // "Fund your card". Existing accounts still hand off to the global details flow.
- const handleUsdPress = useCallback(() => {
+ // USD lists its methods — the bank rail and Apple Pay — as the wallet's cash
+ // flow does, rather than going straight to the bank rail.
+ const handleUsdPress = useCallback(() => goToStep('usdMethods'), [goToStep]);
+
+ // First-time setup stacks above this funding dialog so closing it returns here.
+ // Existing accounts still hand off to the global details flow.
+ const handleBankTransferPress = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
deposit_method: 'bank_transfer',
provider: virtualAccountProvider,
@@ -226,8 +232,17 @@ export default function CardDirectDepositModalMobile({
);
// Onramper's hosted widget, rendered inside this modal by the same embedded
- // navigator the TransFi screens use — so back and the title come from
+ // navigator the TransFi screens use — so its title comes from
// lib/buyCryptoFlow, not from a step of our own.
+ const handleApplePayPress = useCallback(() => {
+ track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ deposit_method: 'buy_crypto',
+ provider: 'onramper',
+ currency: 'USD',
+ });
+ navigateBuyCrypto(DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET);
+ }, [navigateBuyCrypto]);
+
const handleTransferFromWallet = useCallback(() => {
handleOpenChange(false);
setDepositModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM);
@@ -279,7 +294,10 @@ export default function CardDirectDepositModalMobile({
if (buyCryptoModal) {
const target = getEmbeddedBackTarget(buyCryptoModal);
if (target === 'entry') {
- goToStep('options');
+ // The widget is opened from the USD methods; the TransFi and Cash App
+ // screens from the options themselves.
+ const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name;
+ goToStep(isWidget ? 'usdMethods' : 'options');
} else if (target) {
navigateBuyCrypto(target);
}
@@ -298,6 +316,7 @@ export default function CardDirectDepositModalMobile({
const title = (() => {
if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal);
+ if (step === 'usdMethods') return 'Deposit US Dollars';
if (step === 'networks') return selectedToken;
if (step === 'address') return `Deposit ${selectedToken}`;
return 'Fund your card';
@@ -333,6 +352,15 @@ export default function CardDirectDepositModalMobile({
);
}
+ if (step === 'usdMethods') {
+ return (
+
+ );
+ }
+
if (step === 'networks') {
return ;
}
diff --git a/components/Card/CardFund/CardFundOptions.tsx b/components/Card/CardFund/CardFundOptions.tsx
index 863b5fe74..443985175 100644
--- a/components/Card/CardFund/CardFundOptions.tsx
+++ b/components/Card/CardFund/CardFundOptions.tsx
@@ -30,7 +30,7 @@ type CardFundOptionsProps = {
onTokenPress: (symbol: string) => void;
onMoveFromSavingsPress?: () => void;
onExternalWalletPress?: () => void;
- /** USD (ACH / Wire) — opens the virtual-account flow. */
+ /** USD — opens its methods: the virtual account (ACH / Wire) or Apple Pay. */
onUsdPress?: () => void;
/**
* A local currency (BRL, BDT…) — opens the buy-crypto onramp for it. Omit to
@@ -109,7 +109,7 @@ const CardFundOptions = ({
}
title="USD"
- chips={['ACH', 'Wire']}
+ chips={['ACH', 'Wire', 'Apple Pay']}
onPress={onUsdPress}
/>
) : null}
diff --git a/components/DepositOption/DepositCashOptions.tsx b/components/DepositOption/DepositCashOptions.tsx
index 418f37a3f..3ba56a0e6 100644
--- a/components/DepositOption/DepositCashOptions.tsx
+++ b/components/DepositOption/DepositCashOptions.tsx
@@ -14,27 +14,24 @@ 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 useGeoCompliance from '@/hooks/useGeoCompliance';
-import { useOrchestraConfig } from '@/hooks/useOrchestra';
-import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry';
+import { useIsCashAppAvailable } from '@/hooks/useOrchestra';
import { track } from '@/lib/analytics';
import { getAsset } from '@/lib/assets';
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)';
/**
- * USD is funded by the virtual account, which only takes ACH and wire. The
- * local currencies show their committed corridor list (localCurrencies.tsx).
+ * USD's methods, as the screen it opens lists them: the virtual account's ACH
+ * and wire, and Apple Pay. The local currencies show their committed corridor
+ * list (localCurrencies.tsx).
*/
-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 USD_PAYMENT_METHOD_CHIPS = ['ACH', 'Wire', 'Apple Pay'];
+/** In the US the same screen also offers Cash App, so the chips say so. */
+const USD_PAYMENT_METHOD_CHIPS_US = ['ACH', 'Wire', 'Cash App', 'Apple Pay'];
const FEATURED_LOCAL_CURRENCY_CODES = ['EUR', 'BRL', 'BDT', 'PHP'] as const;
const ADDITIONAL_LOCAL_CURRENCY_CODES = ['MXN'] as const;
@@ -50,9 +47,9 @@ export const DEPOSIT_CASH_CURRENCY_COUNT =
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,13 +57,7 @@ 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 { isBuyCryptoAvailable } = useGeoCompliance();
const { handleBuyCryptoPress } = useBuyCryptoEntry();
@@ -80,16 +71,10 @@ const DepositCashOptions = () => {
.filter((currency): currency is (typeof CARD_FUND_LOCAL_CURRENCIES)[number] => !!currency);
}, [showAllCurrencies]);
+ // The bank rail and Apple Pay are offered everywhere, so USD always has more
+ // than one method and always opens the chooser, with Cash App added in the US.
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,51 +94,47 @@ const DepositCashOptions = () => {
};
return (
- <>
-
+
+
+ }
+ title="USD"
+ chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS}
+ onPress={handleUsdPress}
+ />
+ {localCurrencies.map(currency => (
- }
- title="USD"
- chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS}
- onPress={handleUsdPress}
+ icon={currency.icon}
+ title={currency.code}
+ chips={getCardFundLocalPaymentMethods(currency.code)}
+ onPress={() => handleLocalCurrencyPress(currency.code)}
/>
- {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. */}
- setShowAllCurrencies(current => !current)}
- >
- {showAllCurrencies ? (
-
- ) : (
-
- )}
-
- {showAllCurrencies ? 'Show less' : 'Show more'}
-
-
-
-
-
- >
+ ))}
+ {/* 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. */}
+ setShowAllCurrencies(current => !current)}
+ >
+ {showAllCurrencies ? (
+
+ ) : (
+
+ )}
+
+ {showAllCurrencies ? 'Show less' : 'Show more'}
+
+
+
);
};
diff --git a/components/DepositOption/DepositUsdOptions.tsx b/components/DepositOption/DepositUsdOptions.tsx
index 068632a64..6a3f6552f 100644
--- a/components/DepositOption/DepositUsdOptions.tsx
+++ b/components/DepositOption/DepositUsdOptions.tsx
@@ -2,10 +2,12 @@ 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 { useIsCashAppAvailable } from '@/hooks/useOrchestra';
import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry';
import { track } from '@/lib/analytics';
import { useDepositStore } from '@/store/useDepositStore';
@@ -19,17 +21,81 @@ 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 = {
+ onBankTransferPress: () => void;
+ /** Omit to hide the row — Cash App is only offered where the server allows it. */
+ onCashAppPress?: () => void;
+ onApplePayPress: () => void;
+};
+
+/**
+ * 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) => (
+
+
+
+
+ }
+ title="Wire transfer, ACH"
+ subtitle="Your own US account details"
+ onPress={onBankTransferPress}
+ chips={BANK_CHIPS}
+ />
+ {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. */}
+ }
+ title="Apple Pay"
+ subtitle="Pay with Apple Pay or a card"
+ onPress={onApplePayPress}
+ />
+
+);
+
/**
- * How to fund in USD: the bank rail, or Cash App over Lightning.
+ * 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.
+ * The bank rail and Apple Pay are offered everywhere, so USD always opens this
+ * list. 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();
useEffect(() => {
track(TRACKING_EVENTS.DEPOSIT_USD_METHOD_VIEWED);
@@ -47,40 +113,22 @@ 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..64bf4abc7
--- /dev/null
+++ b/components/DepositOption/__tests__/depositUsdOptions.test.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+
+import DepositUsdOptions from '@/components/DepositOption/DepositUsdOptions';
+import { DEPOSIT_MODAL } from '@/constants/modals';
+
+// 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/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('@/store/useDepositStore', () => ({
+ useDepositStore: (selector: (state: any) => unknown) => selector(mockDeposit),
+}));
+jest.mock('@/store/useOrchestraStore', () => ({
+ useOrchestraStore: (selector: (state: any) => unknown) => selector({ reset: jest.fn() }),
+}));
+
+const mockCashApp = { isAvailable: false };
+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;
+});
+
+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());
+});
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/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 394c42a40..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.
*
diff --git a/hooks/useOrchestraCardEntry.ts b/hooks/useOrchestraCardEntry.ts
index ec83a0135..dfecc43ac 100644
--- a/hooks/useOrchestraCardEntry.ts
+++ b/hooks/useOrchestraCardEntry.ts
@@ -3,8 +3,7 @@ import { useCallback } from 'react';
import { OrchestraNavigate } from '@/components/Orchestra/OrchestraNavigation';
import { DEPOSIT_MODAL } from '@/constants/modals';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
-import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability';
-import { useOrchestraConfig } from '@/hooks/useOrchestra';
+import { useIsCashAppAvailable } from '@/hooks/useOrchestra';
import { track } from '@/lib/analytics';
import { useOrchestraStore } from '@/store/useOrchestraStore';
@@ -22,9 +21,7 @@ import { useOrchestraStore } from '@/store/useOrchestraStore';
export const useOrchestraCardEntry = (navigate: OrchestraNavigate) => {
const reset = useOrchestraStore(state => state.reset);
const setDestination = useOrchestraStore(state => state.setDestination);
-
- const { countryCode, isResolving } = useCashAppDepositAvailability();
- const { data: config } = useOrchestraConfig(countryCode, !isResolving);
+ const isAvailable = useIsCashAppAvailable();
const openCashApp = useCallback(() => {
track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
@@ -40,7 +37,7 @@ export const useOrchestraCardEntry = (navigate: OrchestraNavigate) => {
navigate(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT);
}, [navigate, reset, setDestination]);
- return { openCashApp, isAvailable: config?.isAvailable === true };
+ return { openCashApp, isAvailable };
};
export default useOrchestraCardEntry;
diff --git a/lib/api.ts b/lib/api.ts
index 95d61e677..5b31de02a 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -2310,14 +2310,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 +2340,7 @@ export const fetchOnramperWidgetSession = async (
...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
},
credentials: 'include',
- body: JSON.stringify({ platform }),
+ body: JSON.stringify({ platform, destination }),
},
);
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: