From 90bfcea23059a5596e5168a1c9c9189ef8cd5dd1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 11:37:10 +0000 Subject: [PATCH 01/11] feat(savings): claim the FUSE yield boost from the savings vault screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Yield boost card from Figma 26134:23293 with its Claim button, and puts the card back on the funded savings vault screen (the redesign had dropped it), under the balance card. - The boost is paid by Merkl campaigns (one per vault, all WFUSE on Fuse). lib/merklYieldBoost.ts reads the WFUSE leaf from /v4/users/{address}/rewards, scopes "Total Earned" to the campaign ids in EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS, and builds the claim. It doesn't import @merkl/api, so it can be tested without it. - Claim is one user operation: Distributor.claim with the cumulative amount, then WFUSE.withdraw of exactly what the claim pays, measured against the Distributor's own claimed record so a stale index can't revert the batch. Proofs are re-read (bypassing Merkl's cache) right before signing, and the query is refreshed the same way afterwards. - Total Earned is the boost campaigns' credited amount plus pending, in USD at the app's FUSE price (Merkl's quote as a fallback). - The card stays visible with a disabled "—" boost for a user who lost their tier but still has something to claim. - The rewards yield-boost sheet now describes the FUSE boost and its balance cap ("on your first $10,000") when the backend sends yieldBoostBalanceCap, and keeps the old copy when it doesn't. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EupadZ9qYL5nDLM6dDtu5n --- .env.example | 2 + .../Rewards/NewRewards/RewardsScreenNew.tsx | 1 + .../Rewards/NewRewards/TierBenefitsGrid.tsx | 2 + .../Rewards/NewRewards/YieldBoostContent.tsx | 9 +- .../NewRewards/YieldBoostSheet.types.ts | 6 + .../Savings/NewSavings/SavingsScreenNew.tsx | 2 + .../NewSavings/SavingsYieldBoostCard.tsx | 110 ++++++++-- .../__tests__/SavingsYieldBoostCard.test.tsx | 108 +++++++++ hooks/useYieldBoostRewards.ts | 144 ++++++++++++ lib/__tests__/merklYieldBoost.test.ts | 146 +++++++++++++ lib/config.ts | 12 + lib/merklYieldBoost.ts | 205 ++++++++++++++++++ lib/types.ts | 7 + 13 files changed, 731 insertions(+), 23 deletions(-) create mode 100644 components/Savings/NewSavings/__tests__/SavingsYieldBoostCard.test.tsx create mode 100644 hooks/useYieldBoostRewards.ts create mode 100644 lib/__tests__/merklYieldBoost.test.ts create mode 100644 lib/merklYieldBoost.ts diff --git a/.env.example b/.env.example index 77bbe0cf1..2a79463e0 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= 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/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/config.ts b/lib/config.ts index a8bb14988..ecbcb29a7 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -69,6 +69,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/types.ts b/lib/types.ts index 3b33ede3d..2abe14333 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), From a4b43a8e4b93a6cd2425c2e99a30a2913df8a187 Mon Sep 17 00:00:00 2001 From: mul53 Date: Mon, 28 Sep 2026 00:19:36 +0200 Subject: [PATCH 02/11] feat(deposit): offer Cash App under Fund your card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail only existed in the wallet flow. It now appears in all three card funding modals — Wirex, and Rain on mobile and desktop. The steps are the same screens; only the shell differs. OrchestraFlow renders them with the host's own navigator, the way BuyCryptoFlow already does for TransFi, and lib/orchestraFlow supplies the title and back target so each host asks the owning flow rather than keeping its own copy. Both embedded flows now route through one pair of helpers in each modal. Which balance gets funded is the entry point's to decide, not the screen's: useOrchestraCardEntry sets it to 'card' and the amount screen sends it, so the server resolves the card's own address instead of the Safe. The receive line follows suit — "in your card balance" rather than "in your wallet". A modal that forgot to set it would quietly fund the wallet from a screen titled Fund your card, which is why that lives in a shared hook rather than three copies. The row is passed only when the server says the deposit is available, so it stays hidden rather than disabled outside the US. Worth noting the neighbouring "Buy crypto" row is dead code today: it takes onBuyCryptoPress and none of the three modals pass it. Co-Authored-By: Claude Opus 5 --- .../Card/CardDirectDepositModalDesktop.tsx | 30 ++++++++-- .../Card/CardDirectDepositModalMobile.tsx | 30 ++++++++-- components/Card/CardFund/CardFundOptions.tsx | 18 +++++- components/Card/WirexCardFundModal.tsx | 30 ++++++++-- components/Orchestra/OrchestraAmount.tsx | 7 ++- components/Orchestra/OrchestraFlow.tsx | 41 ++++++++++++++ hooks/useOrchestra.ts | 11 +++- hooks/useOrchestraCardEntry.ts | 46 ++++++++++++++++ lib/api/orchestra.ts | 7 ++- lib/orchestraFlow.ts | 55 +++++++++++++++++++ store/useOrchestraStore.ts | 17 +++++- 11 files changed, 273 insertions(+), 19 deletions(-) create mode 100644 components/Orchestra/OrchestraFlow.tsx create mode 100644 hooks/useOrchestraCardEntry.ts create mode 100644 lib/orchestraFlow.ts diff --git a/components/Card/CardDirectDepositModalDesktop.tsx b/components/Card/CardDirectDepositModalDesktop.tsx index 405dac48c..0439177ec 100644 --- a/components/Card/CardDirectDepositModalDesktop.tsx +++ b/components/Card/CardDirectDepositModalDesktop.tsx @@ -17,6 +17,7 @@ import { import DepositNetwork from '@/components/DepositNetwork/DepositNetwork'; import AddFundsToWalletForm from '@/components/DepositOption/AddFundsToWalletForm'; import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog'; +import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { Text } from '@/components/ui/text'; import { BRIDGE_TOKENS } from '@/constants/bridge'; @@ -25,6 +26,7 @@ import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry'; import { useCardStatus } from '@/hooks/useCardStatus'; import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; +import { useOrchestraCardEntry } from '@/hooks/useOrchestraCardEntry'; import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider'; import { track } from '@/lib/analytics'; import { createDirectDepositSession } from '@/lib/api'; @@ -33,6 +35,7 @@ import { getBuyCryptoTitle, getEmbeddedBuyCryptoTarget, } from '@/lib/buyCryptoFlow'; +import { getOrchestraBackTarget, getOrchestraTitle, isOrchestraModal } from '@/lib/orchestraFlow'; import { cleanupThirdwebStyles, client, thirdwebTheme, thirdwebWallets } from '@/lib/thirdweb'; import { CardProvider, DepositModal, RainApplicationStatus } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; @@ -59,6 +62,16 @@ const MODAL_STATES: Record = { * this one unmounts leaves the closing sheet's view on top of the new one, * swallowing its taps. */ +/** + * Both embedded flows render in this shell, so the host asks whichever owns the + * step for its title and back target rather than keeping a second copy. + */ +const getEmbeddedBackTarget = (modal: DepositModal) => + isOrchestraModal(modal) ? getOrchestraBackTarget(modal) : getBuyCryptoBackTarget(modal); + +const getEmbeddedTitle = (modal: DepositModal) => + isOrchestraModal(modal) ? getOrchestraTitle(modal) : getBuyCryptoTitle(modal); + const HANDOFF_DELAY_MS = 260; const TITLE_ICON_STYLE = { width: 24, height: 24, borderRadius: 12 }; @@ -173,6 +186,10 @@ export default function CardDirectDepositModal({ [goToStep, handleOpenChange], ); const { handleBuyCryptoPress } = useBuyCryptoEntry(navigateBuyCrypto); + // Cash App reuses the embedded-flow machinery: its steps are DepositModal + // values too, and navigateBuyCrypto already maps CLOSE and OPEN_OPTIONS onto + // this modal's own actions. + const { openCashApp, isAvailable: isCashAppAvailable } = useOrchestraCardEntry(navigateBuyCrypto); useEffect( () => () => { @@ -328,7 +345,7 @@ export default function CardDirectDepositModal({ const handleBack = useCallback(() => { if (buyCryptoModal) { - const target = getBuyCryptoBackTarget(buyCryptoModal); + const target = getEmbeddedBackTarget(buyCryptoModal); if (target === 'entry') { goToStep('options'); } else if (target) { @@ -348,11 +365,11 @@ export default function CardDirectDepositModal({ const { current: step, previous: previousModal } = stepState; const canGoBack = buyCryptoModal - ? getBuyCryptoBackTarget(buyCryptoModal) !== null + ? getEmbeddedBackTarget(buyCryptoModal) !== null : step !== 'options'; const title = (() => { - if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal); if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -369,7 +386,11 @@ export default function CardDirectDepositModal({ const content = (() => { if (buyCryptoModal) { - return ; + return isOrchestraModal(buyCryptoModal) ? ( + + ) : ( + + ); } if (step === 'options') { return ( @@ -379,6 +400,7 @@ export default function CardDirectDepositModal({ onExternalWalletPress={handleConnectWallet} onUsdPress={handleUsdPress} onLocalCurrencyPress={handleLocalCurrencyPress} + onCashAppPress={isCashAppAvailable ? openCashApp : undefined} isExternalWalletLoading={isWalletOpen} /> ); diff --git a/components/Card/CardDirectDepositModalMobile.tsx b/components/Card/CardDirectDepositModalMobile.tsx index 9ccd0a20f..3f321a491 100644 --- a/components/Card/CardDirectDepositModalMobile.tsx +++ b/components/Card/CardDirectDepositModalMobile.tsx @@ -14,12 +14,14 @@ import { } from '@/components/Card/CardFund/constants'; import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; import VirtualAccountApplyDialog from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyDialog'; +import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { CARD_DEPOSIT_MODAL, DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry'; import { useCardStatus } from '@/hooks/useCardStatus'; import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; +import { useOrchestraCardEntry } from '@/hooks/useOrchestraCardEntry'; import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider'; import { track } from '@/lib/analytics'; import { createDirectDepositSession } from '@/lib/api'; @@ -28,6 +30,7 @@ import { getBuyCryptoTitle, getEmbeddedBuyCryptoTarget, } from '@/lib/buyCryptoFlow'; +import { getOrchestraBackTarget, getOrchestraTitle, isOrchestraModal } from '@/lib/orchestraFlow'; import { CardProvider, DepositModal, RainApplicationStatus } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { useCardDepositStore } from '@/store/useCardDepositStore'; @@ -51,6 +54,16 @@ const MODAL_STATES: Record = { * this one unmounts leaves the closing sheet's view on top of the new one, * swallowing its taps. */ +/** + * Both embedded flows render in this shell, so the host asks whichever owns the + * step for its title and back target rather than keeping a second copy. + */ +const getEmbeddedBackTarget = (modal: DepositModal) => + isOrchestraModal(modal) ? getOrchestraBackTarget(modal) : getBuyCryptoBackTarget(modal); + +const getEmbeddedTitle = (modal: DepositModal) => + isOrchestraModal(modal) ? getOrchestraTitle(modal) : getBuyCryptoTitle(modal); + const HANDOFF_DELAY_MS = 260; const TITLE_ICON_STYLE = { width: 24, height: 24, borderRadius: 12 }; @@ -151,6 +164,10 @@ export default function CardDirectDepositModalMobile({ [goToStep, handleOpenChange], ); const { handleBuyCryptoPress } = useBuyCryptoEntry(navigateBuyCrypto); + // Cash App reuses the embedded-flow machinery: its steps are DepositModal + // values too, and navigateBuyCrypto already maps CLOSE and OPEN_OPTIONS onto + // this modal's own actions. + const { openCashApp, isAvailable: isCashAppAvailable } = useOrchestraCardEntry(navigateBuyCrypto); useEffect( () => () => { @@ -260,7 +277,7 @@ export default function CardDirectDepositModalMobile({ const handleBack = useCallback(() => { if (buyCryptoModal) { - const target = getBuyCryptoBackTarget(buyCryptoModal); + const target = getEmbeddedBackTarget(buyCryptoModal); if (target === 'entry') { goToStep('options'); } else if (target) { @@ -276,11 +293,11 @@ export default function CardDirectDepositModalMobile({ const { current: step, previous: previousModal } = stepState; const canGoBack = buyCryptoModal - ? getBuyCryptoBackTarget(buyCryptoModal) !== null + ? getEmbeddedBackTarget(buyCryptoModal) !== null : step !== 'options'; const title = (() => { - if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal); if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -297,7 +314,11 @@ export default function CardDirectDepositModalMobile({ const content = (() => { if (buyCryptoModal) { - return ; + return isOrchestraModal(buyCryptoModal) ? ( + + ) : ( + + ); } if (step === 'options') { return ( @@ -307,6 +328,7 @@ export default function CardDirectDepositModalMobile({ onExternalWalletPress={handleExternalWallet} onUsdPress={handleUsdPress} onLocalCurrencyPress={handleLocalCurrencyPress} + onCashAppPress={isCashAppAvailable ? openCashApp : undefined} /> ); } diff --git a/components/Card/CardFund/CardFundOptions.tsx b/components/Card/CardFund/CardFundOptions.tsx index 007ed9d74..863b5fe74 100644 --- a/components/Card/CardFund/CardFundOptions.tsx +++ b/components/Card/CardFund/CardFundOptions.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { View } from 'react-native'; import { Image } from 'expo-image'; -import { CreditCard } from 'lucide-react-native'; +import { CreditCard, Zap } from 'lucide-react-native'; import FundExternalWallet from '@/assets/images/fund-external-wallet'; import FundMoveSavings from '@/assets/images/fund-move-savings'; @@ -42,6 +42,12 @@ type CardFundOptionsProps = { * opt in individually, as they do for the local-currency rows. */ onBuyCryptoPress?: () => void; + /** + * Opens the Cash App onramp, funding the card rather than the wallet. Omit + * to hide the row — the funding modals opt in individually, and the flow is + * US-only, so the host gates on availability before passing it. + */ + onCashAppPress?: () => void; isExternalWalletLoading?: boolean; /** * Which groups this issuer offers. Defaults to the full Rain set, so the @@ -63,6 +69,7 @@ const CardFundOptions = ({ onUsdPress, onLocalCurrencyPress, onBuyCryptoPress, + onCashAppPress, isExternalWalletLoading, sections = RAIN_CARD_FUND_SECTIONS, moveFromSolidCopy = CARD_FUND_MOVE_COPY.rain, @@ -147,6 +154,15 @@ const CardFundOptions = ({ onPress={onBuyCryptoPress} /> ) : null} + {onCashAppPress ? ( + } + title="Cash App" + subtitle="Pay from your Cash App balance" + chips={['Instant']} + onPress={onCashAppPress} + /> + ) : null} ) : null} diff --git a/components/Card/WirexCardFundModal.tsx b/components/Card/WirexCardFundModal.tsx index a8b760b23..72b08c0fb 100644 --- a/components/Card/WirexCardFundModal.tsx +++ b/components/Card/WirexCardFundModal.tsx @@ -18,10 +18,12 @@ import { WirexMoveAmount, WirexMoveHoldings, } from '@/components/Card/CardFund/WirexMoveFromWallet'; +import { OrchestraFlowContent } from '@/components/Orchestra/OrchestraFlow'; import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoEntry } from '@/hooks/useBuyCryptoEntry'; import { useCardProvider } from '@/hooks/useCardProvider'; +import { useOrchestraCardEntry } from '@/hooks/useOrchestraCardEntry'; import { track } from '@/lib/analytics'; import { createDirectDepositSession } from '@/lib/api'; import { @@ -29,6 +31,7 @@ import { getBuyCryptoTitle, getEmbeddedBuyCryptoTarget, } from '@/lib/buyCryptoFlow'; +import { getOrchestraBackTarget, getOrchestraTitle, isOrchestraModal } from '@/lib/orchestraFlow'; import { CardProvider, DepositModal } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { MovableHolding } from '@/lib/utils/cardFundMove'; @@ -36,6 +39,16 @@ import { useTransfiStore } from '@/store/useTransfiStore'; type Step = 'options' | 'networks' | 'address' | 'moveHoldings' | 'moveAmount'; +/** + * Both embedded flows render in this shell, so the host asks whichever owns the + * step for its title and back target rather than keeping a second copy. + */ +const getEmbeddedBackTarget = (modal: DepositModal) => + isOrchestraModal(modal) ? getOrchestraBackTarget(modal) : getBuyCryptoBackTarget(modal); + +const getEmbeddedTitle = (modal: DepositModal) => + isOrchestraModal(modal) ? getOrchestraTitle(modal) : getBuyCryptoTitle(modal); + const CLOSE_STATE: ModalState = { name: 'close', number: -1 }; const MODAL_STATES: Record = { @@ -165,6 +178,10 @@ export default function WirexCardFundModal({ [goToStep, handleOpenChange], ); const { handleBuyCryptoPress } = useBuyCryptoEntry(navigateBuyCrypto); + // Cash App reuses the embedded-flow machinery: its steps are DepositModal + // values too, and navigateBuyCrypto already maps CLOSE and OPEN_OPTIONS onto + // this modal's own actions. + const { openCashApp, isAvailable: isCashAppAvailable } = useOrchestraCardEntry(navigateBuyCrypto); const handleTokenPress = useCallback( (symbol: string) => { @@ -247,7 +264,7 @@ export default function WirexCardFundModal({ const handleBack = useCallback(() => { if (buyCryptoModal) { - const target = getBuyCryptoBackTarget(buyCryptoModal); + const target = getEmbeddedBackTarget(buyCryptoModal); if (target === 'entry') { goToStep('options'); } else if (target) { @@ -268,11 +285,11 @@ export default function WirexCardFundModal({ const { current: step, previous: previousModal } = stepState; const canGoBack = buyCryptoModal - ? getBuyCryptoBackTarget(buyCryptoModal) !== null + ? getEmbeddedBackTarget(buyCryptoModal) !== null : step !== 'options'; const title = (() => { - if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (buyCryptoModal) return getEmbeddedTitle(buyCryptoModal); if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; if (step === 'moveHoldings') return CARD_FUND_MOVE_COPY.wirex.title; @@ -291,7 +308,11 @@ export default function WirexCardFundModal({ const content = (() => { if (buyCryptoModal) { - return ; + return isOrchestraModal(buyCryptoModal) ? ( + + ) : ( + + ); } if (step === 'options') { return ( @@ -299,6 +320,7 @@ export default function WirexCardFundModal({ onTokenPress={handleTokenPress} onLocalCurrencyPress={handleLocalCurrencyPress} onMoveFromSavingsPress={handleMoveFromWalletPress} + onCashAppPress={isCashAppAvailable ? openCashApp : undefined} sections={WIREX_CARD_FUND_SECTIONS} moveFromSolidCopy={CARD_FUND_MOVE_COPY.wirex} /> 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/hooks/useOrchestra.ts b/hooks/useOrchestra.ts index 83e97e131..394c42a40 100644 --- a/hooks/useOrchestra.ts +++ b/hooks/useOrchestra.ts @@ -56,8 +56,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..ec83a0135 --- /dev/null +++ b/hooks/useOrchestraCardEntry.ts @@ -0,0 +1,46 @@ +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 { track } from '@/lib/analytics'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +/** + * Opening the Cash App onramp from a card funding modal. + * + * Shared by the three of them 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". + * + * `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 { countryCode, isResolving } = useCashAppDepositAvailability(); + const { data: config } = useOrchestraConfig(countryCode, !isResolving); + + 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: config?.isAvailable === true }; +}; + +export default useOrchestraCardEntry; 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/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/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 }), })); From 49d59d8a25a8eeedf516f00063383447a4b4af09 Mon Sep 17 00:00:00 2001 From: mul53 Date: Mon, 28 Sep 2026 08:14:14 +0200 Subject: [PATCH 03/11] feat(deposit): Apple Pay under USD, funding the card from card screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onramper's hosted widget is back, as an "Apple Pay" method under USD in both deposit flows. - Wallet: Cash → USD → "Deposit US Dollars" lists Wire transfer/ACH, Cash App where the server allows it, and Apple Pay. The bank rail and Apple Pay are offered everywhere, so USD now always opens the chooser instead of going straight to the bank rail outside the US. - Card (Rain mobile and desktop): USD opens the same list — Wire transfer/ACH and Apple Pay — as a step of the funding modal, and back from the widget returns to it. Wirex's funding screen has no USD section and is unchanged. - The rows live in one UsdMethodList shared by both flows, and Cash App availability in one useIsCashAppAvailable hook shared by the cash list's chips and the USD list. - The widget session now names its destination: the card funding modals ask for `card`, so the purchase lands on the card deposit address and arrives as card balance, while the wallet flow keeps `wallet`. Until the matching backend change deploys, the field is ignored and card-screen purchases keep landing in the wallet. Co-Authored-By: Claude Opus 5.5 --- .../OnramperWidget/OnramperWidget.native.tsx | 4 +- .../OnramperWidget/OnramperWidget.web.tsx | 4 +- .../OnramperWidget/OnramperWidgetStates.tsx | 8 ++ .../BuyCrypto/Transfi/BuyCryptoFlow.tsx | 5 +- .../__tests__/buyCryptoFlowContent.test.tsx | 53 ++++++++ .../Card/CardDirectDepositModalDesktop.tsx | 40 +++++- .../Card/CardDirectDepositModalMobile.tsx | 40 +++++- components/Card/CardFund/CardFundOptions.tsx | 4 +- .../DepositOption/DepositCashOptions.tsx | 123 ++++++++---------- .../DepositOption/DepositUsdOptions.tsx | 120 ++++++++++++----- .../__tests__/depositUsdOptions.test.tsx | 83 ++++++++++++ constants/modals.ts | 6 +- hooks/useDepositOption.tsx | 6 +- hooks/useOnramperWidget.ts | 14 +- hooks/useOrchestra.ts | 17 +++ lib/api.ts | 11 +- lib/buyCryptoFlow.ts | 3 +- 17 files changed, 401 insertions(+), 140 deletions(-) create mode 100644 components/BuyCrypto/Transfi/__tests__/buyCryptoFlowContent.test.tsx create mode 100644 components/DepositOption/__tests__/depositUsdOptions.test.tsx 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 405dac48c..5d8ff9a02 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 ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { Text } from '@/components/ui/text'; @@ -41,12 +42,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 }, @@ -181,9 +183,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, @@ -231,8 +237,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 { @@ -330,7 +345,10 @@ export default function CardDirectDepositModal({ if (buyCryptoModal) { const target = getBuyCryptoBackTarget(buyCryptoModal); if (target === 'entry') { - goToStep('options'); + // The widget is opened from the USD methods; the TransFi screens from + // the options themselves. + const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + goToStep(isWidget ? 'usdMethods' : 'options'); } else if (target) { navigateBuyCrypto(target); } @@ -353,6 +371,7 @@ export default function CardDirectDepositModal({ const title = (() => { if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (step === 'usdMethods') return 'Deposit US Dollars'; if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -384,6 +403,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 9ccd0a20f..5d13b87db 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 ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; import { CARD_DEPOSIT_MODAL, DEPOSIT_MODAL } from '@/constants/modals'; @@ -34,12 +35,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 }, @@ -159,9 +161,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, @@ -209,8 +215,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); @@ -262,7 +277,10 @@ export default function CardDirectDepositModalMobile({ if (buyCryptoModal) { const target = getBuyCryptoBackTarget(buyCryptoModal); if (target === 'entry') { - goToStep('options'); + // The widget is opened from the USD methods; the TransFi screens from + // the options themselves. + const isWidget = buyCryptoModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + goToStep(isWidget ? 'usdMethods' : 'options'); } else if (target) { navigateBuyCrypto(target); } @@ -281,6 +299,7 @@ export default function CardDirectDepositModalMobile({ const title = (() => { if (buyCryptoModal) return getBuyCryptoTitle(buyCryptoModal); + if (step === 'usdMethods') return 'Deposit US Dollars'; if (step === 'networks') return selectedToken; if (step === 'address') return `Deposit ${selectedToken}`; return 'Fund your card'; @@ -311,6 +330,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 007ed9d74..19770abc5 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 @@ -102,7 +102,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 83e97e131..7749b3eb7 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,22 @@ 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 and the USD methods screen, 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/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: From 109290d90f0ee468aca2f0b51977a034d21d6ef6 Mon Sep 17 00:00:00 2001 From: mul53 Date: Mon, 28 Sep 2026 22:45:13 +0200 Subject: [PATCH 04/11] feat(buy-crypto): send a KYC-limit refusal to TransFi's verification page On STANDARD_KYC_REQUIRED / ENHANCED_KYC_REQUIRED the error screen offered "Verify identity", which opened our own identity flow. The user is already verified there, so it bounced them back to the amount screen and into the same refusal - a loop with no way out. The screen now reads "Upgrade your verification" and asks the backend for TransFi's page for the level the refusal names (POST /transfi/kyc/upgrade), then opens it. When TransFi already has a submission it shows "Verification in review" instead, and it offers "Buy a smaller amount", since the limit only caps this purchase. Every other complete_kyc error behaves as before. Co-Authored-By: Claude Opus 5.5 --- components/BuyCrypto/Transfi/TransfiError.tsx | 185 ++++++++++++++-- .../__tests__/transfiErrorUpgrade.test.tsx | 204 ++++++++++++++++++ constants/tracking-events.ts | 9 + hooks/useTransfi.ts | 20 +- lib/__tests__/transfiErrors.test.ts | 39 ++++ lib/api.ts | 23 ++ lib/transfiErrors.ts | 24 +++ lib/types.ts | 15 ++ 8 files changed, 503 insertions(+), 16 deletions(-) create mode 100644 components/BuyCrypto/Transfi/__tests__/transfiErrorUpgrade.test.tsx diff --git a/components/BuyCrypto/Transfi/TransfiError.tsx b/components/BuyCrypto/Transfi/TransfiError.tsx index 0217c687f..9fcf01021 100644 --- a/components/BuyCrypto/Transfi/TransfiError.tsx +++ b/components/BuyCrypto/Transfi/TransfiError.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; -import { View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Platform, View } from 'react-native'; import { useRouter } from 'expo-router'; +import { openBrowserAsync } from 'expo-web-browser'; import { AlertTriangle, Clock, LifeBuoy, ShieldAlert, XCircle } from 'lucide-react-native'; import { useBuyCryptoNavigation } from '@/components/BuyCrypto/Transfi/BuyCryptoNavigation'; @@ -10,16 +11,23 @@ import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoKycRoute } from '@/hooks/useBuyCryptoKycRoute'; +import { useUpgradeTransfiKyc } from '@/hooks/useTransfi'; import { track } from '@/lib/analytics'; import { + asTransfiError, canCompleteProfile, + kycUpgradeLevel, type TransfiError as TransfiErrorType, transfiErrorTitle, } from '@/lib/transfiErrors'; +import { TransfiKycLevel } from '@/lib/types'; import { useTransfiStore } from '@/store/useTransfiStore'; const SUPPORT_EMAIL = 'support@solid.xyz'; +const UPGRADE_UNAVAILABLE_MESSAGE = + 'We couldn’t open the verification page. Please try again in a moment.'; + const ICON_BY_ACTION = { retry: AlertTriangle, adjust_amount: AlertTriangle, @@ -35,6 +43,13 @@ const formatFiat = (value: number | undefined, currency: string) => ? undefined : `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`; +/** + * Where a KYC upgrade stands once the user has asked for it: TransFi's page + * handed to them (or stopped by a popup blocker, so they have to open it + * themselves), or TransFi already reviewing a submission for that level. + */ +type UpgradeState = { state: 'opened' | 'blocked'; url: string } | { state: 'in_review' }; + /** * Terminal screen for a buy-crypto failure. * @@ -50,8 +65,12 @@ export const TransfiError = () => { const setModal = useBuyCryptoNavigation(); const error = useTransfiStore(state => state.error); const errorOrigin = useTransfiStore(state => state.errorOrigin); + const setError = useTransfiStore(state => state.setError); const reset = useTransfiStore(state => state.reset); const routeToKyc = useBuyCryptoKycRoute(); + const { mutateAsync: upgradeKyc, isPending: isUpgrading } = useUpgradeTransfiKyc(); + const [upgrade, setUpgrade] = useState(); + const [upgradeError, setUpgradeError] = useState(); useEffect(() => { if (!error) return; @@ -83,8 +102,67 @@ export const TransfiError = () => { ); } - const Icon = ICON_BY_ACTION[error.action]; - const primary = resolvePrimaryAction(error); + const upgradeLevel = kycUpgradeLevel(error); + const inReview = upgrade?.state === 'in_review'; + const Icon = inReview ? Clock : ICON_BY_ACTION[error.action]; + const primary = resolvePrimaryAction(error, upgrade); + + /** + * Hand TransFi's verification page to the browser rather than framing it — as + * with the hosted retry, the flow uses the camera and its own redirects. + */ + const openVerificationPage = async (url: string) => { + let opened = false; + if (Platform.OS === 'web') { + // A null handle means a popup blocker stopped it; the screen then leads + // with a button so the user can open it themselves. + opened = Boolean(window.open(url, '_blank', 'noopener,noreferrer')); + } else { + try { + await openBrowserAsync(url); + opened = true; + } catch (openError) { + console.error('Failed to open the TransFi verification page:', openError); + } + } + setUpgrade({ state: opened ? 'opened' : 'blocked', url }); + if (opened) track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_PAGE_OPENED, { code: error.code }); + }; + + /** + * Ask TransFi for its page for the level this refusal names. A refusal with a + * verdict of its own — an account TransFi won't serve, no profile to upgrade — + * replaces this screen's error; anything transient stays inline so the button + * can be pressed again. + */ + const startUpgrade = async (level: TransfiKycLevel) => { + setUpgradeError(undefined); + try { + const result = await upgradeKyc(level); + if (result.status === 'pending') { + track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_IN_REVIEW, { level: result.level }); + setUpgrade({ state: 'in_review' }); + return; + } + if (!result.kycUrl) { + setUpgradeError(UPGRADE_UNAVAILABLE_MESSAGE); + return; + } + await openVerificationPage(result.kycUrl); + } catch (upgradeFailure) { + const failure = asTransfiError(upgradeFailure); + track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_FAILED, { + code: failure.code, + action: failure.action, + }); + if (failure.action === 'retry') { + setUpgradeError(UPGRADE_UNAVAILABLE_MESSAGE); + return; + } + setUpgrade(undefined); + setError(failure, errorOrigin ?? undefined); + } + }; const handlePrimary = () => { if (!primary) return; @@ -111,22 +189,42 @@ export const TransfiError = () => { case 'kyc': void routeToKyc(); break; + case 'upgrade': + if (upgradeLevel) void startUpgrade(upgradeLevel); + break; + case 'reopen_upgrade': + if (upgrade && upgrade.state !== 'in_review') void openVerificationPage(upgrade.url); + break; } }; + // The limits only cap this purchase, so a smaller one still goes through + // while the next KYC level is outstanding. + const buySmallerAmount = () => { + track(TRACKING_EVENTS.BUY_CRYPTO_ERROR_ACTION_PRESSED, { + code: error.code, + choice: 'smaller_amount', + }); + setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT); + }; + const limits = describeLimits(error); + const title = inReview ? 'Verification in review' : transfiErrorTitle(error); + const message = upgradeMessage(upgrade) ?? error.message; + const hasSecondary = Boolean(upgradeLevel); return ( - + - - {transfiErrorTitle(error)} - - {error.message} + {title} + {message} + {upgradeError ? ( + {upgradeError} + ) : null} @@ -155,14 +253,44 @@ export const TransfiError = () => { {primary ? ( - + ) : null} + {hasSecondary ? ( + ) : null} -