From 7308fc11290ec46bd7b499d7bd1ea3d6d72d59a5 Mon Sep 17 00:00:00 2001 From: Eli Smargon Date: Wed, 23 Sep 2026 20:02:40 +0300 Subject: [PATCH 1/9] feat(rewards): refine tier upgrade and top-up flows --- .../images/rewards-tiers/upgrade-card-cap.svg | 5 + .../rewards-tiers/upgrade-card-star.svg | 3 + .../upgrade-card-subscription.svg | 6 + .../rewards-tiers/upgrade-card-yield.svg | 6 + components/Activity/CardActivityRow.tsx | 10 +- .../DepositOption/DepositTypeSelection.tsx | 36 ++++- .../__tests__/depositUpgradeTopUp.test.tsx | 116 +++++++++++++++ components/ResponsiveModal.tsx | 29 +++- .../Rewards/NewRewards/JoinTierClubCard.tsx | 110 +++++++------- .../NewRewards/RewardsBenefitsScreenNew.tsx | 36 +++-- .../Rewards/NewRewards/RewardsScreenNew.tsx | 23 +-- .../Rewards/NewRewards/RewardsSummaryCard.tsx | 88 +++++++++--- .../UpgradeTier/LockTokenSelector.tsx | 1 + .../NewRewards/UpgradeTier/TierDetailRow.tsx | 41 ++++-- .../UpgradeTier/TierUpgradeModalProvider.tsx | 1 + .../UpgradeTier/UpgradeTierCashbackIcon.tsx | 22 +++ .../UpgradeTier/UpgradeTierContent.tsx | 87 ++++++++---- .../UpgradeTier/UpgradeTierHeroCard.tsx | 120 +++++++++------- .../UpgradeTier/tierUpgradeBenefits.ts | 8 +- .../__tests__/tierUpgradeCta.test.ts | 24 +++- .../Rewards/NewRewards/tierUpgradeCta.ts | 35 ++++- components/Swap/BuyFuseScreen.tsx | 93 ++++++++---- components/Swap/BuyFuseUpgradeReview.tsx | 69 +++++++++ components/Swap/SwapModalProvider.tsx | 28 ++-- .../__tests__/BuyFuseScreen.upgrade.test.tsx | 134 ++++++++++++++++++ .../__tests__/BuyFuseUpgradeReview.test.tsx | 78 ++++++++++ .../__tests__/SwapModalProvider.ios.test.tsx | 92 ++++++++++++ components/Tooltip.tsx | 5 + .../WalletTokenSelector/WalletTokenButton.tsx | 17 ++- .../WalletTokenSelector/WalletTokenList.tsx | 22 +-- components/ui/dialog.tsx | 7 +- hooks/useRewards.ts | 7 +- store/swapStore.ts | 13 +- store/useDepositStore.ts | 7 + 34 files changed, 1091 insertions(+), 288 deletions(-) create mode 100644 assets/images/rewards-tiers/upgrade-card-cap.svg create mode 100644 assets/images/rewards-tiers/upgrade-card-star.svg create mode 100644 assets/images/rewards-tiers/upgrade-card-subscription.svg create mode 100644 assets/images/rewards-tiers/upgrade-card-yield.svg create mode 100644 components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx create mode 100644 components/Rewards/NewRewards/UpgradeTier/UpgradeTierCashbackIcon.tsx create mode 100644 components/Swap/BuyFuseUpgradeReview.tsx create mode 100644 components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx create mode 100644 components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx create mode 100644 components/Swap/__tests__/SwapModalProvider.ios.test.tsx diff --git a/assets/images/rewards-tiers/upgrade-card-cap.svg b/assets/images/rewards-tiers/upgrade-card-cap.svg new file mode 100644 index 000000000..937ba7161 --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-cap.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/images/rewards-tiers/upgrade-card-star.svg b/assets/images/rewards-tiers/upgrade-card-star.svg new file mode 100644 index 000000000..34530fb6c --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/rewards-tiers/upgrade-card-subscription.svg b/assets/images/rewards-tiers/upgrade-card-subscription.svg new file mode 100644 index 000000000..db7832187 --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-subscription.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/images/rewards-tiers/upgrade-card-yield.svg b/assets/images/rewards-tiers/upgrade-card-yield.svg new file mode 100644 index 000000000..f96e35f0d --- /dev/null +++ b/assets/images/rewards-tiers/upgrade-card-yield.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/components/Activity/CardActivityRow.tsx b/components/Activity/CardActivityRow.tsx index 0880404fe..e297ee2e7 100644 --- a/components/Activity/CardActivityRow.tsx +++ b/components/Activity/CardActivityRow.tsx @@ -71,11 +71,8 @@ const CardActivityRow = ({ const formattedTimestamp = formatActivityTimestamp(getCardTransactionTimestamp(transaction)); - const cashbackLabel = cashbackInfo?.isEscrowed - ? 'Cashback (Escrowed)' - : cashbackInfo?.isPending - ? 'Cashback (Pending)' - : 'Cashback'; + const cashbackLabel = + cashbackInfo?.isPending && !cashbackInfo.isEscrowed ? 'Cashback (Pending)' : 'Cashback'; return ( {usdEquivalent && {usdEquivalent}} {/* Green whether or not the payout has landed: it is money coming back - either way, and the label on the left already carries the escrow - status, so the figure does not repeat it. */} + either way. The receipt shows when escrowed cashback releases. */} {cashbackInfo?.amount && ( {cashbackInfo.amount} )} diff --git a/components/DepositOption/DepositTypeSelection.tsx b/components/DepositOption/DepositTypeSelection.tsx index ff3f819f9..c984c4a90 100644 --- a/components/DepositOption/DepositTypeSelection.tsx +++ b/components/DepositOption/DepositTypeSelection.tsx @@ -6,9 +6,11 @@ import { Text } from '@/components/ui/text'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useDimension } from '@/hooks/useDimension'; +import { useSavingsFundFlow } from '@/hooks/useSavingsFundFlow'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; import { getCryptoDepositEntry } from '@/lib/walletDepositFlow'; +import { useSwapState } from '@/store/swapStore'; import { useDepositStore } from '@/store/useDepositStore'; import { DEPOSIT_CASH_CLUSTER_ICONS, DEPOSIT_CASH_CURRENCY_COUNT } from './DepositCashOptions'; @@ -27,18 +29,25 @@ type DepositTypeSelectionProps = { }; /** - * "Deposit with" — the first step of the wallet deposit flow, and only a fork: - * crypto sent to the deposit address, or cash through a bank or local ramp. - * Each branch owns the methods underneath it (see `DepositCryptoOptions` and - * `DepositCashOptions`), which is what keeps this short enough to be a drawer on - * a phone rather than the full-height sheet the later steps use. + * "Deposit with" — the first step of the wallet deposit flow. The upgrade + * Top up entry also offers Buy FUSE, while ordinary deposits keep the two + * funding methods: crypto or cash. */ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { const { isDesktop, isScreenMedium } = useDimension(); const setModal = useDepositStore(state => state.setModal); + const resetDepositFlow = useDepositStore(state => state.resetDepositFlow); + const upgradeTopUp = useDepositStore(state => state.upgradeTopUp); + const { selectToken: selectSavingsFundToken } = useSavingsFundFlow(); + const openBuyFuse = useSwapState(state => state.actions.openBuyFuse); const handleCryptoPress = () => { track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'crypto' }); + if (upgradeTopUp?.depositToSavings) { + useDepositStore.getState().setSavingsFundIntent('savings'); + selectSavingsFundToken('WFUSE'); + return; + } setModal(getCryptoDepositEntry(isDesktop)); }; @@ -47,6 +56,15 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); }; + const handleBuyFusePress = () => { + if (!upgradeTopUp) return; + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { deposit_method: 'buy_fuse' }); + const { tier, depositToSavings } = upgradeTopUp; + resetDepositFlow(); + // The deposit drawer must finish leaving before the swap dialog mounts. + setTimeout(() => openBuyFuse(tier, { depositToSavings }), 200); + }; + return ( {/* The drawer carries its own header: a grab handle (small screens, where it @@ -77,6 +95,14 @@ const DepositTypeSelection = ({ onClose }: DepositTypeSelectionProps) => { subtitle="Transfer from your bank account or with local ramps" onPress={handleCashPress} /> + {upgradeTopUp ? ( + } + title="Buy FUSE" + subtitle="Buy with USDC on Fuse" + onPress={handleBuyFusePress} + /> + ) : null} + + + + ); +} diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index 4650028a5..c2e6b95bc 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -40,15 +40,17 @@ const SwapModalProvider = () => { })), ); - const { currentModal, previousModal, transaction, buyFuseTier, setModal } = useSwapState( - useShallow(state => ({ - currentModal: state.currentModal ?? SWAP_MODAL.CLOSE, - previousModal: state.previousModal ?? SWAP_MODAL.CLOSE, - transaction: state.transaction, - buyFuseTier: state.buyFuseTier, - setModal: state.actions.setModal, - })), - ); + const { currentModal, previousModal, transaction, buyFuseTier, buyFuseUpgrade, setModal } = + useSwapState( + useShallow(state => ({ + currentModal: state.currentModal ?? SWAP_MODAL.CLOSE, + previousModal: state.previousModal ?? SWAP_MODAL.CLOSE, + transaction: state.transaction, + buyFuseTier: state.buyFuseTier, + buyFuseUpgrade: state.buyFuseUpgrade, + setModal: state.actions.setModal, + })), + ); const isTransactionStatus = currentModal.name === SWAP_MODAL.OPEN_TRANSACTION_STATUS.name; const isBuyFuse = currentModal.name === SWAP_MODAL.OPEN_BUY_FUSE.name; @@ -102,7 +104,7 @@ const SwapModalProvider = () => { } if (isBuyFuse) { - return ; + return ; } if (isTransactionStatus) { @@ -141,13 +143,15 @@ const SwapModalProvider = () => { handleAcceptSwapDisclaimer, isBuyFuse, buyFuseTier, + buyFuseUpgrade, isTransactionStatus, transaction, handleTransactionStatusPress, ]); - // Swap is not available on iOS — never render the swap modal there. - if (Platform.OS === 'ios') { + // Regular Swap remains unavailable on iOS. Buy FUSE can open through its + // dedicated entry points and still passes the geo and disclaimer gates. + if (Platform.OS === 'ios' && !isBuyFuse) { return null; } diff --git a/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx new file mode 100644 index 000000000..0ea617125 --- /dev/null +++ b/components/Swap/__tests__/BuyFuseScreen.upgrade.test.tsx @@ -0,0 +1,134 @@ +import React from 'react'; + +import BuyFuseScreen from '@/components/Swap/BuyFuseScreen'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@cryptoalgebra/fuse-sdk', () => ({ + ADDRESS_ZERO: '0x0000000000000000000000000000000000000000', +})); +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ bottom: 0 }), +})); +jest.mock('@/assets/images/messages', () => 'MessageCircle'); +jest.mock('@/components/Max', () => 'Max'); +jest.mock('@/components/RenderTokenIcon', () => 'RenderTokenIcon'); +jest.mock('@/components/Swap/BuyFuseSavingsReview', () => 'BuyFuseSavingsReview'); +jest.mock('@/components/Swap/BuyFuseTierCard', () => 'BuyFuseTierCard'); +jest.mock('@/components/Swap/BuyFuseUpgradeReview', () => 'BuyFuseUpgradeReview'); +jest.mock('@/components/Swap/SwapButton', () => 'SwapButton'); +jest.mock('@/components/Swap/SwapParams', () => 'SwapParams'); +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/constants/tokens', () => ({ STABLECOINS_TOKENS: { USDC: { address: '0x1234' } } })); +jest.mock('@/constants/vaults', () => ({ VAULTS: [{ name: 'FUSE', minimumAmount: '5000' }] })); +jest.mock('@/hooks/useRewards', () => ({ + useRewardsUserData: (options: unknown) => mockUseRewardsUserData(options), +})); +jest.mock('@/hooks/useUSDCValue', () => ({ useUSDCValue: () => ({ formatted: 0 }) })); +jest.mock('@/lib/buyFuseFunding', () => ({ formatBuyFuseFundingBalance: () => '0' })); +jest.mock('@/lib/buyFuseTiers', () => ({ + getBuyFuseProgress: jest.fn(), + getBuyFuseTierForAmount: jest.fn(), + getBuyFuseTierTargets: jest.fn(() => []), + getNextBuyFuseTier: jest.fn(), + hasReachedFuseTarget: jest.fn(), +})); +jest.mock('@/lib/getTokenIcon', () => ({ + __esModule: true, + default: () => ({ type: 'image', source: 'fuse' }), +})); +jest.mock('@/lib/utils', () => ({ formatUSD: () => '$0' })); +jest.mock('@/store/swapStore', () => ({ + useSwapState: (selector: (state: any) => unknown) => selector(mockSwapState), + useSwapActionHandlers: () => ({ onUserInput: jest.fn() }), + useDerivedSwapInfo: () => ({ + currencyBalances: { INPUT: undefined }, + parsedAmount: undefined, + toggledTrade: undefined, + voltageTrade: { trade: undefined }, + isVoltageTrade: false, + isVoltageTradeLoading: false, + tradeState: { state: 'NO_ROUTE_FOUND' }, + }), +})); +jest.mock('@/store/useRewardsUpgradeStore', () => ({ + useRewardsUpgradeStore: (selector: (state: any) => unknown) => + selector({ confirmed: undefined, pendingUntil: undefined, savingsConfirmed: false }), +})); +jest.mock('@/store/useSupportDrawerStore', () => ({ openSupportDrawer: jest.fn() })); +jest.mock('@/store/useUserStore', () => ({ + useUserStore: (selector: (state: any) => unknown) => + selector({ users: [{ selected: true, userId: 'test-user' }] }), +})); + +const mockUseRewardsUserData = jest.fn((_options?: unknown) => ({ + data: undefined, + isError: false, +})); +const mockSwapState = { + independentField: 'OUTPUT', + typedValue: '', + actions: { + selectCurrency: jest.fn(), + typeInput: jest.fn(), + resetForm: jest.fn(), + setModal: jest.fn(), + }, +}; + +it('shows the upgrade purchase without legacy tier loading or Savings instructions', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(mockUseRewardsUserData).toHaveBeenCalledWith({ enabled: false }); + expect(root.root.findAllByType('BuyFuseTierCard')).toHaveLength(0); + expect(root.root.findByType('SwapButton').props.disabled).toBe(false); + expect(JSON.stringify(root.toJSON())).not.toContain('Checking available tier upgrades'); + expect(JSON.stringify(root.toJSON())).not.toContain('Savings minimum deposit'); + + act(() => root.root.findByType('SwapButton').props.onConfirmed()); + expect(root.root.findAllByType('BuyFuseUpgradeReview')).toHaveLength(1); + act(() => root.unmount()); +}); + +it('keeps the legacy tier guidance for non-upgrade Buy FUSE entry points', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + mockUseRewardsUserData.mockClear(); + let root: any; + act(() => { + root = create(); + }); + + expect(mockUseRewardsUserData).toHaveBeenCalledWith({ enabled: true }); + expect(JSON.stringify(root.toJSON())).toContain('Checking available tier upgrades'); + expect(JSON.stringify(root.toJSON())).toContain('Savings minimum deposit'); + expect(root.root.findByType('SwapButton').props.disabled).toBe(true); + act(() => root.unmount()); +}); + +it('shows the Savings minimum only when the upgrade needs soFUSE', () => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + let root: any; + act(() => { + root = create( + , + ); + }); + + expect(JSON.stringify(root.toJSON())).toContain('Savings minimum deposit'); + expect(JSON.stringify(root.toJSON())).not.toContain('Checking available tier upgrades'); + act(() => root.unmount()); +}); diff --git a/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx b/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx new file mode 100644 index 000000000..3e90cfca2 --- /dev/null +++ b/components/Swap/__tests__/BuyFuseUpgradeReview.test.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +import BuyFuseUpgradeReview from '@/components/Swap/BuyFuseUpgradeReview'; +import { DEPOSIT_MODAL, SWAP_MODAL } from '@/constants/modals'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('@/components/ui/button', () => ({ Button: 'Button' })); +jest.mock('@/components/ui/text', () => ({ Text: 'Text' })); +jest.mock('@/constants/vaults', () => ({ VAULTS: [{ name: 'USDC' }, { name: 'FUSE' }] })); +jest.mock('@/store/swapStore', () => ({ + useSwapState: { getState: () => ({ actions: { setModal: mockCloseSwap } }) }, +})); +jest.mock('@/store/useDepositStore', () => ({ useDepositStore: { getState: () => mockDeposit } })); +jest.mock('@/store/useSavingStore', () => ({ + useSavingStore: { getState: () => ({ selectVaultForDeposit: mockSelectVault }) }, +})); +jest.mock('@/store/useTierUpgradeStore', () => ({ + useTierUpgradeStore: { getState: () => mockUpgrade }, +})); + +const mockCloseSwap = jest.fn(); +const mockSelectVault = jest.fn(); +const mockDeposit = { + resetDepositFlow: jest.fn(), + setSrcChainId: jest.fn(), + setPrincipalToken: jest.fn(), + setDepositFromSolid: jest.fn(), + setModal: jest.fn(), +}; +const mockUpgrade = { + open: jest.fn(), + setRoute: jest.fn(), + setLockAsset: jest.fn(), +}; + +const render = (depositToSavings: boolean) => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + jest.useFakeTimers(); + jest.clearAllMocks(); +}); + +afterEach(() => jest.useRealTimers()); + +it('returns a native FUSE purchase to the lock upgrade', () => { + const root = render(false); + act(() => root.root.findAllByType('Button')[0].props.onPress()); + expect(mockCloseSwap).toHaveBeenCalledWith(SWAP_MODAL.CLOSE); + expect(mockUpgrade.open).not.toHaveBeenCalled(); + act(() => jest.advanceTimersByTime(200)); + expect(mockUpgrade.open).toHaveBeenCalledWith(RewardsTier.ULTRA); + expect(mockUpgrade.setRoute).toHaveBeenCalledWith('lock'); + expect(mockUpgrade.setLockAsset).toHaveBeenCalledWith('FUSE'); + expect(mockDeposit.setModal).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); + +it('opens Savings funding only when the chosen upgrade asset was soFUSE', () => { + const root = render(true); + act(() => root.root.findAllByType('Button')[0].props.onPress()); + expect(mockCloseSwap).toHaveBeenCalledWith(SWAP_MODAL.CLOSE); + act(() => jest.advanceTimersByTime(200)); + expect(mockSelectVault).toHaveBeenCalledWith(1); + expect(mockDeposit.setPrincipalToken).toHaveBeenCalledWith('FUSE'); + expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_FORM); + expect(mockUpgrade.open).not.toHaveBeenCalled(); + act(() => root.unmount()); +}); diff --git a/components/Swap/__tests__/SwapModalProvider.ios.test.tsx b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx new file mode 100644 index 000000000..f6ce11ad3 --- /dev/null +++ b/components/Swap/__tests__/SwapModalProvider.ios.test.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { Platform } from 'react-native'; + +import SwapModalProvider from '@/components/Swap/SwapModalProvider'; +import { SWAP_MODAL } from '@/constants/modals'; +import { RewardsTier } from '@/lib/types'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +jest.mock('expo-router', () => ({ useRouter: () => ({ push: jest.fn() }) })); +jest.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })); +jest.mock('@/components/Compliance/ExchangeDisclaimer', () => 'ExchangeDisclaimer'); +jest.mock('@/components/Compliance/GeoRestrictionNotice', () => 'GeoRestrictionNotice'); +jest.mock('@/components/NeedHelp', () => 'NeedHelp'); +jest.mock('@/components/ResponsiveModal', () => 'ResponsiveModal'); +jest.mock('@/components/Swap/BuyFuseScreen', () => 'BuyFuseScreen'); +jest.mock('@/components/Swap/SwapButton', () => 'SwapButton'); +jest.mock('@/components/Swap/SwapPair', () => 'SwapPair'); +jest.mock('@/components/Swap/SwapParams', () => 'SwapParams'); +jest.mock('@/components/TransactionStatus', () => 'TransactionStatus'); +jest.mock('@/hooks/useGeoCompliance', () => ({ + __esModule: true, + default: () => ({ isSwapAvailable: mockSwapAvailable }), +})); +jest.mock('@/lib/analytics', () => ({ track: jest.fn() })); +jest.mock('@/lib/getTokenIcon', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('@/store/useComplianceStore', () => ({ + useComplianceStore: (selector: (state: any) => unknown) => + selector({ hasAcceptedDisclaimer: () => true, acceptDisclaimer: jest.fn() }), +})); +jest.mock('@/store/swapStore', () => ({ + useSwapState: (selector: (state: any) => unknown) => + selector({ + currentModal: mockCurrentModal, + previousModal: { name: 'close', number: 0 }, + transaction: {}, + buyFuseTier: undefined, + buyFuseUpgrade: mockBuyFuseUpgrade, + actions: { setModal: jest.fn() }, + }), +})); + +let mockCurrentModal = SWAP_MODAL.CLOSE; +let mockSwapAvailable = true; +let mockBuyFuseUpgrade: { tier: RewardsTier; depositToSavings: boolean } | undefined; +const originalPlatform = Platform.OS; + +const render = () => { + let root: any; + act(() => { + root = create(); + }); + return root; +}; + +beforeEach(() => { + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + Platform.OS = 'ios'; + mockSwapAvailable = true; + mockCurrentModal = SWAP_MODAL.CLOSE; + mockBuyFuseUpgrade = undefined; +}); + +afterEach(() => { + Platform.OS = originalPlatform; +}); + +it('renders Buy FUSE on iOS', () => { + mockCurrentModal = SWAP_MODAL.OPEN_BUY_FUSE; + mockBuyFuseUpgrade = { tier: RewardsTier.ULTRA, depositToSavings: false }; + const root = render(); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(1); + expect(root.root.findByType('BuyFuseScreen').props.upgradeContext).toEqual(mockBuyFuseUpgrade); + act(() => root.unmount()); +}); + +it('keeps the regular Swap modal hidden on iOS', () => { + mockCurrentModal = SWAP_MODAL.OPEN_FORM; + const root = render(); + expect(root.root.findAllByType('ResponsiveModal')).toHaveLength(0); + act(() => root.unmount()); +}); + +it('shows the restriction notice when Buy FUSE is unavailable in the current country', () => { + mockCurrentModal = SWAP_MODAL.OPEN_BUY_FUSE; + mockSwapAvailable = false; + const root = render(); + expect(root.root.findAllByType('GeoRestrictionNotice')).toHaveLength(1); + expect(root.root.findAllByType('BuyFuseScreen')).toHaveLength(0); + act(() => root.unmount()); +}); diff --git a/components/Tooltip.tsx b/components/Tooltip.tsx index 4c906a393..ca8c9aad1 100644 --- a/components/Tooltip.tsx +++ b/components/Tooltip.tsx @@ -21,6 +21,7 @@ interface TooltipProps { side?: 'top' | 'right' | 'bottom' | 'left'; sideOffset?: number; analyticsContext?: string; + accessibilityLabel?: string; classNames?: TooltipClassNames; } @@ -31,6 +32,7 @@ const TooltipPopover = ({ side = 'bottom', sideOffset = 4, analyticsContext, + accessibilityLabel, classNames, }: TooltipProps) => { const insets = useSafeAreaInsets(); @@ -44,6 +46,9 @@ const TooltipPopover = ({ const getTrigger = () => { return ( { track(TRACKING_EVENTS.TOOLTIP_OPENED, { context: analyticsContext || 'unknown', diff --git a/components/WalletTokenSelector/WalletTokenButton.tsx b/components/WalletTokenSelector/WalletTokenButton.tsx index 7ba048364..47bb40429 100644 --- a/components/WalletTokenSelector/WalletTokenButton.tsx +++ b/components/WalletTokenSelector/WalletTokenButton.tsx @@ -13,6 +13,9 @@ interface WalletTokenButtonProps { onPress: () => void; /** When true the button is static (no chevron, not pressable). */ disabled?: boolean; + /** Hide the chain name under the token ticker. */ + showChainName?: boolean; + tickerFontSize?: 16 | 18; } /** @@ -25,6 +28,8 @@ const WalletTokenButton = ({ selectedToken, onPress, disabled = false, + showChainName = true, + tickerFontSize = 18, }: WalletTokenButtonProps) => { return ( - {selectedToken.contractTickerSymbol} - - {getBridgeChain(selectedToken.chainId)?.name} + + {selectedToken.contractTickerSymbol} + {showChainName && ( + + {getBridgeChain(selectedToken.chainId)?.name} + + )} {!disabled && } diff --git a/components/WalletTokenSelector/WalletTokenList.tsx b/components/WalletTokenSelector/WalletTokenList.tsx index a4374e6f2..e5f99e472 100644 --- a/components/WalletTokenSelector/WalletTokenList.tsx +++ b/components/WalletTokenSelector/WalletTokenList.tsx @@ -15,6 +15,7 @@ interface WalletTokenListProps { onSelect: (token: TokenBalance) => void; emptyMessage?: string; emptyDescription?: string; + showChainLabel?: boolean; } /** @@ -27,6 +28,7 @@ const WalletTokenList: React.FC = ({ onSelect, emptyMessage = 'No tokens found', emptyDescription, + showChainLabel = true, }) => { // Sort by USD value descending const sortedTokens = useMemo(() => { @@ -54,14 +56,14 @@ const WalletTokenList: React.FC = ({ {sortedTokens.map(token => { - const balance = Number( - formatUnits(BigInt(token.balance || '0'), token.contractDecimals), - ); + const balance = Number(formatUnits(BigInt(token.balance || '0'), token.contractDecimals)); const balanceUSD = balance * (token.quoteRate || 0); const isSelected = selectedToken?.contractAddress === token.contractAddress && selectedToken?.chainId === token.chainId; - const chainName = getBridgeChain(token.chainId)?.name || `Chain ${token.chainId}`; + const chainName = showChainLabel + ? getBridgeChain(token.chainId)?.name || `Chain ${token.chainId}` + : null; return ( = ({ /> {token.contractTickerSymbol} - - {token.contractTickerSymbol} on {chainName} - + {showChainLabel && ( + + {token.contractTickerSymbol} on {chainName} + + )} ${formatNumber(balanceUSD, 2)} - - {formatNumber(balance, 2)} - + {formatNumber(balance, 2)} ); diff --git a/components/ui/dialog.tsx b/components/ui/dialog.tsx index 4e36ac2e0..b30339e6d 100644 --- a/components/ui/dialog.tsx +++ b/components/ui/dialog.tsx @@ -290,7 +290,12 @@ const DialogContent = React.forwardRef< closeOnPress={isNativeBottomSheet} /> { +export const useRewardsUserData = (options?: { + refetchInterval?: number | false; + enabled?: boolean; +}) => { const userId = useSelectedUserId(); const hasConfirmedTier = useRewardsUpgradeStore( state => state.userId === userId && !!state.confirmed, @@ -73,7 +76,7 @@ export const useRewardsUserData = (options?: { refetchInterval?: number | false useRewardsUpgradeStore.getState().observe(userId!, session, data); return data; }, - enabled: !!userId, + enabled: !!userId && options?.enabled !== false, refetchInterval: options?.refetchInterval ?? false, // A cached response from an earlier account session cannot establish this // session's baseline. Switching back must fetch even within staleTime. diff --git a/store/swapStore.ts b/store/swapStore.ts index f65dbf1a8..e042d4319 100644 --- a/store/swapStore.ts +++ b/store/swapStore.ts @@ -46,6 +46,7 @@ interface SwapState { readonly currentModal: SwapModal; readonly previousModal: SwapModal; readonly buyFuseTier: RewardsTier | undefined; + readonly buyFuseUpgrade: BuyFuseUpgradeContext | undefined; readonly transaction: TransactionStatusModal & { inputCurrencySymbol?: string; outputCurrencySymbol?: string; @@ -58,11 +59,16 @@ interface SwapState { typeInput: (field: SwapFieldType, typedValue: string) => void; resetForm: () => void; setModal: (modal: SwapModal) => void; - openBuyFuse: (tier?: RewardsTier) => void; + openBuyFuse: (tier?: RewardsTier, upgrade?: { depositToSavings: boolean }) => void; setTransaction: (transaction: SwapState['transaction']) => void; }; } +export interface BuyFuseUpgradeContext { + tier: RewardsTier; + depositToSavings: boolean; +} + export const useSwapState = create((set, get) => ({ independentField: SwapField.INPUT, typedValue: '', @@ -77,6 +83,7 @@ export const useSwapState = create((set, get) => ({ currentModal: SWAP_MODAL.CLOSE, previousModal: SWAP_MODAL.CLOSE, buyFuseTier: undefined, + buyFuseUpgrade: undefined, transaction: {}, actions: { selectCurrency: (field, currencyId) => { @@ -126,12 +133,14 @@ export const useSwapState = create((set, get) => ({ set({ previousModal: get().currentModal, currentModal: modal, + ...(modal.name === SWAP_MODAL.CLOSE.name ? { buyFuseUpgrade: undefined } : {}), }), - openBuyFuse: tier => + openBuyFuse: (tier, upgrade) => set({ previousModal: get().currentModal, currentModal: SWAP_MODAL.OPEN_BUY_FUSE, buyFuseTier: tier, + buyFuseUpgrade: tier && upgrade ? { tier, ...upgrade } : undefined, }), setTransaction: transaction => set({ transaction }), }, diff --git a/store/useDepositStore.ts b/store/useDepositStore.ts index c411c336c..dea86e94b 100644 --- a/store/useDepositStore.ts +++ b/store/useDepositStore.ts @@ -11,6 +11,7 @@ import { USER } from '@/lib/config'; import mmkvStorage from '@/lib/mmvkStorage'; import { DepositModal, + RewardsTier, SavingsFundIntent, SourceDepositInstructions, TransactionStatusModal, @@ -119,6 +120,8 @@ interface DepositState { * one flow and is cleared by `resetDepositFlow`. */ savingsFundIntent: SavingsFundIntent; + /** Only set while the deposit chooser was opened by an upgrade Top up action. */ + upgradeTopUp?: { tier: RewardsTier; depositToSavings: boolean }; /** * Chain and currency chosen for the wallet deposit address ("Show deposit * address" -> "Select chain" -> the address). @@ -134,6 +137,7 @@ interface DepositState { setExternalWallet: (data: ExternalWalletState) => void; setDepositFromSolid: (v: boolean) => void; setSavingsFundIntent: (intent: SavingsFundIntent) => void; + setUpgradeTopUp: (context: DepositState['upgradeTopUp']) => void; setModal: (modal: DepositModal) => void; setTransaction: (transaction: TransactionStatusModal) => void; setBankTransferData: (data: Partial) => void; @@ -164,6 +168,7 @@ export const useDepositStore = create()( sessionStartTime: undefined, depositFromSolid: false, savingsFundIntent: 'savings', + upgradeTopUp: undefined, walletDeposit: {}, externalWallet: { address: undefined, @@ -175,6 +180,7 @@ export const useDepositStore = create()( setExternalWallet: data => set({ externalWallet: data }), setDepositFromSolid: (v: boolean) => set({ depositFromSolid: v }), setSavingsFundIntent: (intent: SavingsFundIntent) => set({ savingsFundIntent: intent }), + setUpgradeTopUp: context => set({ upgradeTopUp: context }), setModal: modal => { const isClose = modal.name === DEPOSIT_MODAL.CLOSE.name; @@ -209,6 +215,7 @@ export const useDepositStore = create()( sessionStartTime: undefined, depositFromSolid: false, savingsFundIntent: 'savings', + upgradeTopUp: undefined, walletDeposit: {}, }), }), From 24383b23e7bd2caeae85665a364237f8bed20b41 Mon Sep 17 00:00:00 2001 From: Eli Smargon Date: Wed, 23 Sep 2026 20:14:38 +0300 Subject: [PATCH 2/9] feat(rewards): show USD value for tier lock amount --- .../NewRewards/UpgradeTier/UpgradeTierContent.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx index d46af94d4..813f30577 100644 --- a/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx +++ b/components/Rewards/NewRewards/UpgradeTier/UpgradeTierContent.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo } from 'react'; import { Linking, Pressable, View } from 'react-native'; import { formatUnits } from 'viem'; +import { fuse } from 'viem/chains'; import Loading from '@/components/Loading'; import { Button } from '@/components/ui/button'; @@ -9,6 +10,7 @@ import { WalletTokenButton } from '@/components/WalletTokenSelector'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useMaxAPY } from '@/hooks/useAnalytics'; +import { useNativePriceUsd } from '@/hooks/useNativePriceUsd'; import { useTierBenefits } from '@/hooks/useRewards'; import { useTierMembership, useTierUpgradeChainState } from '@/hooks/useTierMembership'; import { track } from '@/lib/analytics'; @@ -70,6 +72,7 @@ const UpgradeTierContent = () => { const { maxAPY: fuseSavingsApy } = useMaxAPY(VaultType.FUSE); const requestedTier = useTierUpgradeStore(state => state.tier); const route = useTierUpgradeStore(state => state.route); + const fusePriceUsd = useNativePriceUsd(fuse.id, 'fusePriceUsd', route === 'lock'); const setRoute = useTierUpgradeStore(state => state.setRoute); const review = useTierUpgradeStore(state => state.review); const selectToken = useTierUpgradeStore(state => state.selectToken); @@ -270,7 +273,14 @@ const UpgradeTierContent = () => { tickerFontSize={16} /> - + 0 ? formatUsd(remainingFuse * fusePriceUsd) : undefined + } + withDivider + /> 0 ? `${fuseSavingsApy.toFixed(1)}%` : '—'} From ea32c606f2671649d56adcc4c5687d2f9a2eb9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 11:48:14 +0000 Subject: [PATCH 3/9] test(deposit): expect the token step from Crypto, as on qa qa now opens "Select token" first from Crypto and has deleted the "Select chain" step (a1e3912, 6ee35e0), so DEPOSIT_MODAL.OPEN_DEPOSIT_CHAIN no longer exists. The chooser test asserted that an ordinary Crypto press opens it, which failed once qa was merged. The soFUSE top-up test's negative check against it passed vacuously, since it compared against undefined. Both now name the step Crypto opens on a phone. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01E6iamcwoqjwq77x9kDCPzR --- .../DepositOption/__tests__/depositUpgradeTopUp.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx b/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx index 3752f1b82..0ac59c363 100644 --- a/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx +++ b/components/DepositOption/__tests__/depositUpgradeTopUp.test.tsx @@ -71,7 +71,7 @@ it('keeps Buy FUSE out of the ordinary deposit chooser', () => { const rows = root.root.findAllByType('DepositMethodRow'); expect(rows.map((row: any) => row.props.title)).toEqual(['Crypto', 'Cash']); act(() => rows[0].props.onPress()); - expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_CHAIN); + expect(mockDeposit.setModal).toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_TOKEN); act(() => root.unmount()); }); @@ -111,6 +111,6 @@ it('preserves the existing Savings deposit route for Crypto from a soFUSE top up act(() => root.root.findAllByType('DepositMethodRow')[0].props.onPress()); expect(mockDeposit.setSavingsFundIntent).toHaveBeenCalledWith('savings'); expect(mockSelectSavingsToken).toHaveBeenCalledWith('WFUSE'); - expect(mockDeposit.setModal).not.toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_CHAIN); + expect(mockDeposit.setModal).not.toHaveBeenCalledWith(DEPOSIT_MODAL.OPEN_DEPOSIT_TOKEN); act(() => root.unmount()); }); From 70e14e1257817bedc5e00824977d0ba4d51fc277 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 12:21:31 +0000 Subject: [PATCH 4/9] Carry the legacy skip-the-line flag through to the app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backend now reports `legacyFuseOnly` on the membership state: whether the user's tier rests on FUSE sitting in Savings and nothing else. That route is being retired, and when it is switched off everyone it was carrying drops to Core in the same instant. The type only, so the app can be given a migration prompt without another backend round trip. False for anyone who has also locked or subscribed — nothing about the change can reach them, so there is nothing to warn them about. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XGzkz4QFpEZVg6GpNH311x --- lib/__tests__/tierUpgrade.test.ts | 2 ++ lib/types.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/lib/__tests__/tierUpgrade.test.ts b/lib/__tests__/tierUpgrade.test.ts index 06e02b573..39c93b5ba 100644 --- a/lib/__tests__/tierUpgrade.test.ts +++ b/lib/__tests__/tierUpgrade.test.ts @@ -32,6 +32,8 @@ const offer = (overrides: Partial = {}): TierOffer => ({ const membership = (overrides: Partial = {}): TierMembershipState => ({ enabled: true, pointsUnlockEnabled: false, + // Nothing resting on the legacy skip-the-line route by default. + legacyFuseOnly: false, offers: [offer(), offer({ tier: RewardsTier.ULTRA, lockFuse: 400_000, annualFeeUsd: null })], lock: { enabled: true, diff --git a/lib/types.ts b/lib/types.ts index 5f97d6bde..4b582175a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3236,6 +3236,16 @@ export interface TierMembershipState { lock: TierLockState; subscription: TierSubscription | null; currentTier: RewardsTier; + /** + * Whether that tier rests on the legacy "skip the line" route alone — FUSE + * sitting in Savings, granting a tier without being committed to anything. + * + * The migration flag. That route is being retired, and when it is switched + * off everyone it was carrying drops to Core in the same instant. False for + * anyone who has also locked or subscribed, because nothing about the change + * can reach them — so this is exactly the set of users worth warning. + */ + legacyFuseOnly: boolean; memberSince: string | null; contracts: TierMembershipContracts; } From e55c94554bcadd305ce206ec299f0fb3197873c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 17:29:48 +0000 Subject: [PATCH 5/9] Drop the points copy from the benefits screen once points can't unlock Each tier's page said "Unlocks at 5M points" and opened a "how to earn points" sheet whether or not points could still reach that tier. Once the ladder is retired, or for anyone the grandfather list leaves nothing to, that advertises a route the backend refuses. The page now follows the membership state's pointsUnlockEnabled. The backend reports it per user: can points still take this user higher? When that is false, the line reads "Unlocks with a membership" if a lock or an annual charge is on sale for the tier, and is hidden otherwise. The points sheet goes in both cases. While the state is loading, the page keeps v2's copy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VhnDQhJoWUKneB9KFcZgfV --- .../NewRewards/RewardsBenefitsScreenNew.tsx | 87 ++++++++++++++----- lib/types.ts | 6 +- 2 files changed, 69 insertions(+), 24 deletions(-) diff --git a/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx b/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx index 48fd9d658..daed7e308 100644 --- a/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx +++ b/components/Rewards/NewRewards/RewardsBenefitsScreenNew.tsx @@ -494,6 +494,14 @@ interface TierPageProps { isDesktopLayout: boolean; /** Width of the page column — the window on mobile, the body column on desktop. */ pageWidth: number; + /** + * Whether points can still take this user to a higher tier — the membership + * state's per-user verdict. False for everyone the grandfather list leaves + * nothing to, and once the ladder is switched off. + */ + pointsUnlockTiers: boolean; + /** Whether this tier is on sale through v3 — a lock or an annual charge. */ + purchasable: boolean; } const PremiumUpgradeFooter = ({ @@ -620,10 +628,26 @@ const PremiumUpgradeFooter = ({ * between real, already-rendered pages instead of faking it with a fade/slide * of a single swapped-out content block. */ -const TierPage = ({ tier, isCurrentTier, isDesktopLayout, pageWidth }: TierPageProps) => { +const TierPage = ({ + tier, + isCurrentTier, + isDesktopLayout, + pageWidth, + pointsUnlockTiers, + purchasable, +}: TierPageProps) => { const insets = useSafeAreaInsets(); const content = TIER_CONTENT[tier]; - const subtitle = isCurrentTier ? 'Your current tier' : content.unlockCopy; + // "Unlocks at 5M points" is only true while points can still take this user + // there. Past that the tier is bought, so the line says so when it is on + // sale and says nothing when it is not, and the points explainer goes too. + const subtitle = isCurrentTier + ? 'Your current tier' + : pointsUnlockTiers || tier === RewardsTier.CORE + ? content.unlockCopy + : purchasable + ? 'Unlocks with a membership' + : null; const pageTopSpacing = insets.top + HEADER_ROW_HEIGHT - (isDesktopLayout ? DESKTOP_HERO_TOP_REDUCTION : 0); @@ -727,28 +751,41 @@ const TierPage = ({ tier, isCurrentTier, isDesktopLayout, pageWidth }: TierPageP {content.headline} - - - {subtitle} - - - - } - /> + + {subtitle} + + + + } + /> + ) : subtitle ? ( + + {subtitle} + + ) : null} @@ -1036,6 +1073,10 @@ function RewardsBenefitsForAccount() { isCurrentTier={currentTier === tier} isDesktopLayout={isSidebarShell} pageWidth={pageWidth} + // Until the membership state arrives, v2's copy — which is what + // an older backend without per-user gating also means. + pointsUnlockTiers={membership?.pointsUnlockEnabled ?? true} + purchasable={upgradeRoutes(tier).length > 0} /> ))} diff --git a/lib/types.ts b/lib/types.ts index 014c60bc6..3b33ede3d 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -3291,7 +3291,11 @@ export interface TierMembershipContracts { export interface TierMembershipState { /** Whether either purchase route is available. False hides the upgrade UI. */ enabled: boolean; - /** Whether points still unlock a tier. False in v3. */ + /** + * Whether points can still take this user to a higher tier. Per user: false + * once the ladder is switched off, for anyone not on the grandfather list, + * and for a grandfathered user already at the tier the list caps them at. + */ pointsUnlockEnabled: boolean; offers: TierOffer[]; lock: TierLockState; From 3fe822e98c7f5b527fe610cc5a975dd31df91476 Mon Sep 17 00:00:00 2001 From: mul53 Date: Fri, 25 Sep 2026 10:33:39 +0200 Subject: [PATCH 6/9] feat(deposit): Cash App fiat onramp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a USD deposit rail paid from Cash App. Selecting USD under "Deposit with cash" now opens a chooser — "Wire transfer, ACH" (the virtual account) or "Cash App" — instead of going straight to the bank rail. Outside the US the chooser is skipped, because one option is a question with one answer. The flow is amount → invoice → status, with the invoice screen acting as the review step: it shows the sats, fee and delivery Orchestra actually quoted, and nothing is charged until it is paid. There is no live quote on the amount screen by design — /estimate prices in sats only, with no fiat parameter at any spelling, and the app has no spot source, so a dollar figure there could only be quoted against a rate Orchestra never agreed to. Everything goes through our own backend at /accounts/v1/orchestra, which holds the Orchestra server key. The app carries no Orchestra credential: the recipient is the Safe the server resolves from the session, and availability — region plus allowlist — is the server's verdict, which it enforces again on order creation rather than trusting a hidden row. Status is streamed over SSE where EventSource exists and polled at the documented three seconds everywhere else, which is every native build. The poll keeps running underneath a healthy stream: frames carry only the status, and a reconnect does not replay what was missed. Amounts are formatted from smallest units with BigInt and grouped by hand, because Hermes and the React Native Web Intl shim throw on a BigInt passed to Intl.NumberFormat — Node's does not, so the tests alone would not have caught it. There is now a test that makes Intl behave the way the app's runtime does. Co-Authored-By: Claude Opus 5 --- .env.example | 11 + __tests__/orchestraErrors.test.ts | 95 ++++++ __tests__/orchestraFormat.test.ts | 77 +++++ .../DepositOption/DepositCashOptions.tsx | 48 ++- .../DepositOption/DepositUsdOptions.tsx | 90 ++++++ components/Orchestra/OrchestraAmount.tsx | 224 ++++++++++++++ components/Orchestra/OrchestraError.tsx | 88 ++++++ components/Orchestra/OrchestraInvoice.tsx | 239 +++++++++++++++ components/Orchestra/OrchestraNavigation.tsx | 33 +++ components/Orchestra/OrchestraOrderStatus.tsx | 277 ++++++++++++++++++ constants/modals.ts | 33 +++ constants/tracking-events.ts | 27 ++ .../useCashAppDepositAvailability.test.tsx | 100 +++++++ hooks/useCashAppDepositAvailability.ts | 55 ++++ hooks/useDepositOption.tsx | 67 ++++- hooks/useOrchestra.ts | 96 ++++++ hooks/useOrchestraOrderStream.ts | 94 ++++++ hooks/useVirtualAccountEntry.ts | 55 ++++ lib/api/orchestra.ts | 112 +++++++ lib/config.ts | 11 + lib/orchestraErrors.ts | 179 +++++++++++ lib/orchestraFormat.ts | 87 ++++++ lib/types/orchestra.ts | 175 +++++++++++ store/useOrchestraStore.ts | 41 +++ 24 files changed, 2287 insertions(+), 27 deletions(-) create mode 100644 __tests__/orchestraErrors.test.ts create mode 100644 __tests__/orchestraFormat.test.ts create mode 100644 components/DepositOption/DepositUsdOptions.tsx create mode 100644 components/Orchestra/OrchestraAmount.tsx create mode 100644 components/Orchestra/OrchestraError.tsx create mode 100644 components/Orchestra/OrchestraInvoice.tsx create mode 100644 components/Orchestra/OrchestraNavigation.tsx create mode 100644 components/Orchestra/OrchestraOrderStatus.tsx create mode 100644 hooks/__tests__/useCashAppDepositAvailability.test.tsx create mode 100644 hooks/useCashAppDepositAvailability.ts create mode 100644 hooks/useOrchestra.ts create mode 100644 hooks/useOrchestraOrderStream.ts create mode 100644 hooks/useVirtualAccountEntry.ts create mode 100644 lib/api/orchestra.ts create mode 100644 lib/orchestraErrors.ts create mode 100644 lib/orchestraFormat.ts create mode 100644 lib/types/orchestra.ts create mode 100644 store/useOrchestraStore.ts diff --git a/.env.example b/.env.example index 6e2d51142..8c3aa48ab 100644 --- a/.env.example +++ b/.env.example @@ -85,3 +85,14 @@ EXPO_PUBLIC_TRUSTPILOT_LOCALE=en-US # Where "Write a review" goes if the widget script is blocked. Defaults to # https://www.trustpilot.com/evaluate/. EXPO_PUBLIC_TRUSTPILOT_REVIEW_URL= + +# --- Flashnet Orchestra: Lightning fiat onramp (https://docs.flashnet.xyz/orchestra/onramp) --- +# The app needs NO Orchestra key. The accounts service holds the server key +# (ORCHESTRA_SERVER_KEY in solid-backend) and proxies every call under +# /accounts/v1/orchestra, so this flow reaches Orchestra through +# EXPO_PUBLIC_FLASH_API_BASE_URL like any other backend feature — and the +# recipient address is resolved from the session rather than sent from here. +# +# A client key (fnp_) would let the app call Orchestra directly. It is +# deliberately not used: it would put the recipient address in a body the user +# controls, and make an order id enough to read someone else's deposit. diff --git a/__tests__/orchestraErrors.test.ts b/__tests__/orchestraErrors.test.ts new file mode 100644 index 000000000..a560f6b40 --- /dev/null +++ b/__tests__/orchestraErrors.test.ts @@ -0,0 +1,95 @@ +import { + asOrchestraError, + ORCHESTRA_ERROR_CODE, + OrchestraError, + orchestraErrorFromCode, + orchestraErrorTitle, + toOrchestraError, +} from '@/lib/orchestraErrors'; +import { ORCHESTRA_FAILED_STATUSES, ORCHESTRA_SETTLED_STATUSES } from '@/lib/types/orchestra'; + +const responseWith = (status: number, body?: unknown) => + ({ + status, + json: async () => { + if (body === undefined) throw new SyntaxError('Unexpected end of JSON input'); + return body; + }, + }) as Response; + +describe('toOrchestraError', () => { + it('reads Orchestra’s nested error body', async () => { + const error = await toOrchestraError( + responseWith(400, { error: { code: 'amount_too_small', message: 'below minimum' } }), + ); + expect(error.code).toBe(ORCHESTRA_ERROR_CODE.AMOUNT_TOO_SMALL); + expect(error.action).toBe('adjust_amount'); + expect(error.status).toBe(400); + // The user-facing copy is ours; Orchestra's wording is kept for telemetry. + expect(error.message).not.toBe('below minimum'); + expect(error.rawMessage).toBe('below minimum'); + }); + + it('falls back to a generic retry for a body that is not ours', async () => { + const error = await toOrchestraError(responseWith(502)); + expect(error.code).toBe(ORCHESTRA_ERROR_CODE.UNKNOWN); + expect(error.action).toBe('retry'); + expect(error.status).toBe(502); + }); + + it('keeps the status so isHTTPError-style checks still work', async () => { + const error = await toOrchestraError( + responseWith(403, { error: { code: 'invalid_read_token' } }), + ); + expect(error.status).toBe(403); + expect(error).toBeInstanceOf(OrchestraError); + }); +}); + +describe('orchestraErrorFromCode', () => { + it('maps order-level failure codes to copy and an action', () => { + const slippage = orchestraErrorFromCode('slippage_exceeded'); + expect(slippage.action).toBe('none'); + expect(slippage.message).toMatch(/refunded/i); + + const missingRefund = orchestraErrorFromCode('refund_address_missing'); + expect(missingRefund.action).toBe('contact_support'); + }); + + it('treats an unknown code as retryable rather than fatal', () => { + const error = orchestraErrorFromCode('some_new_code_we_have_not_seen'); + expect(error.action).toBe('retry'); + expect(orchestraErrorTitle(error)).toBe('Something went wrong'); + }); +}); + +describe('asOrchestraError', () => { + it('passes an OrchestraError through untouched', () => { + const original = orchestraErrorFromCode('amount_too_large', 400); + expect(asOrchestraError(original)).toBe(original); + }); + + it('turns a dropped connection into something renderable', () => { + const error = asOrchestraError(new TypeError('Failed to fetch')); + expect(error.action).toBe('retry'); + expect(error.message).not.toMatch(/fetch/i); + expect(error.rawMessage).toBe('Failed to fetch'); + }); +}); + +describe('terminal status sets', () => { + it('does not treat unfulfilled as settled — a late deposit can still resume it', () => { + expect(ORCHESTRA_SETTLED_STATUSES).not.toContain('unfulfilled'); + expect(ORCHESTRA_FAILED_STATUSES).not.toContain('unfulfilled'); + }); + + it('settles on completed as well as the three failure outcomes', () => { + expect([...ORCHESTRA_SETTLED_STATUSES].sort()).toEqual([ + 'completed', + 'expired', + 'failed', + 'refunded', + ]); + expect(ORCHESTRA_FAILED_STATUSES).not.toContain('completed'); + }); +}); diff --git a/__tests__/orchestraFormat.test.ts b/__tests__/orchestraFormat.test.ts new file mode 100644 index 000000000..1e090e7a3 --- /dev/null +++ b/__tests__/orchestraFormat.test.ts @@ -0,0 +1,77 @@ +import { formatSats, formatSmallestUnits } from '@/lib/orchestraFormat'; + +describe('formatSmallestUnits', () => { + it('renders smallest units at the asset decimals', () => { + expect(formatSmallestUnits('49750000', 6)).toBe('49.75'); + expect(formatSmallestUnits('250000', 6)).toBe('0.25'); + expect(formatSmallestUnits('50000000', 6)).toBe('50'); + expect(formatSmallestUnits('1', 6)).toBe('0'); + }); + + it('pads when the asset has fewer decimals than requested places', () => { + expect(formatSmallestUnits('4975', 2, 4)).toBe('49.75'); + expect(formatSmallestUnits('7', 0, 2)).toBe('7'); + }); + + it('rounds on the first dropped digit without going through a float', () => { + expect(formatSmallestUnits('49755000', 6)).toBe('49.76'); + expect(formatSmallestUnits('49754000', 6)).toBe('49.75'); + expect(formatSmallestUnits('999999', 6, 2)).toBe('1'); + expect(formatSmallestUnits('5000000000000000000000', 18, 2)).toBe('5,000'); + }); + + it('rejects anything that is not an integer string', () => { + expect(formatSmallestUnits('49.75', 6)).toBeUndefined(); + expect(formatSmallestUnits(undefined, 6)).toBeUndefined(); + expect(formatSmallestUnits('100', undefined)).toBeUndefined(); + }); + + it('groups sats', () => { + expect(formatSats('44210')).toBe('44,210 sats'); + expect(formatSats('abc')).toBeUndefined(); + }); +}); + +/** + * Hermes and the React Native Web Intl shim coerce `format`'s argument with + * ToNumber, so handing them a BigInt throws "Cannot convert a BigInt value to a + * number" — which is what the invoice screen did in the app. Node's Intl accepts + * BigInt happily, so the only way to catch this in a test is to make Intl behave + * the way the app's runtime does. + */ +describe('never hands a BigInt to Intl', () => { + const RealNumberFormat = Intl.NumberFormat; + + beforeAll(() => { + // Deliberately replacing the global for this suite. + Intl.NumberFormat = function PatchedNumberFormat( + ...args: ConstructorParameters + ) { + const instance = new RealNumberFormat(...args); + const realFormat = instance.format.bind(instance); + instance.format = (value: number | bigint) => { + if (typeof value === 'bigint') { + throw new TypeError('Cannot convert a BigInt value to a number'); + } + return realFormat(value); + }; + return instance; + } as unknown as typeof Intl.NumberFormat; + Object.assign(Intl.NumberFormat, RealNumberFormat); + }); + + afterAll(() => { + Intl.NumberFormat = RealNumberFormat; + }); + + it('formats smallest units on a runtime whose Intl rejects BigInt', () => { + expect(() => formatSmallestUnits('49750000', 6)).not.toThrow(); + expect(formatSmallestUnits('49750000', 6)).toBe('49.75'); + expect(formatSmallestUnits('5000000000000000000000', 18, 2)).toBe('5,000'); + }); + + it('formats sats on a runtime whose Intl rejects BigInt', () => { + expect(() => formatSats('44210')).not.toThrow(); + expect(formatSats('44210')).toBe('44,210 sats'); + }); +}); diff --git a/components/DepositOption/DepositCashOptions.tsx b/components/DepositOption/DepositCashOptions.tsx index fe5ec7799..7c1da3c63 100644 --- a/components/DepositOption/DepositCashOptions.tsx +++ b/components/DepositOption/DepositCashOptions.tsx @@ -14,13 +14,12 @@ 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 { useCardStatus } from '@/hooks/useCardStatus'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; import useGeoCompliance from '@/hooks/useGeoCompliance'; -import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; -import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider'; +import { useOrchestraConfig } from '@/hooks/useOrchestra'; +import { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; -import { RainApplicationStatus } from '@/lib/types'; import { useDepositStore } from '@/store/useDepositStore'; import { useTransfiStore } from '@/store/useTransfiStore'; @@ -34,6 +33,8 @@ const SHOW_MORE_ICON_COLOR = 'rgba(255,255,255,0.7)'; * 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 FEATURED_LOCAL_CURRENCY_CODES = ['EUR', 'BRL', 'BDT', 'PHP'] as const; const ADDITIONAL_LOCAL_CURRENCY_CODES = ['MXN'] as const; @@ -57,12 +58,15 @@ const DepositCashOptions = () => { const setModal = useDepositStore(state => state.setModal); const resetTransfi = useTransfiStore(state => state.reset); const setTransfiCurrency = useTransfiStore(state => state.setFiatCurrency); - const [isVirtualAccountApplyOpen, setIsVirtualAccountApplyOpen] = useState(false); + const [showAllCurrencies, setShowAllCurrencies] = useState(false); - const { data: cardStatus } = useCardStatus(); - const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED; - const { data: existingAutomation } = useOnrampAutomation(isRainApproved); - const { provider: virtualAccountProvider } = useVirtualAccountProvider(); + 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 } = useCashAppDepositAvailability(); + const { data: orchestraConfig } = useOrchestraConfig(countryCode); + const isCashAppAvailable = orchestraConfig?.isAvailable === true; const { isBuyCryptoAvailable } = useGeoCompliance(); const { handleBuyCryptoPress } = useBuyCryptoEntry(); @@ -77,20 +81,15 @@ const DepositCashOptions = () => { }, [showAllCurrencies]); const handleUsdPress = () => { - track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { - deposit_method: 'bank_transfer', - provider: virtualAccountProvider, - }); - // A Wirex user has no Rain automation and never will, so the Rain apply - // pitch is not their next step — their details screen owns activation for - // both rails. The other entry points into this flow already route on the - // provider; this one did not, which is how Wirex users reached a "Verify - // now" that could only bounce them off the Rain KYC gate. - if (virtualAccountProvider === 'wirex' || existingAutomation) { - setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + // 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; } - setIsVirtualAccountApplyOpen(true); + openVirtualAccount(); }; const handleLocalCurrencyPress = (code: string) => { @@ -122,7 +121,7 @@ const DepositCashOptions = () => { /> } title="USD" - chips={USD_PAYMENT_METHOD_CHIPS} + chips={isCashAppAvailable ? USD_PAYMENT_METHOD_CHIPS_US : USD_PAYMENT_METHOD_CHIPS} onPress={handleUsdPress} /> {localCurrencies.map(currency => ( @@ -153,10 +152,7 @@ const DepositCashOptions = () => { - setIsVirtualAccountApplyOpen(false)} - /> + ); }; diff --git a/components/DepositOption/DepositUsdOptions.tsx b/components/DepositOption/DepositUsdOptions.tsx new file mode 100644 index 000000000..068632a64 --- /dev/null +++ b/components/DepositOption/DepositUsdOptions.tsx @@ -0,0 +1,90 @@ +import { useEffect } from 'react'; +import { View } from 'react-native'; +import { Building2, Zap } from 'lucide-react-native'; + +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 { useVirtualAccountEntry } from '@/hooks/useVirtualAccountEntry'; +import { track } from '@/lib/analytics'; +import { useDepositStore } from '@/store/useDepositStore'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +import VirtualAccountApplyDialog from './VirtualAccountDetails/VirtualAccountApplyDialog'; + +const ICON_SIZE = 36; + +const BANK_CHIPS = ['Wire', 'ACH']; +// The row is already titled Cash App; the chip says how fast, not how. +const CASH_APP_CHIPS = ['Instant']; + +/** + * How to fund in USD: the bank rail, 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. + */ +const DepositUsdOptions = () => { + const setModal = useDepositStore(state => state.setModal); + const resetOrchestra = useOrchestraStore(state => state.reset); + const { open: openVirtualAccount, isApplyOpen, closeApply } = useVirtualAccountEntry(); + + useEffect(() => { + track(TRACKING_EVENTS.DEPOSIT_USD_METHOD_VIEWED); + }, []); + + const handleCashAppPress = () => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'buy_crypto', + provider: 'orchestra', + currency: 'USD', + }); + // 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. + resetOrchestra(); + setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + }; + + 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} + /> + + + + + ); +}; + +export default DepositUsdOptions; diff --git a/components/Orchestra/OrchestraAmount.tsx b/components/Orchestra/OrchestraAmount.tsx new file mode 100644 index 000000000..495235b9d --- /dev/null +++ b/components/Orchestra/OrchestraAmount.tsx @@ -0,0 +1,224 @@ +import { useEffect } from 'react'; +import { ActivityIndicator, TextInput, View } from 'react-native'; +import { Image } from 'expo-image'; + +import NeedHelp from '@/components/NeedHelp'; +import { useOrchestraNavigation } from '@/components/Orchestra/OrchestraNavigation'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; +import { useCreateOrchestraOnramp, useOrchestraConfig } from '@/hooks/useOrchestra'; +import { track } from '@/lib/analytics'; +import { getAsset } from '@/lib/assets'; +import { + asOrchestraError, + ORCHESTRA_ERROR_CODE, + orchestraErrorFromCode, +} from '@/lib/orchestraErrors'; +import { formatUsd } from '@/lib/orchestraFormat'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +/** + * First step of the Lightning onramp: how much USD to deposit. + * + * There is no live quote on this screen, and that is a property of the API + * rather than an omission. GET /estimate prices in sats only — it has no fiat + * parameter under any spelling — and the app has no spot source of its own, so + * a dollar figure here could only be quoted against a rate Orchestra didn't + * agree to. POST /onramp is the first call that accepts `amountFiatUsd`, and it + * answers with the real sats, fee and delivery at Orchestra's own spot. + * + * So the invoice screen is the review step: it shows those numbers, nothing is + * charged until the invoice is paid, and back returns here. What this screen + * owes the user is the band their amount has to fall in, which /limits gives — + * operator-tuned and fetched rather than hardcoded. + */ +export const OrchestraAmount = () => { + const setModal = useOrchestraNavigation(); + + const amountUsd = useOrchestraStore(state => state.amountUsd); + const setAmountUsd = useOrchestraStore(state => state.setAmountUsd); + const setOrder = useOrchestraStore(state => state.setOrder); + const setError = useOrchestraStore(state => state.setError); + + useEffect(() => { + track(TRACKING_EVENTS.ORCHESTRA_AMOUNT_VIEWED); + }, []); + + const { countryCode } = useCashAppDepositAvailability(); + const { + data: config, + error: configError, + isPending: configPending, + } = useOrchestraConfig(countryCode); + const { mutate: createOrder, isPending: creatingOrder } = useCreateOrchestraOnramp(); + + // Without config there is no band to validate against and no asset to name, + // so the screen can only render an inert form. Whatever went wrong — the + // server has no Orchestra key, the backend is unreachable, the session + // lapsed — the error screen states it, which is the whole reason this step + // is reachable at all rather than hidden behind a vanishing row. + // The row is gated on the same verdict, but this step is reachable by other + // routes — a restored modal step, a user whose IP moved between sessions. The + // server refuses order creation too, so this only saves a round trip. + useEffect(() => { + if (!config || config.isAvailable) return; + setError( + orchestraErrorFromCode(ORCHESTRA_ERROR_CODE.NOT_IN_AUDIENCE), + DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT, + ); + setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR); + }, [config, setError, setModal]); + + useEffect(() => { + if (!configError) return; + setError(asOrchestraError(configError), DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR); + }, [configError, setError, setModal]); + + // The backend already merged the live band with Orchestra's published floor, + // so these are the bounds to enforce, not a starting point to second-guess. + const minUsd = config?.minUsd ?? 0; + const maxUsd = config?.maxUsd ?? 0; + + const amountNum = Number(amountUsd); + const hasAmount = Number.isFinite(amountNum) && amountNum > 0; + const belowMin = hasAmount && amountNum < minUsd; + const aboveMax = hasAmount && amountNum > maxUsd; + const inRange = hasAmount && !belowMin && !aboveMax; + + const symbol = config?.assetDisplaySymbol ?? config?.destinationAsset ?? 'USDC'; + const network = config?.chainDisplayName; + + // The band is only known once config lands; until then there is nothing to + // validate against, so the button stays disabled rather than accepting an + // amount that might be out of range. + const continueDisabled = !config || !inRange || creatingOrder; + + const handleContinue = () => { + if (!config || !inRange) return; + + // Only the amount: the recipient is the Safe the backend resolves from the + // session, not a field this screen gets to choose. + // 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 }, + { + onSuccess: order => { + track(TRACKING_EVENTS.ORCHESTRA_ORDER_CREATED, { + order_id: order.orderId, + amount_usd: amountNum, + amount_mode: order.amountMode, + has_cash_app_link: Boolean(order.paymentLinks?.cashApp), + }); + setOrder(order); + setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE); + }, + onError: error => { + const orchestraError = asOrchestraError(error); + track(TRACKING_EVENTS.ORCHESTRA_ORDER_CREATION_FAILED, { + amount_usd: amountNum, + error_code: orchestraError.code, + error_action: orchestraError.action, + error_message: orchestraError.rawMessage, + }); + setError(orchestraError, DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR); + }, + }, + ); + }; + + if (configPending) { + return ( + + + + ); + } + + return ( + + + You pay + + + $ + setAmountUsd(t.replace(/[^0-9.]/g, ''))} + keyboardType="decimal-pad" + placeholder="0.00" + placeholderTextColor="rgba(255,255,255,0.5)" + className="flex-1 p-0 text-3xl font-semibold text-white web:outline-none" + style={{ fontFamily: 'MonaSans_600SemiBold' }} + /> + + + + {symbol} + + + + You'll receive {symbol} + {network ? ` on ${network}` : ''} in your wallet + + + + + How this works + + We'll create a Cash App payment request for this amount. You'll see the exact + rate, fee, and what you receive before you pay anything. + + + Between {formatUsd(minUsd)} and {formatUsd(maxUsd)} per deposit + + {belowMin ? ( + Enter at least {formatUsd(minUsd)}. + ) : null} + {aboveMax ? ( + Enter at most {formatUsd(maxUsd)}. + ) : null} + + + + + You'll pay with Cash App. Conversion is provided by Flashnet; rates and fees are set + by Flashnet and may change. Available in the US only, excluding New York. + + + + + + + + ); +}; + +export default OrchestraAmount; diff --git a/components/Orchestra/OrchestraError.tsx b/components/Orchestra/OrchestraError.tsx new file mode 100644 index 000000000..50b0f41fb --- /dev/null +++ b/components/Orchestra/OrchestraError.tsx @@ -0,0 +1,88 @@ +import { useEffect } from 'react'; +import { View } from 'react-native'; +import { AlertCircle } from 'lucide-react-native'; + +import NeedHelp from '@/components/NeedHelp'; +import { useOrchestraNavigation } from '@/components/Orchestra/OrchestraNavigation'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { track } from '@/lib/analytics'; +import { orchestraErrorTitle } from '@/lib/orchestraErrors'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +/** + * Failure screen for the Lightning onramp. + * + * The button is chosen by the error's `action`, not by which step raised it: a + * refusal the user can fix by typing a different number goes back to the amount + * box, and one they cannot fix — an unroutable corridor, a key the dashboard has + * to change — offers support and the way out instead of a retry that can only + * fail the same way. + */ +export const OrchestraError = () => { + const setModal = useOrchestraNavigation(); + const error = useOrchestraStore(state => state.error); + const errorOrigin = useOrchestraStore(state => state.errorOrigin); + const reset = useOrchestraStore(state => state.reset); + + useEffect(() => { + if (!error) return; + track(TRACKING_EVENTS.ORCHESTRA_ERROR_VIEWED, { + error_code: error.code, + error_action: error.action, + error_status: error.status, + error_message: error.rawMessage, + }); + }, [error]); + + const close = () => { + reset(); + setModal(DEPOSIT_MODAL.CLOSE); + }; + + const retry = () => { + track(TRACKING_EVENTS.ORCHESTRA_ERROR_ACTION_PRESSED, { + error_code: error?.code, + error_action: error?.action, + }); + setModal(errorOrigin ?? DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + }; + + const canRetry = error == null || error.action === 'retry' || error.action === 'adjust_amount'; + + return ( + + + + + + + {error ? orchestraErrorTitle(error) : 'Something went wrong'} + + + {error?.message ?? + 'We couldn’t start this deposit. Nothing was charged — please try again.'} + + + + + {canRetry ? ( + + ) : null} + + + + + + ); +}; + +export default OrchestraError; diff --git a/components/Orchestra/OrchestraInvoice.tsx b/components/Orchestra/OrchestraInvoice.tsx new file mode 100644 index 000000000..8d57fa042 --- /dev/null +++ b/components/Orchestra/OrchestraInvoice.tsx @@ -0,0 +1,239 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Linking, Platform, View } from 'react-native'; +import QRCode from 'react-native-qrcode-svg'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import NeedHelp from '@/components/NeedHelp'; +import { useOrchestraNavigation } from '@/components/Orchestra/OrchestraNavigation'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useOrchestraConfig } from '@/hooks/useOrchestra'; +import { track } from '@/lib/analytics'; +import { formatSats, formatSmallestUnits, formatUsd } from '@/lib/orchestraFormat'; +import { eclipseAddress } from '@/lib/utils'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +const solidLogo = require('@/assets/images/solid-white.png'); + +/** Seconds left until `expiresAt`, ticking, floored at zero. */ +const useSecondsUntil = (expiresAt: string | undefined) => { + const target = useMemo(() => (expiresAt ? Date.parse(expiresAt) : NaN), [expiresAt]); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + if (!Number.isFinite(target)) return; + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, [target]); + + if (!Number.isFinite(target)) return undefined; + return Math.max(0, Math.floor((target - now) / 1000)); +}; + +const formatCountdown = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const rest = seconds % 60; + return hours > 0 ? `${hours}h ${minutes}m` : `${minutes}:${String(rest).padStart(2, '0')}`; +}; + +/** + * Second step: pay the invoice. + * + * The invoice is only ever handed to a wallet — opened as a Cash App deep link, + * scanned off the QR, or copied into a Lightning wallet. It is never fetched: + * the pay link is a launcher, and requesting it would burn the handoff without + * paying anything. + * + * The invoice's own expiry is shown here and deliberately kept separate from + * order tracking. An exact-in invoice lasts about 24 hours and an exact-out one + * five minutes, but a payment that lands late can still settle during the + * recovery window — so a lapsed countdown greys out this screen without ending + * the order. + */ +export const OrchestraInvoice = () => { + const setModal = useOrchestraNavigation(); + const order = useOrchestraStore(state => state.order); + const amountUsd = useOrchestraStore(state => state.amountUsd); + const { data: config } = useOrchestraConfig(); + + const secondsLeft = useSecondsUntil(order?.expiresAt); + const hasExpired = secondsLeft === 0; + + useEffect(() => { + track(TRACKING_EVENTS.ORCHESTRA_INVOICE_VIEWED, { + order_id: order?.orderId, + amount_usd: Number(amountUsd) || undefined, + amount_mode: order?.amountMode, + }); + }, [order?.orderId, order?.amountMode, amountUsd]); + + if (!order) { + return ( + + + Could not load the invoice. Please try again. + + + ); + } + + const symbol = config?.assetDisplaySymbol ?? config?.destinationAsset ?? 'USDC'; + const receiveAmount = formatSmallestUnits(order.estimatedOut, config?.decimals); + const payAmount = formatSats(order.amountIn); + const cashAppUrl = order.paymentLinks?.cashApp; + + /** + * The fee, in the asset Orchestra settles it in. + * + * `feeAssetDetails` carries that asset's own `decimals`, which need not match + * the destination's — so it is read from the response rather than inferred + * from the ticker, and the destination's exponent is only the fallback for a + * response that omits the block. `totalFeeAmount` is the figure to show: the + * bare `feeAmount` leaves out the rounding component the user also pays. + */ + const feeDecimals = order.feeAssetDetails?.decimals ?? config?.decimals; + const feeAmount = formatSmallestUnits(order.totalFeeAmount ?? order.feeAmount, feeDecimals); + const feeLabel = feeAmount ? `${feeAmount} ${order.feeAsset ?? symbol}` : undefined; + + // The docs' own split: on a phone the pay link launches Cash App, on a desktop + // it is something to scan with one. A phone camera pointed at that URL opens + // Cash App, which a raw BOLT11 does not — so the desktop QR is the link and + // the mobile QR is the invoice, for the wallet the user already has open. + // Either way the copy row below hands out the invoice itself. + const showsCashAppQr = Platform.OS === 'web' && Boolean(cashAppUrl); + const qrValue = showsCashAppQr ? (cashAppUrl as string) : order.depositAddress; + + const openCashApp = () => { + if (!cashAppUrl) return; + track(TRACKING_EVENTS.ORCHESTRA_CASH_APP_OPENED, { order_id: order.orderId }); + void Linking.openURL(cashAppUrl); + }; + + return ( + + + + {amountUsd ? `Pay ${formatUsd(Number(amountUsd))}` : 'Pay this invoice'} + + + Scan or open in Cash App to finish your deposit + + + + + + + + + {showsCashAppQr ? 'Scan with your phone to pay in Cash App' : 'Scan with Cash App'} + + {secondsLeft != null ? ( + + {hasExpired + ? 'This invoice has expired. Go back to start a new one.' + : `Expires in ${formatCountdown(secondsLeft)}`} + + ) : null} + + + {/* The review the amount screen could not give: /estimate prices in sats + only, so these are the first real numbers in the flow — and nothing is + charged until the invoice below is paid. */} + + + {feeLabel ? : null} + + + + + + + Payment request + + {eclipseAddress(order.depositAddress)} + + + + track(TRACKING_EVENTS.ORCHESTRA_INVOICE_COPIED, { order_id: order.orderId }) + } + /> + + + + Pay with Cash App. Don't close this window — your deposit is tracked here once the + payment is detected. + + + + {cashAppUrl ? ( + + ) : null} + + + + + + ); +}; + +const BreakdownRow = ({ + label, + value, + emphasize, +}: { + label: string; + value: string; + emphasize?: boolean; +}) => ( + + + {label} + + + {value} + + +); + +export default OrchestraInvoice; diff --git a/components/Orchestra/OrchestraNavigation.tsx b/components/Orchestra/OrchestraNavigation.tsx new file mode 100644 index 000000000..c080f7e81 --- /dev/null +++ b/components/Orchestra/OrchestraNavigation.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; + +import { useDepositStore } from '@/store/useDepositStore'; + +import type { DepositModal } from '@/lib/types'; + +export type OrchestraNavigate = (modal: DepositModal) => void; + +const OrchestraNavigationContext = createContext(null); + +export const OrchestraNavigationProvider = ({ + navigate, + children, +}: { + navigate: OrchestraNavigate; + children: React.ReactNode; +}) => ( + + {children} + +); + +/** + * Uses an embedded flow's navigator when present, otherwise the global deposit + * modal — so the same steps can run inside the card funding modals without + * reaching for the deposit store directly. + */ +export const useOrchestraNavigation = () => { + const embeddedNavigate = useContext(OrchestraNavigationContext); + const globalNavigate = useDepositStore(state => state.setModal); + + return embeddedNavigate ?? globalNavigate; +}; diff --git a/components/Orchestra/OrchestraOrderStatus.tsx b/components/Orchestra/OrchestraOrderStatus.tsx new file mode 100644 index 000000000..930f71259 --- /dev/null +++ b/components/Orchestra/OrchestraOrderStatus.tsx @@ -0,0 +1,277 @@ +import { useEffect, useRef } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import { useRouter } from 'expo-router'; +import { Check, Clock, XCircle } from 'lucide-react-native'; + +import DepositStepper from '@/components/DepositStepper'; +import NeedHelp from '@/components/NeedHelp'; +import { useOrchestraNavigation } from '@/components/Orchestra/OrchestraNavigation'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { path } from '@/constants/path'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useOrchestraConfig } from '@/hooks/useOrchestra'; +import { useOrchestraOrderStream } from '@/hooks/useOrchestraOrderStream'; +import { track } from '@/lib/analytics'; +import { orchestraErrorFromCode } from '@/lib/orchestraErrors'; +import { formatSmallestUnits } from '@/lib/orchestraFormat'; +import { ORCHESTRA_FAILED_STATUSES } from '@/lib/types/orchestra'; +import { DepositProgressRow } from '@/lib/utils/deposit-steps'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; + +import type { OrchestraStatus } from '@/lib/types/orchestra'; + +// Keys come from DepositProgressRow's union — the stepper is shared with the +// activity feed, and only the labels are ours. +const STEPS = [ + { key: 'received', label: 'Payment received' }, + { key: 'confirmed', label: 'Converting' }, + { key: 'depositing', label: 'On its way to your wallet' }, +] as const; + +/** + * Orchestra's states collapsed onto the three the user is shown. + * + * `awaiting_approval` sits at the conversion step rather than getting a row of + * its own: it covers both a ZeroConf offer and an operator hold, neither of + * which the user can act on, and "under review" on a screen with no review + * button reads as a dead end. + */ +const STEP_BY_STATUS: Record = { + processing: 0, + confirming: 0, + awaiting_approval: 1, + swapping: 1, + bridging: 1, + delivering: 2, + refunding: 1, + completed: 2, + refunded: -1, + failed: -1, + expired: -1, + unfulfilled: 0, +}; + +export const OrchestraOrderStatus = () => { + const router = useRouter(); + const setModal = useOrchestraNavigation(); + const reset = useOrchestraStore(state => state.reset); + const storedOrder = useOrchestraStore(state => state.order); + const { data: config } = useOrchestraConfig(); + + const { status, order, isUnreadable } = useOrchestraOrderStream(storedOrder?.orderId); + + // Before the Lightning payment is detected there is no order to read, so the + // screen holds at "waiting for payment" rather than showing nothing. + const effectiveStatus = status ?? 'processing'; + const isCompleted = effectiveStatus === 'completed'; + const isFailed = ORCHESTRA_FAILED_STATUSES.includes(effectiveStatus); + const isRefunding = effectiveStatus === 'refunding'; + const isWaitingForPayment = order == null; + + const activeIndex = STEP_BY_STATUS[effectiveStatus] ?? 0; + const progressRows: DepositProgressRow[] = STEPS.map((step, index) => ({ + ...step, + state: + isCompleted || index < activeIndex + ? 'complete' + : index === activeIndex + ? 'active' + : 'pending', + })); + + const symbol = config?.assetDisplaySymbol ?? config?.destinationAsset ?? 'USDC'; + const delivered = formatSmallestUnits( + order?.amountOut ?? storedOrder?.estimatedOut, + config?.decimals, + ); + + // The status is streamed and polled, so the terminal event fires once per + // screen rather than on every update that lands in the same state. + const reportedRef = useRef(false); + useEffect(() => { + if (reportedRef.current || (!isCompleted && !isFailed)) return; + reportedRef.current = true; + track( + isCompleted + ? TRACKING_EVENTS.ORCHESTRA_ORDER_COMPLETED + : TRACKING_EVENTS.ORCHESTRA_ORDER_FAILED, + { + order_id: storedOrder?.orderId, + status: effectiveStatus, + error_code: order?.errorCode, + }, + ); + }, [isCompleted, isFailed, effectiveStatus, order?.errorCode, storedOrder?.orderId]); + + const close = () => { + reset(); + setModal(DEPOSIT_MODAL.CLOSE); + }; + + /** + * The order can't be read any more — the 24-hour read token lapsed, or the key + * lost its scope. That is not a failed deposit, and the screen must not imply + * one: a paid invoice is still being settled somewhere we can no longer see. + * Without this branch the snapshot stays undefined and the screen sits on + * "Waiting for your payment" indefinitely, telling someone whose money has + * already left that nothing has happened. + */ + if (isUnreadable && !isCompleted) { + return ( + + + + + + + We can't track this deposit + + + If you paid the invoice, your deposit is still being processed and will appear in your + wallet. Contact support with the order ID below if it doesn't arrive. + + + {storedOrder?.orderId ? ( + + Order ID: {storedOrder.orderId} + + ) : null} + + + + + + ); + } + + if (isFailed) { + // `refunded` is a failure for this screen's purposes — the deposit did not + // arrive — but the money did come back, and saying so is the difference + // between a scare and an inconvenience. + const wasRefunded = effectiveStatus === 'refunded'; + const detail = order?.errorCode + ? orchestraErrorFromCode(order.errorCode).message + : wasRefunded + ? 'Your payment has been returned.' + : 'Your deposit didn’t go through.'; + + return ( + + + {effectiveStatus === 'expired' ? ( + + ) : ( + + )} + + + + {effectiveStatus === 'expired' + ? 'Invoice expired' + : wasRefunded + ? 'Deposit refunded' + : 'Deposit failed'} + + + {effectiveStatus === 'expired' + ? 'The invoice wasn’t paid in time. Nothing was charged — start again to get a new one.' + : detail} + + + + + + + + ); + } + + return ( + + + {isCompleted ? ( + + + + ) : ( + + )} + + {isCompleted + ? 'Deposit complete' + : isRefunding + ? 'Returning your payment' + : isWaitingForPayment + ? 'Waiting for your payment' + : 'Processing your deposit'} + + + {isCompleted && delivered + ? `${delivered} ${symbol} is in your wallet` + : isRefunding + ? 'This deposit couldn’t be completed, so your payment is on its way back to you.' + : isWaitingForPayment + ? 'This updates as soon as your Cash App payment lands.' + : `Your ${symbol} is on its way.`} + + + + {/* The delivery ladder describes a deposit that is still heading for the + wallet. A refund is going the other way, so it gets the sentence above + and no progress bar pointing at an arrival that isn't coming. */} + {isRefunding ? null : } + + {/* `unfulfilled` is not a failure: the deposit was never confirmed or was + replaced, and a late payment can still resume the order — so the screen + says so instead of showing an indefinite spinner with no explanation. */} + {effectiveStatus === 'unfulfilled' ? ( + + We haven't confirmed your payment yet. If it was sent, it can still settle over the + next few hours and this will update. + + ) : null} + + {storedOrder?.orderId ? ( + Order ID: {storedOrder.orderId} + ) : null} + + + {isCompleted ? ( + + ) : null} + + + + ); +}; + +export default OrchestraOrderStatus; diff --git a/constants/modals.ts b/constants/modals.ts index be81a050b..1f07ee370 100644 --- a/constants/modals.ts +++ b/constants/modals.ts @@ -27,6 +27,16 @@ export const DEPOSIT_MODAL = { name: 'open_deposit_cash', number: 1.8, }, + /** + * "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. + */ + OPEN_DEPOSIT_USD_METHOD: { + name: 'open_deposit_usd_method', + number: 1.85, + }, /** * "Select token" — which currency the deposit address is for: the first step * after "Show deposit address", and revisited from the address screen. @@ -187,6 +197,29 @@ export const DEPOSIT_MODAL = { name: 'open_onramper_widget', number: 28, }, + // Flashnet Orchestra Lightning onramp: enter an amount, pay the invoice, watch + // it settle. Numbered after every other deposit step so moving + // between the onramps from the cash screen always animates forward. + OPEN_ORCHESTRA_AMOUNT: { + name: 'open_orchestra_amount', + number: 29, + }, + OPEN_ORCHESTRA_INVOICE: { + name: 'open_orchestra_invoice', + number: 30, + }, + OPEN_ORCHESTRA_STATUS: { + name: 'open_orchestra_status', + number: 31, + }, + /** + * Failure screen for the onramp. Numbered past every other step so arriving + * at it always animates forward, whichever step failed. + */ + OPEN_ORCHESTRA_ERROR: { + name: 'open_orchestra_error', + number: 32, + }, }; export const SEND_MODAL = { diff --git a/constants/tracking-events.ts b/constants/tracking-events.ts index 0f0db9298..43d729190 100644 --- a/constants/tracking-events.ts +++ b/constants/tracking-events.ts @@ -203,6 +203,33 @@ export const TRACKING_EVENTS = { BUY_CRYPTO_PROFILE_FORM_SUBMITTED: 'buy_crypto_profile_form_submitted', BUY_CRYPTO_PROFILE_FORM_FAILED: 'buy_crypto_profile_form_failed', + /** + * The USD rail chooser was shown. Sits between picking USD on the cash screen + * and picking a rail, so without it the two look like one step and the + * drop-off between them is invisible. + */ + DEPOSIT_USD_METHOD_VIEWED: 'deposit_usd_method_viewed', + + // Deposit Method: Lightning onramp (Flashnet Orchestra) + ORCHESTRA_AMOUNT_VIEWED: 'orchestra_amount_viewed', + ORCHESTRA_ORDER_CREATED: 'orchestra_order_created', + ORCHESTRA_ORDER_CREATION_FAILED: 'orchestra_order_creation_failed', + /** The invoice + QR step was shown; the order exists but is unpaid. */ + ORCHESTRA_INVOICE_VIEWED: 'orchestra_invoice_viewed', + ORCHESTRA_INVOICE_COPIED: 'orchestra_invoice_copied', + ORCHESTRA_CASH_APP_OPENED: 'orchestra_cash_app_opened', + /** + * The user said they paid. Only a claim — the order is still tracked from + * Orchestra — but the gap between this and a detected payment is what tells + * us the Lightning hand-off is failing. + */ + ORCHESTRA_PAYMENT_CONFIRMED_BY_USER: 'orchestra_payment_confirmed_by_user', + ORCHESTRA_ORDER_COMPLETED: 'orchestra_order_completed', + /** Fired for failed, refunded and expired alike; `status` says which. */ + ORCHESTRA_ORDER_FAILED: 'orchestra_order_failed', + ORCHESTRA_ERROR_VIEWED: 'orchestra_error_viewed', + ORCHESTRA_ERROR_ACTION_PRESSED: 'orchestra_error_action_pressed', + // Deposit Bonus Banner Events DEPOSIT_BONUS_BANNER_VIEWED: 'deposit_bonus_banner_viewed', DEPOSIT_BONUS_BANNER_INFLUENCED: 'deposit_bonus_banner_influenced', diff --git a/hooks/__tests__/useCashAppDepositAvailability.test.tsx b/hooks/__tests__/useCashAppDepositAvailability.test.tsx new file mode 100644 index 000000000..6fca0ffb5 --- /dev/null +++ b/hooks/__tests__/useCashAppDepositAvailability.test.tsx @@ -0,0 +1,100 @@ +import React from 'react'; + +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; +import { detectGeo } from '@/lib/geo'; +import { useCountryStore } from '@/store/useCountryStore'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { act, create } = require('react-test-renderer'); + +// The country store persists through MMKV, which has no native module under jest. +jest.mock('@/lib/mmvkStorage', () => ({ + __esModule: true, + default: () => ({ + setItem: jest.fn(), + getItem: () => null, + removeItem: jest.fn(), + }), +})); + +jest.mock('@/lib/geo', () => ({ detectGeo: jest.fn() })); +const mockDetectGeo = detectGeo as jest.MockedFunction; + +type Result = ReturnType; + +/** Render the hook and return its latest value. */ +const renderHook = async (): Promise => { + let latest: Result | undefined; + const Probe = () => { + latest = useCashAppDepositAvailability(); + return null; + }; + await act(async () => { + create(); + }); + return latest as Result; +}; + +const storeCountry = (countryCode: string, state?: string) => + useCountryStore.getState().setCountryInfo({ + countryCode, + countryName: countryCode, + state, + isAvailable: true, + source: 'manual', + }); + +describe('useCashAppDepositAvailability', () => { + beforeEach(() => { + jest.clearAllMocks(); + useCountryStore.getState().clearCountryInfo(); + }); + + it('allows the US', async () => { + storeCountry('US'); + const result = await renderHook(); + + expect(result.isAvailable).toBe(true); + expect(result.isResolved).toBe(true); + // The country was already known, so no lookup was needed. + expect(mockDetectGeo).not.toHaveBeenCalled(); + }); + + it('refuses everywhere else — Cash App Lightning is US-only', async () => { + storeCountry('GB'); + await expect(renderHook()).resolves.toMatchObject({ + isAvailable: false, + isResolved: true, + }); + }); + + it('accepts a lowercase stored code', async () => { + storeCountry('us'); + await expect(renderHook()).resolves.toMatchObject({ isAvailable: true }); + }); + + it('falls back to an IP lookup when the country is unknown', async () => { + mockDetectGeo.mockResolvedValue({ + countryCode: 'US', + countryName: 'United States', + region: 'California', + }); + + const result = await renderHook(); + + expect(mockDetectGeo).toHaveBeenCalled(); + expect(result.isAvailable).toBe(true); + expect(result.region).toBe('California'); + }); + + it('treats an unresolvable country as unavailable, not as permission', async () => { + // Every geo provider failed. A US-only rail is not something to offer on the + // strength of not knowing where the user is. + mockDetectGeo.mockResolvedValue(null); + + await expect(renderHook()).resolves.toMatchObject({ + isAvailable: false, + isResolved: false, + }); + }); +}); diff --git a/hooks/useCashAppDepositAvailability.ts b/hooks/useCashAppDepositAvailability.ts new file mode 100644 index 000000000..7261cdf3e --- /dev/null +++ b/hooks/useCashAppDepositAvailability.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; + +import { detectGeo } from '@/lib/geo'; +import { useCountryStore } from '@/store/useCountryStore'; + +/** + * Whether the Cash App / Lightning deposit can be offered here. + * + * United States only. This is not our restriction to relax: Cash App's own + * Lightning send and receive is US-only, and Orchestra additionally excludes + * New York City. Offering the row anywhere else produces an invoice the user + * has no way to pay. + * + * The country comes from the store when it is already known — a manual pick on + * the country screen, or a recent IP detection — and otherwise from a fresh + * lookup. `detectGeo` memoises per session and never throws, so this costs one + * request at most and returns `null` rather than failing when every provider is + * unreachable. + * + * An unresolved country is treated as **not** available: a deposit method that + * flickers into view once geo resolves is worse than one that appears a beat + * late, and "we could not tell where you are" is not a reason to offer a + * US-only rail. + */ +export const useCashAppDepositAvailability = () => { + const countryInfo = useCountryStore(state => state.countryInfo); + const storedCode = countryInfo?.countryCode; + const [detected, setDetected] = useState<{ countryCode: string; region?: string }>(); + + useEffect(() => { + if (storedCode) return; + let cancelled = false; + void detectGeo().then(geo => { + if (cancelled || !geo) return; + setDetected({ countryCode: geo.countryCode, region: geo.region }); + }); + return () => { + cancelled = true; + }; + }, [storedCode]); + + const countryCode = (storedCode ?? detected?.countryCode)?.toUpperCase(); + const region = countryInfo?.state ?? detected?.region; + + return { + isAvailable: countryCode === 'US', + /** False while the country is still being resolved. */ + isResolved: Boolean(countryCode), + countryCode, + /** State/province where the provider resolved one. Unreliable over VPN. */ + region, + }; +}; + +export default useCashAppDepositAvailability; diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index 5c00494f2..4f2fdde47 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -30,6 +30,7 @@ import DepositDirectlyTokens from '@/components/DepositOption/DepositDirectlyTok import DepositExternalWalletOptions from '@/components/DepositOption/DepositExternalWalletOptions'; import DepositOptions from '@/components/DepositOption/DepositOptions'; import DepositTypeSelection from '@/components/DepositOption/DepositTypeSelection'; +import DepositUsdOptions from '@/components/DepositOption/DepositUsdOptions'; import DepositWalletConnector from '@/components/DepositOption/DepositWalletConnector'; import { VirtualAccountApplyModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountApplyModal'; import { VirtualAccountDetailsModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal'; @@ -38,6 +39,10 @@ import WalletDepositAddress from '@/components/DepositOption/WalletDepositAddres import WalletDepositTokens from '@/components/DepositOption/WalletDepositAddress/WalletDepositTokens'; import { DepositTokenSelector, DepositToVaultForm } from '@/components/DepositToVault'; import SavingsDepositTokenSelector from '@/components/DepositToVault/SavingsDepositTokenSelector'; +import { OrchestraAmount } from '@/components/Orchestra/OrchestraAmount'; +import { OrchestraError as OrchestraErrorScreen } from '@/components/Orchestra/OrchestraError'; +import { OrchestraInvoice } from '@/components/Orchestra/OrchestraInvoice'; +import { OrchestraOrderStatus } from '@/components/Orchestra/OrchestraOrderStatus'; import SavingsFundScreen from '@/components/Savings/SavingsFund/SavingsFundScreen'; import TransactionStatus from '@/components/TransactionStatus'; import { Button, buttonVariants } from '@/components/ui/button'; @@ -61,6 +66,7 @@ import { } from '@/lib/vaults'; import { getDepositTokenBackTarget } from '@/lib/walletDepositFlow'; import { useDepositStore } from '@/store/useDepositStore'; +import { useOrchestraStore } from '@/store/useOrchestraStore'; import { useSavingStore } from '@/store/useSavingStore'; import useResponsiveModal from './useResponsiveModal'; @@ -164,6 +170,11 @@ const useDepositOption = ({ const isBuyCryptoProfile = currentModal.name === DEPOSIT_MODAL.OPEN_BUY_CRYPTO_PROFILE.name; const isBuyCryptoError = currentModal.name === DEPOSIT_MODAL.OPEN_BUY_CRYPTO_ERROR.name; const isOnramperWidget = currentModal.name === DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name; + const isDepositUsdMethod = currentModal.name === DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD.name; + const isOrchestraAmount = currentModal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT.name; + const isOrchestraInvoice = currentModal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_INVOICE.name; + const isOrchestraStatus = currentModal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_STATUS.name; + const isOrchestraError = currentModal.name === DEPOSIT_MODAL.OPEN_ORCHESTRA_ERROR.name; const isPublicAddress = currentModal.name === DEPOSIT_MODAL.OPEN_PUBLIC_ADDRESS.name; const isDepositDirectly = currentModal.name === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY.name; const isDepositDirectlyAddress = @@ -335,6 +346,26 @@ const useDepositOption = ({ return setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH)} />; } + if (isDepositUsdMethod) { + return ; + } + + if (isOrchestraAmount) { + return ; + } + + if (isOrchestraInvoice) { + return ; + } + + if (isOrchestraStatus) { + return ; + } + + if (isOrchestraError) { + return ; + } + if (isPublicAddress) { return ; } @@ -439,6 +470,11 @@ const useDepositOption = ({ if (isBuyCryptoProfile) return 'buy-crypto-profile'; if (isBuyCryptoError) return 'buy-crypto-error'; if (isOnramperWidget) return 'onramper-widget'; + if (isDepositUsdMethod) return 'deposit-usd-method'; + if (isOrchestraAmount) return 'orchestra-amount'; + if (isOrchestraInvoice) return 'orchestra-invoice'; + if (isOrchestraStatus) return 'orchestra-status'; + if (isOrchestraError) return 'orchestra-error'; if (isPublicAddress) return 'public-address'; if (isSavingsFund) return 'savings-fund-options'; if (isSavingsFundNetworks) return 'savings-fund-networks'; @@ -485,6 +521,12 @@ const useDepositOption = ({ // it would say the same thing twice. if (isBuyCryptoError) return undefined; if (isOnramperWidget) return 'Buy crypto'; + if (isDepositUsdMethod) return 'Deposit US Dollars'; + if (isOrchestraAmount) return 'Deposit with Cash App'; + if (isOrchestraInvoice) return 'Pay with Cash App'; + if (isOrchestraStatus) return 'Deposit status'; + // Same reasoning as the TransFi error screen above. + if (isOrchestraError) return undefined; if (isPublicAddress) return 'Deposit address'; if (isDepositDirectly) return 'Choose network'; if (isDepositDirectlyTokens) return 'Choose token'; @@ -520,6 +562,7 @@ const useDepositOption = ({ isDepositCrypto || isDepositCash || isDepositToken || + isDepositUsdMethod || isPublicAddress ) { return 'md:max-w-[480px] md:pb-6'; @@ -539,7 +582,11 @@ const useDepositOption = ({ isBuyCryptoPayment || isBuyCryptoStatus || isBuyCryptoProfile || - isBuyCryptoError + isBuyCryptoError || + isOrchestraAmount || + isOrchestraInvoice || + isOrchestraStatus || + isOrchestraError ) { return 'w-[470px] max-h-[90vh]'; } @@ -598,6 +645,7 @@ const useDepositOption = ({ !isDepositCrypto && !isDepositCash && !isDepositToken && + !isDepositUsdMethod && !isPublicAddress ) { return 'min-h-[40rem]'; @@ -824,6 +872,23 @@ const useDepositOption = ({ setModal(DEPOSIT_MODAL.CLOSE); resetDepositFlow(); clearSessionStartTime(); + } else if (isDepositUsdMethod) { + setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_CASH); + } else if (isOrchestraAmount) { + // Entered from the USD method chooser, which is itself only reachable + // from the cash list. + setModal(DEPOSIT_MODAL.OPEN_DEPOSIT_USD_METHOD); + } else if (isOrchestraInvoice) { + // The invoice is already allocated, but going back only abandons it — the + // amount screen mints a new order rather than reusing this one. + setModal(DEPOSIT_MODAL.OPEN_ORCHESTRA_AMOUNT); + } else if (isOrchestraStatus || isOrchestraError) { + // An invoice may already be paid, and the failure is what sent them to the + // error screen — neither has a step worth returning to. + useOrchestraStore.getState().reset(); + setModal(DEPOSIT_MODAL.CLOSE); + resetDepositFlow(); + clearSessionStartTime(); } else if (isPublicAddress) { // Reached through the token list from the crypto branch, and directly from // the savings flow's own external-wallet list — each step back to where it diff --git a/hooks/useOrchestra.ts b/hooks/useOrchestra.ts new file mode 100644 index 000000000..83e97e131 --- /dev/null +++ b/hooks/useOrchestra.ts @@ -0,0 +1,96 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; + +import { createOrchestraOnramp, getOrchestraConfig, getOrchestraStatus } from '@/lib/api/orchestra'; +import { OrchestraError } from '@/lib/orchestraErrors'; +import { ORCHESTRA_SETTLED_STATUSES } from '@/lib/types/orchestra'; +import { withRefreshToken } from '@/lib/utils'; + +export const ORCHESTRA_CONFIG_KEY = 'orchestraConfig'; +export const ORCHESTRA_STATUS_KEY = 'orchestraStatus'; + +/** Docs: poll status every 3 seconds when the stream is unavailable. */ +export const ORCHESTRA_POLL_INTERVAL_MS = 3000; + +/** + * Cadence once a live stream is carrying the transitions. The snapshot is still + * needed — stream frames carry only the status, and reconnects don't replay + * what was missed — but at a heartbeat rather than a poll. + */ +export const ORCHESTRA_STREAM_BACKSTOP_INTERVAL_MS = 30000; + +/** + * Don't retry a refusal the server has already decided on. A 4xx here is a + * verdict — an amount outside the route bounds, a wallet that isn't ready, an + * order that isn't yours — and more attempts only delay the screen that + * explains it. Anything else gets one more go. + */ +const retryUnlessRefused = (failureCount: number, error: unknown) => + failureCount < 1 && + !(error instanceof OrchestraError && error.status >= 400 && error.status < 500); + +/** + * Where the deposit lands, how its amounts scale, and the band it has to fall + * in — one call, so the amount screen renders whole or not at all. + * + * Doubles as the availability check: the backend answers 503 when it has no + * Orchestra server key, which is how the entry point knows not to offer the + * deposit method at all. + */ +export function useOrchestraConfig(countryCode?: string, enabled = true) { + return useQuery({ + // Keyed on the country: the audience answer changes with it, and a cached + // "no" from before geo resolved would outlive the reason for it. + queryKey: [ORCHESTRA_CONFIG_KEY, countryCode], + queryFn: ({ signal }) => withRefreshToken(() => getOrchestraConfig(countryCode, signal)), + enabled, + staleTime: 5 * 60 * 1000, + retry: retryUnlessRefused, + }); +} + +/** + * Create the order and its invoice. + * + * Not retried: it allocates a real invoice, and a second attempt strands the + * first. The screen turns the failure into the error step instead. + */ +export function useCreateOrchestraOnramp() { + return useMutation({ + mutationFn: ({ amountFiatUsd, countryCode }: { amountFiatUsd: string; countryCode?: string }) => + withRefreshToken(() => createOrchestraOnramp(amountFiatUsd, countryCode)), + retry: false, + }); +} + +/** + * One order's snapshot, polled. + * + * The fallback path, and the source of truth for everything the stream doesn't + * carry — amounts, stages, `errorCode`. `useOrchestraOrderStream` passes + * `poll: false` once a live stream is up, which drops this to the slow backstop + * rather than stopping it. + * + * Polling stops on its own once the order settles. `unfulfilled` is not + * settled: a late deposit can still resume it, so it keeps being read. + */ +export function useOrchestraOrderStatus(orderId: string | undefined, options?: { poll?: boolean }) { + const poll = options?.poll ?? true; + return useQuery({ + queryKey: [ORCHESTRA_STATUS_KEY, orderId], + queryFn: ({ signal }) => withRefreshToken(() => getOrchestraStatus(orderId as string, signal)), + enabled: Boolean(orderId), + refetchInterval: query => { + const status = query.state.data?.order?.status; + if (status && ORCHESTRA_SETTLED_STATUSES.includes(status)) return false; + // A refusal does not become true by being asked again, and the interval + // is independent of `retry` — without this an unauthorised or expired + // read would fire every three seconds for as long as the screen is open. + const error = query.state.error; + if (error instanceof OrchestraError && error.status >= 400 && error.status < 500) { + return false; + } + return poll ? ORCHESTRA_POLL_INTERVAL_MS : ORCHESTRA_STREAM_BACKSTOP_INTERVAL_MS; + }, + retry: retryUnlessRefused, + }); +} diff --git a/hooks/useOrchestraOrderStream.ts b/hooks/useOrchestraOrderStream.ts new file mode 100644 index 000000000..9bab87ea9 --- /dev/null +++ b/hooks/useOrchestraOrderStream.ts @@ -0,0 +1,94 @@ +import { useEffect, useState } from 'react'; +import { useQueryClient } from '@tanstack/react-query'; + +import { ORCHESTRA_STATUS_KEY, useOrchestraOrderStatus } from '@/hooks/useOrchestra'; +import { orchestraStreamUrl } from '@/lib/api/orchestra'; +import { OrchestraError } from '@/lib/orchestraErrors'; +import { ORCHESTRA_SETTLED_STATUSES } from '@/lib/types/orchestra'; + +import type { OrchestraStatus } from '@/lib/types/orchestra'; + +/** + * Live status for one Orchestra order: our backend's SSE proxy where it can be + * reached, the documented 3-second poll everywhere else. + * + * `EventSource` is a browser API — React Native ships none and the app carries + * no polyfill — so native builds poll, which the docs name as the supported + * fallback rather than a degraded mode. It also cannot set headers, which is + * why the stream is cookie-authenticated: that is how web sessions authenticate + * here anyway, and native never opens one. + * + * The poll keeps running underneath at a slow backstop while the stream is + * healthy. Frames carry only the status, and a reconnect does not replay missed + * transitions, so the snapshot stays the thing that reconciles. + */ +export function useOrchestraOrderStream(orderId: string | undefined) { + const queryClient = useQueryClient(); + const [streamedStatus, setStreamedStatus] = useState(); + const [isStreaming, setIsStreaming] = useState(false); + + const { data, error } = useOrchestraOrderStatus(orderId, { poll: !isStreaming }); + + // The snapshot wins when it has one. The stream is faster off the mark, but + // the poll it triggers is what carries the amounts beside the status, and two + // sources disagreeing on screen is worse than a beat of lag. + const status = data?.order?.status ?? streamedStatus; + const isSettled = status ? ORCHESTRA_SETTLED_STATUSES.includes(status) : false; + + // A 4xx on the snapshot — a lapsed session, an order that is not ours — is + // not a hiccup the stream can route around: the same credentials open it. + const isUnreadable = error instanceof OrchestraError && error.status >= 400 && error.status < 500; + + useEffect(() => { + if (!orderId || isSettled || isUnreadable) return; + if (typeof EventSource === 'undefined') return; + + const source = new EventSource(orchestraStreamUrl(orderId), { withCredentials: true }); + + const onStatus = (event: MessageEvent) => { + setIsStreaming(true); + let next: OrchestraStatus | undefined; + try { + next = (JSON.parse(event.data) as { status?: OrchestraStatus }).status; + } catch { + // A frame we can't read is no reason to drop the stream — the snapshot + // is still being read underneath and will catch the same transition. + return; + } + if (!next) return; + setStreamedStatus(next); + // The frame carries the state and nothing else, so pull the snapshot that + // goes with it: amounts and errorCode should land with the change. + void queryClient.invalidateQueries({ queryKey: [ORCHESTRA_STATUS_KEY, orderId] }); + }; + + // EventSource reconnects on its own, so an error means "not connected right + // now", not "give up". Dropping the flag hands the screen back to the fast + // poll until a frame proves otherwise. + const onError = () => setIsStreaming(false); + + source.addEventListener('status', onStatus as EventListener); + source.addEventListener('error', onError); + + return () => { + source.removeEventListener('status', onStatus as EventListener); + source.removeEventListener('error', onError); + source.close(); + setIsStreaming(false); + }; + }, [orderId, isSettled, isUnreadable, queryClient]); + + return { + status, + order: data?.order ?? null, + stages: data?.stages ?? [], + /** True once a frame has arrived; always false on native. */ + isStreaming, + /** Only set when the *snapshot* read failed — a dead stream is not an error. */ + error, + /** The order can no longer be read at all; nothing further will arrive. */ + isUnreadable, + }; +} + +export default useOrchestraOrderStream; diff --git a/hooks/useVirtualAccountEntry.ts b/hooks/useVirtualAccountEntry.ts new file mode 100644 index 000000000..c138cbb54 --- /dev/null +++ b/hooks/useVirtualAccountEntry.ts @@ -0,0 +1,55 @@ +import { useState } from 'react'; + +import { DEPOSIT_MODAL } from '@/constants/modals'; +import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { useOnrampAutomation } from '@/hooks/useOnrampAutomation'; +import { useVirtualAccountProvider } from '@/hooks/useVirtualAccountProvider'; +import { track } from '@/lib/analytics'; +import { RainApplicationStatus } from '@/lib/types'; +import { useDepositStore } from '@/store/useDepositStore'; + +/** + * Opening the USD virtual account — the ACH/wire rail. + * + * Extracted from DepositCashOptions because two screens now reach it: the cash + * list directly, where Cash App is unavailable, and the USD method chooser, + * where it is one of two options. Duplicating the provider routing would have + * been the second copy to drift. + * + * A Wirex user has no Rain automation and never will, so the Rain apply pitch is + * not their next step — their details screen owns activation for both rails. + * Sending them to the pitch is how they reached a "Verify now" that could only + * bounce them off the Rain KYC gate. + */ +export const useVirtualAccountEntry = () => { + const setModal = useDepositStore(state => state.setModal); + const [isApplyOpen, setIsApplyOpen] = useState(false); + + const { data: cardStatus } = useCardStatus(); + const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED; + const { data: existingAutomation } = useOnrampAutomation(isRainApproved); + const { provider } = useVirtualAccountProvider(); + + const open = () => { + track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, { + deposit_method: 'bank_transfer', + provider, + }); + + if (provider === 'wirex' || existingAutomation) { + setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS); + return; + } + setIsApplyOpen(true); + }; + + return { + open, + provider, + isApplyOpen, + closeApply: () => setIsApplyOpen(false), + }; +}; + +export default useVirtualAccountEntry; diff --git a/lib/api/orchestra.ts b/lib/api/orchestra.ts new file mode 100644 index 000000000..07b31f61d --- /dev/null +++ b/lib/api/orchestra.ts @@ -0,0 +1,112 @@ +import { getJWTToken, getPlatformHeaders } from '@/lib/api'; +import { EXPO_PUBLIC_FLASH_API_BASE_URL } from '@/lib/config'; +import { toOrchestraError } from '@/lib/orchestraErrors'; + +import type { + OrchestraConfig, + OrchestraOnrampOrder, + OrchestraStatusResponse, +} from '@/lib/types/orchestra'; + +/** + * Flashnet Orchestra's Lightning onramp, through our own backend. + * + * Nothing here talks to Orchestra directly. The accounts service holds the + * Orchestra *server* key and proxies every call, which buys two things a + * device-held client key cannot: the recipient address is resolved from the + * session rather than sent in a body the user controls, and a status read is + * checked against that address, so an order id is not enough to read someone + * else's deposit. + * + * Backend: apps/flash-accounts-service/src/orchestra in solid-backend. + */ + +const ORCHESTRA_BASE = `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/orchestra`; + +const orchestraHeaders = () => { + const jwt = getJWTToken(); + return { + Accept: 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }; +}; + +/** + * Destination, its decimals, the live fiat band, and whether this account may + * use the onramp — one call, because the amount screen cannot render without + * all of it and three round trips to show one form is three chances to + * half-render it. + * + * The country goes to the server rather than being judged here: the audience + * rule is "supported region **or** allowlisted", and only the server knows the + * second half. It has no geoip, so the first half has to be told to it. + */ +export const getOrchestraConfig = async ( + countryCode?: string, + signal?: AbortSignal, +): Promise => { + const query = countryCode ? `?${new URLSearchParams({ countryCode })}` : ''; + const response = await fetch(`${ORCHESTRA_BASE}/config${query}`, { + method: 'GET', + headers: orchestraHeaders(), + credentials: 'include', + signal, + }); + + if (!response.ok) throw await toOrchestraError(response); + return response.json(); +}; + +/** + * Create the order and its invoice. + * + * Only the amount is sent. The recipient is whatever Safe the authenticated + * user owns, decided server-side — passing one from here would be a field the + * server has to distrust anyway. + */ +export const createOrchestraOnramp = async ( + amountFiatUsd: string, + countryCode?: string, +): Promise => { + const response = await fetch(`${ORCHESTRA_BASE}/onramp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', ...orchestraHeaders() }, + credentials: 'include', + body: JSON.stringify({ amountFiatUsd, ...(countryCode ? { countryCode } : {}) }), + }); + + if (!response.ok) throw await toOrchestraError(response); + return response.json(); +}; + +/** + * One order's snapshot. Answers `{ order: null }` until the Lightning payment + * is detected, which is most of the status screen's life — and, deliberately, + * for an order that was never ours, so a guessed id tells the caller nothing. + */ +export const getOrchestraStatus = async ( + orderId: string, + signal?: AbortSignal, +): Promise => { + const response = await fetch(`${ORCHESTRA_BASE}/orders/${encodeURIComponent(orderId)}`, { + method: 'GET', + headers: orchestraHeaders(), + credentials: 'include', + signal, + }); + + if (!response.ok) throw await toOrchestraError(response); + return response.json(); +}; + +/** + * URL for the order's SSE stream on our backend. + * + * No token in the query: EventSource cannot set headers, but it can send + * cookies with `withCredentials`, and web sessions are cookie-authenticated + * here. Native has no EventSource at all and takes the polling path, so the + * header-only JWT it uses is never needed for a stream. + */ +export const orchestraStreamUrl = (orderId: string): string => + `${ORCHESTRA_BASE}/orders/${encodeURIComponent(orderId)}/stream`; diff --git a/lib/config.ts b/lib/config.ts index 469174aa4..a8bb14988 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -91,6 +91,17 @@ export const EXPO_PUBLIC_PERSONA_RAIN_TEMPLATE_ID = export const EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID = process.env.EXPO_PUBLIC_PERSONA_SANDBOX_ENVIRONMENT_ID ?? ''; export const EXPO_PUBLIC_COINGECKO_API_KEY = process.env.EXPO_PUBLIC_COINGECKO_API_KEY ?? ''; + +/** + * Flashnet Orchestra — the Lightning fiat onramp. + * + * No key and no base URL live here any more. The accounts service holds the + * Orchestra *server* key and proxies every call, so the app reaches Orchestra + * only through EXPO_PUBLIC_FLASH_API_BASE_URL like any other backend feature. + * Whether the deposit method is available is the backend's answer too — its + * /orchestra/config route 503s when it has no key — so there is nothing to + * gate on here either. + */ export const isProduction = EXPO_PUBLIC_ENVIRONMENT === 'production'; // In-development features: visible on qa/preview builds, hidden in production. export const isDevFeatureEnabled = !isProduction; diff --git a/lib/orchestraErrors.ts b/lib/orchestraErrors.ts new file mode 100644 index 000000000..f993956e1 --- /dev/null +++ b/lib/orchestraErrors.ts @@ -0,0 +1,179 @@ +/** + * Client side of the Orchestra error contract. + * + * Orchestra answers failures with `{"error":{"code","message"}}` — a nested body + * rather than TransFi's flat one, and a `message` written for an integrator + * rather than for the person holding the phone. So unlike `transfiErrors.ts`, + * this module supplies the user-facing copy as well: the code decides what we + * say, and the raw message is kept only for telemetry. + * + * Codes come from https://docs.flashnet.xyz/api/errors. + */ + +/** What the screen offers next. Mirrors the shape the TransFi error screen uses. */ +export type OrchestraErrorAction = 'retry' | 'adjust_amount' | 'contact_support' | 'none'; + +export const ORCHESTRA_ERROR_CODE = { + AMOUNT_TOO_SMALL: 'amount_too_small', + AMOUNT_TOO_LARGE: 'amount_too_large', + ROUTE_UNAVAILABLE: 'route_unavailable', + ROUTE_DISABLED: 'route_disabled', + UNSUPPORTED_ROUTE: 'unsupported_route', + AMOUNT_EXCEEDS_LIQUIDITY: 'amount_exceeds_liquidity', + PRICE_IMPACT_TOO_HIGH: 'price_impact_too_high', + SPOT_UNAVAILABLE: 'spot_unavailable', + RATE_LIMITED: 'rate_limited', + ORIGIN_NOT_ALLOWED: 'origin_not_allowed', + ORIGIN_REQUIRED: 'origin_required', + READ_TOKEN_REQUIRED: 'read_token_required', + INVALID_READ_TOKEN: 'invalid_read_token', + SCOPE_REQUIRED: 'scope_required', + ACCOUNT_PENDING_APPROVAL: 'account_pending_approval', + // Codes our own backend raises, as opposed to Orchestra's. + NOT_CONFIGURED: 'ORCHESTRA_NOT_CONFIGURED', + NO_WALLET_ADDRESS: 'ORCHESTRA_NO_WALLET_ADDRESS', + ORDER_NOT_YOURS: 'ORCHESTRA_ORDER_NOT_YOURS', + NOT_IN_AUDIENCE: 'ORCHESTRA_NOT_IN_AUDIENCE', + UNKNOWN: 'unknown_error', +} as const; + +const GENERIC_MESSAGE = + 'We couldn’t start this deposit. Nothing was charged — please try again in a moment.'; + +/** + * An onramp failure in the shape the screens render. + * + * Carries `status` so it still satisfies `isHTTPError`, and `action` so the + * error screen knows which button to show without re-deriving it from the code. + */ +export class OrchestraError extends Error { + readonly name = 'OrchestraError'; + + constructor( + readonly code: string, + readonly action: OrchestraErrorAction, + message: string, + readonly status: number, + /** Orchestra's own wording, for Sentry and Amplitude — not for the screen. */ + readonly rawMessage?: string, + ) { + super(message); + } +} + +/** + * What the user is told, by code. + * + * Anything not listed falls through to the generic retry line. The + * configuration and scope failures deliberately say something bland: a user can + * do nothing about a missing client key or a disallowed origin, and the code + * itself is what the on-call engineer needs, which telemetry already carries. + */ +const MESSAGE_BY_CODE: Record = { + [ORCHESTRA_ERROR_CODE.AMOUNT_TOO_SMALL]: 'That amount is below the minimum for this deposit.', + [ORCHESTRA_ERROR_CODE.AMOUNT_TOO_LARGE]: 'That amount is above the maximum for this deposit.', + [ORCHESTRA_ERROR_CODE.AMOUNT_EXCEEDS_LIQUIDITY]: + 'That amount is more than we can convert right now. Try a smaller one.', + [ORCHESTRA_ERROR_CODE.PRICE_IMPACT_TOO_HIGH]: + 'The rate moved too far to complete this. Try a smaller amount.', + [ORCHESTRA_ERROR_CODE.ROUTE_UNAVAILABLE]: 'This deposit route is unavailable right now.', + [ORCHESTRA_ERROR_CODE.ROUTE_DISABLED]: 'This deposit route is unavailable right now.', + [ORCHESTRA_ERROR_CODE.UNSUPPORTED_ROUTE]: 'This deposit route is unavailable right now.', + [ORCHESTRA_ERROR_CODE.SPOT_UNAVAILABLE]: + 'We can’t price a USD amount at the moment. Please try again shortly.', + [ORCHESTRA_ERROR_CODE.RATE_LIMITED]: 'Too many attempts. Please wait a moment and try again.', + // Not a user problem and not retryable: Flashnet reviews new partner accounts + // before activating their keys, so the key is valid and simply not live yet. + // Without this it fell through to "try again in a moment", which is advice + // that can only waste the reader's time. + [ORCHESTRA_ERROR_CODE.ACCOUNT_PENDING_APPROVAL]: + 'This deposit method isn’t live yet. (Flashnet hasn’t approved the account.)', + [ORCHESTRA_ERROR_CODE.ORIGIN_NOT_ALLOWED]: 'This deposit method isn’t available here.', + [ORCHESTRA_ERROR_CODE.SCOPE_REQUIRED]: 'This deposit method isn’t available right now.', + [ORCHESTRA_ERROR_CODE.NOT_CONFIGURED]: + 'This deposit method isn’t available yet. (The server has no Orchestra key.)', + [ORCHESTRA_ERROR_CODE.NO_WALLET_ADDRESS]: 'Your wallet isn’t ready yet. Try again in a moment.', + [ORCHESTRA_ERROR_CODE.ORDER_NOT_YOURS]: 'We couldn’t find that deposit.', + [ORCHESTRA_ERROR_CODE.NOT_IN_AUDIENCE]: 'Cash App deposits aren’t available on your account yet.', + // Order-level failures, read off order.errorCode rather than an HTTP body. + slippage_exceeded: 'The rate moved while we were converting. Your payment is being refunded.', + refund_address_missing: 'Something went wrong and we couldn’t refund automatically.', + duplicate_lightning_invoice: 'That invoice has already been paid.', +}; + +const ACTION_BY_CODE: Record = { + [ORCHESTRA_ERROR_CODE.AMOUNT_TOO_SMALL]: 'adjust_amount', + [ORCHESTRA_ERROR_CODE.AMOUNT_TOO_LARGE]: 'adjust_amount', + [ORCHESTRA_ERROR_CODE.AMOUNT_EXCEEDS_LIQUIDITY]: 'adjust_amount', + [ORCHESTRA_ERROR_CODE.PRICE_IMPACT_TOO_HIGH]: 'adjust_amount', + [ORCHESTRA_ERROR_CODE.ROUTE_UNAVAILABLE]: 'none', + [ORCHESTRA_ERROR_CODE.ROUTE_DISABLED]: 'none', + [ORCHESTRA_ERROR_CODE.UNSUPPORTED_ROUTE]: 'none', + [ORCHESTRA_ERROR_CODE.NOT_CONFIGURED]: 'none', + [ORCHESTRA_ERROR_CODE.NO_WALLET_ADDRESS]: 'retry', + [ORCHESTRA_ERROR_CODE.ORDER_NOT_YOURS]: 'none', + [ORCHESTRA_ERROR_CODE.NOT_IN_AUDIENCE]: 'none', + [ORCHESTRA_ERROR_CODE.ORIGIN_NOT_ALLOWED]: 'contact_support', + [ORCHESTRA_ERROR_CODE.ORIGIN_REQUIRED]: 'contact_support', + [ORCHESTRA_ERROR_CODE.SCOPE_REQUIRED]: 'contact_support', + [ORCHESTRA_ERROR_CODE.ACCOUNT_PENDING_APPROVAL]: 'contact_support', + refund_address_missing: 'contact_support', + slippage_exceeded: 'none', +}; + +const TITLE_BY_ACTION: Record = { + retry: 'Something went wrong', + adjust_amount: 'Amount not accepted', + contact_support: 'We can’t complete this', + none: 'Deposit unavailable', +}; + +export const orchestraErrorTitle = (error: OrchestraError): string => TITLE_BY_ACTION[error.action]; + +/** Build an error from a code alone — used for order-level failures from /status. */ +export const orchestraErrorFromCode = (code: string | undefined, status = 0): OrchestraError => { + const resolved = code ?? ORCHESTRA_ERROR_CODE.UNKNOWN; + return new OrchestraError( + resolved, + ACTION_BY_CODE[resolved] ?? 'retry', + MESSAGE_BY_CODE[resolved] ?? GENERIC_MESSAGE, + status, + code, + ); +}; + +/** + * Read a failed fetch into an OrchestraError. + * + * Orchestra nests the body under `error`, but a 502 from a gateway or an empty + * 503 arrives as HTML or nothing at all — both fall through to the generic + * retry line rather than surfacing a parse failure the user can't act on. + */ +export const toOrchestraError = async (response: Response): Promise => { + let body: { error?: { code?: unknown; message?: unknown } } | undefined; + try { + body = (await response.json()) as typeof body; + } catch { + body = undefined; + } + const code = typeof body?.error?.code === 'string' ? body.error.code : undefined; + const rawMessage = typeof body?.error?.message === 'string' ? body.error.message : undefined; + const error = orchestraErrorFromCode(code, response.status); + return new OrchestraError(error.code, error.action, error.message, response.status, rawMessage); +}; + +/** + * Coerce anything thrown during the flow into something the screens can render. + * A dropped connection reaches the mutation as a bare TypeError, and "Failed to + * fetch" is not an explanation. + */ +export const asOrchestraError = (error: unknown): OrchestraError => { + if (error instanceof OrchestraError) return error; + return new OrchestraError( + ORCHESTRA_ERROR_CODE.UNKNOWN, + 'retry', + GENERIC_MESSAGE, + 0, + error instanceof Error ? error.message : undefined, + ); +}; diff --git a/lib/orchestraFormat.ts b/lib/orchestraFormat.ts new file mode 100644 index 000000000..3b725a389 --- /dev/null +++ b/lib/orchestraFormat.ts @@ -0,0 +1,87 @@ +/** + * Formatting for Orchestra's wire amounts. + * + * Every amount on the wire is an integer string in the asset's smallest unit. + * They are parsed as BigInt rather than Number: 50,000 USD of a 6-decimal asset + * is 5e10, and a 8-decimal one puts BTC amounts well past where a double stops + * being exact. + */ + +/** + * The locale's group and decimal separators, discovered once from a plain + * Number. + * + * Intl is only ever handed a Number in this module. `Intl.NumberFormat.format` + * is specified to accept a BigInt, but the runtimes this app ships on — Hermes, + * and the Intl shim under React Native Web — coerce the argument with ToNumber + * first, which throws "Cannot convert a BigInt value to a number". Node's Intl + * does accept one, so a unit test will not catch it; only the app will. + * + * Grouping the digit string ourselves keeps the exactness BigInt was chosen for + * and never puts one in front of Intl. + */ +const SEPARATORS = (() => { + try { + const parts = new Intl.NumberFormat(undefined).formatToParts(11111.1); + return { + group: parts.find(part => part.type === 'group')?.value ?? ',', + decimal: parts.find(part => part.type === 'decimal')?.value ?? '.', + }; + } catch { + return { group: ',', decimal: '.' }; + } +})(); + +/** Insert the locale's thousands separator into a plain digit string. */ +const groupDigits = (digits: string): string => + digits.replace(/^0+(?=\d)/, '').replace(/\B(?=(\d{3})+(?!\d))/g, SEPARATORS.group); + +/** + * Render a smallest-unit integer string as a decimal. + * + * Returns undefined for anything that isn't one, so a caller can distinguish + * "not priced yet" from "priced at zero" — a field Orchestra omitted and a + * field it set to "0" mean different things on the quote breakdown. + */ +export const formatSmallestUnits = ( + value: string | undefined, + decimals: number | undefined, + maximumFractionDigits = 2, +): string | undefined => { + if (value == null || decimals == null) return undefined; + if (!/^-?\d+$/.test(value)) return undefined; + + const negative = value.startsWith('-'); + const digits = (negative ? value.slice(1) : value).padStart(decimals + 1, '0'); + const whole = digits.slice(0, digits.length - decimals); + const fraction = decimals > 0 ? digits.slice(digits.length - decimals) : ''; + + // Round by carrying on the first dropped digit, so the value never passes + // through a float. `padEnd` matters when the asset has fewer decimals than we + // are willing to show: 2-decimal "4975" asked for 4 places is 49.75, not + // 0.4975. + const kept = fraction.slice(0, maximumFractionDigits).padEnd(maximumFractionDigits, '0'); + const nextDigit = fraction.charCodeAt(maximumFractionDigits) - 48; + const rounded = BigInt(whole + kept) + (nextDigit >= 5 ? 1n : 0n); + + const scale = 10n ** BigInt(maximumFractionDigits); + const unit = rounded / scale; + const rest = (rounded % scale).toString().padStart(maximumFractionDigits, '0'); + + const formattedUnit = groupDigits(unit.toString()); + const trimmed = rest.replace(/0+$/, ''); + return `${negative ? '-' : ''}${formattedUnit}${ + trimmed ? `${SEPARATORS.decimal}${trimmed}` : '' + }`; +}; + +/** Sats, grouped — "44,210 sats". Already a digit string; no BigInt needed. */ +export const formatSats = (value: string | undefined): string | undefined => + value == null || !/^\d+$/.test(value) ? undefined : `${groupDigits(value)} sats`; + +export const formatUsd = (value: number): string => + new Intl.NumberFormat(undefined, { + style: 'currency', + currency: 'USD', + maximumFractionDigits: 2, + }).format(value); diff --git a/lib/types/orchestra.ts b/lib/types/orchestra.ts new file mode 100644 index 000000000..5e19e0390 --- /dev/null +++ b/lib/types/orchestra.ts @@ -0,0 +1,175 @@ +/** + * Flashnet Orchestra — Lightning fiat onramp. + * + * Mirrors the wire shapes at https://docs.flashnet.xyz/orchestra/onramp. Amounts + * are integer strings in the asset's smallest unit throughout, never numbers: + * sats on the Lightning leg, the destination asset's own decimals on the other, + * and JSON numbers would lose precision on both. + */ + +/** Orchestra states while the order is still moving. See /orchestra/status. */ +export type OrchestraInFlightStatus = + | 'processing' + | 'confirming' + | 'awaiting_approval' + | 'swapping' + | 'bridging' + | 'delivering' + | 'refunding'; + +/** + * Terminal-ish states. `unfulfilled` is the odd one: the deposit was never + * confirmed or was replaced, and a late payment can still resume the order + * during the six-hour recovery window — so it does not close the stream. + */ +export type OrchestraOutcomeStatus = + | 'completed' + | 'refunded' + | 'failed' + | 'expired' + | 'unfulfilled'; + +export type OrchestraStatus = OrchestraInFlightStatus | OrchestraOutcomeStatus; + +/** + * Nothing more will arrive without the user starting over, so tracking can stop + * — both the poll and our end of the SSE stream. + * + * The server closes the stream on `completed`, `failed` and `refunded`, and + * leaves it open on `unfulfilled` because a late deposit can still resume the + * order during the six-hour recovery window. `expired` is ours to add: the + * server says nothing about it, and an order that expired without a deposit has + * nothing left to report. + */ +export const ORCHESTRA_SETTLED_STATUSES: readonly OrchestraStatus[] = [ + 'completed', + 'failed', + 'refunded', + 'expired', +]; + +/** Settled without delivering. */ +export const ORCHESTRA_FAILED_STATUSES: readonly OrchestraStatus[] = [ + 'failed', + 'refunded', + 'expired', +]; + +/** `exact_in` spends a fixed input and fees reduce delivery; `exact_out` fixes delivery. */ +export type OrchestraAmountMode = 'exact_in' | 'exact_out'; + +export interface OrchestraPaymentLinks { + /** Deep link that opens Cash App on the invoice. Navigate to it — never fetch it. */ + cashApp?: string; + /** Orchestra-hosted handoff page (mobile) / QR page (desktop) for this order. */ + shortUrl?: string; +} + +/** Request body for POST /v1/orchestration/onramp. */ +export interface OrchestraOnrampRequest { + destinationChain: string; + destinationAsset: string; + recipientAddress: string; + /** USD string, "1.00" to "50000.00". Mutually exclusive with `amount`. */ + amountFiatUsd?: string; + /** Smallest units — sats for exact-in, destination units for exact-out. */ + amount?: string; + amountMode?: OrchestraAmountMode; + /** Lightning address (user@domain) or an amountless BOLT11, used if the order fails. */ + refundAddress?: string; +} + +/** Response from POST /v1/orchestration/onramp. */ +export interface OrchestraOnrampOrder { + orderId: string; + quoteId: string; + /** The BOLT11 invoice to pay. */ + depositAddress: string; + paymentLinks?: OrchestraPaymentLinks; + /** Sats the payer sends. */ + amountIn: string; + /** Destination smallest units expected on delivery. */ + estimatedOut: string; + /** The swap fee alone. `totalFeeAmount` is the one to show a user. */ + feeAmount?: string; + /** Rounding applied on top of the swap fee; already included in the total. */ + roundingFeeAmount?: string; + /** feeAmount + roundingFeeAmount — what the deposit actually costs. */ + totalFeeAmount?: string; + /** The route's fee rate in basis points, e.g. 60 for 0.6%. */ + feeBps?: number; + feeAsset?: string; + /** + * The fee asset's own decimals, which need not match the destination's — read + * from here rather than inferred from the ticker. + */ + feeAssetDetails?: { asset?: string; assetDisplaySymbol?: string; decimals: number }; + feeAmountUsd?: string; + totalFeeAmountUsd?: string; + /** ISO timestamp. Exact-in invoices last ~24h, exact-out and fixed delivery 5 minutes. */ + expiresAt: string; + amountMode?: OrchestraAmountMode; + effectiveSlippageBps?: number; + /** BTC/USD spot the USD amount was converted at. */ + spotUsdPerBtc?: string; + /** Fields Orchestra dropped rather than rejecting the request over. */ + ignoredFields?: string[]; +} + +/** One execution milestone from the status response. */ +export interface OrchestraStage { + name: string; + completedAt?: string | null; +} + +/** The order half of GET /v1/orchestration/status. */ +export interface OrchestraOrder { + orderId?: string; + quoteId?: string; + status: OrchestraStatus; + amountIn?: string; + amountOut?: string; + estimatedOut?: string; + destinationChain?: string; + destinationAsset?: string; + recipientAddress?: string; + /** Present on failures and refunds; see the order-error codes in /api/errors. */ + errorCode?: string; + /** An `unfulfilled` order whose deposit was replaced points at its successor. */ + supersededByOperationId?: string; + recoveredFromOperationId?: string; + reviewStatus?: string; +} + +/** GET /v1/orchestration/status?id=… */ +export interface OrchestraStatusResponse { + order: OrchestraOrder | null; + stages?: OrchestraStage[]; +} + +/** The fiat band from /limits, present only when the source is Lightning BTC. */ + +/** + * GET /accounts/v1/orchestra/config — everything the amount screen needs in one + * call: where the deposit lands, how that asset's amounts are scaled, and the + * band the entered amount has to fall inside. + * + * The bounds arrive as numbers rather than the USD strings Orchestra publishes, + * because the backend has already parsed them and a form compares numbers. + */ +export interface OrchestraConfig { + /** + * Whether this account may use the onramp — the backend's answer, covering + * the launch flag and the allowlist. The row is hidden on `false`, but the + * gate that matters is on order creation, which checks again. + */ + isAvailable: boolean; + destinationChain: string; + destinationAsset: string; + /** Smallest-unit exponent for the destination; absent if /routes was unreadable. */ + decimals?: number; + assetDisplaySymbol?: string; + chainDisplayName?: string; + minUsd: number; + maxUsd: number; +} diff --git a/store/useOrchestraStore.ts b/store/useOrchestraStore.ts new file mode 100644 index 000000000..3e5b7552d --- /dev/null +++ b/store/useOrchestraStore.ts @@ -0,0 +1,41 @@ +import { create } from 'zustand'; + +import { OrchestraError } from '@/lib/orchestraErrors'; + +import type { DepositModal } from '@/lib/types'; +import type { OrchestraOnrampOrder } from '@/lib/types/orchestra'; + +/** + * Ephemeral state for the Orchestra Lightning onramp, shared across the + * Add-funds modal steps (amount → invoice → status). Not persisted: it only + * needs to survive step transitions within a single flow. + * + * The created order is kept here rather than in a react-query cache because the + * `readToken` is the only thing that can read the order back, it is returned + * exactly once by /onramp, and losing it on an unmount would leave a paid + * invoice the app can no longer track. + */ +interface OrchestraState { + /** USD the user typed, as a string — "50.00". */ + amountUsd: string; + 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; + setOrder: (order: OrchestraOnrampOrder) => void; + setError: (error: OrchestraError | null, origin?: DepositModal) => void; + reset: () => void; +} + +export const useOrchestraStore = create(set => ({ + amountUsd: '', + order: null, + error: null, + errorOrigin: null, + setAmountUsd: amountUsd => set({ amountUsd }), + setOrder: order => set({ order }), + setError: (error, origin) => set({ error, errorOrigin: origin ?? null }), + reset: () => set({ amountUsd: '', order: null, error: null, errorOrigin: null }), +})); From 3a77686d9eba730505cc7fd8d7f965918070eda7 Mon Sep 17 00:00:00 2001 From: mul53 Date: Fri, 25 Sep 2026 15:17:54 +0200 Subject: [PATCH 7/9] fix(modal): stop a long title pushing the header buttons off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The modal header is a three-part row: a 50px back button, the title, and the close button. The title's container had no flex constraint and the title itself no line limit, so a long one took its natural width and shoved both controls out of the header. They were still mounted, just off-screen — which on the Cash App amount step left a modal with no visible way back. The no-title branch already used `flex-1`; the title branch never did. It now takes the space between the two controls and truncates to one line rather than growing past them. Also shortens that step's own title from "Deposit with Cash App" to "Cash App". It is reached by tapping a row already labelled Cash App, from a screen titled "Deposit US Dollars", so the longer version was repeating context to earn a truncation. Co-Authored-By: Claude Opus 5 --- components/ResponsiveModal.tsx | 18 ++++++++++++++++-- hooks/useDepositOption.tsx | 2 +- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/components/ResponsiveModal.tsx b/components/ResponsiveModal.tsx index ebef21978..fef24ac3e 100644 --- a/components/ResponsiveModal.tsx +++ b/components/ResponsiveModal.tsx @@ -245,18 +245,32 @@ const ResponsiveModal = ({ )} {title ? ( + // `flex-1` so the title takes the space between the two 50px + // controls rather than its natural width. Without it a long + // title pushed the back and close buttons off the header + // entirely — they were still mounted, just off-screen, which + // left the step with no visible way back. {titleIcon} diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx index 4f2fdde47..cdc7f93fd 100644 --- a/hooks/useDepositOption.tsx +++ b/hooks/useDepositOption.tsx @@ -522,7 +522,7 @@ const useDepositOption = ({ if (isBuyCryptoError) return undefined; if (isOnramperWidget) return 'Buy crypto'; if (isDepositUsdMethod) return 'Deposit US Dollars'; - if (isOrchestraAmount) return 'Deposit with Cash App'; + if (isOrchestraAmount) return 'Cash App'; if (isOrchestraInvoice) return 'Pay with Cash App'; if (isOrchestraStatus) return 'Deposit status'; // Same reasoning as the TransFi error screen above. From 5a455ca86fee3d095f3e425772407d02be921451 Mon Sep 17 00:00:00 2001 From: mul53 Date: Fri, 25 Sep 2026 15:43:21 +0200 Subject: [PATCH 8/9] fix(orchestra): don't decide availability before geo resolves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The config query fired on mount with countryCode still undefined. The server correctly answers "not available" to a request that names no region — but the amount screen read that as a verdict on the user and bounced them to the error screen, a beat before the real answer arrived. The hook kept its detected country in local state rather than the country store, so every mount started at undefined and each screen re-ran the race independently. The cause was that "we don't know yet" and "we looked and couldn't tell" were the same value. They are different answers: the first must not be acted on, the second is final. The hook now reports isResolving separately from isResolved, and every /config call waits for it. This only ever hit users who are not on the allowlist — an allowlisted username short-circuits the region check server-side, which is why it survived testing on an allowlisted account. The invoice and status screens also now pass the country, so all four call sites share one cache entry instead of firing a second request under ['orchestraConfig', undefined]. Their amounts were never wrong — decimals come from /v2/routes and do not depend on the country — but that entry cached isAvailable: false, which was a trap for anyone adding a gate to those screens later. Co-Authored-By: Claude Opus 5 --- .../DepositOption/DepositCashOptions.tsx | 4 +- components/Orchestra/OrchestraAmount.tsx | 8 +++- components/Orchestra/OrchestraInvoice.tsx | 6 ++- components/Orchestra/OrchestraOrderStatus.tsx | 6 ++- .../useCashAppDepositAvailability.test.tsx | 43 +++++++++++++++++++ hooks/useCashAppDepositAvailability.ts | 22 ++++++++-- 6 files changed, 80 insertions(+), 9 deletions(-) diff --git a/components/DepositOption/DepositCashOptions.tsx b/components/DepositOption/DepositCashOptions.tsx index 7c1da3c63..418f37a3f 100644 --- a/components/DepositOption/DepositCashOptions.tsx +++ b/components/DepositOption/DepositCashOptions.tsx @@ -64,8 +64,8 @@ const DepositCashOptions = () => { // 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 } = useCashAppDepositAvailability(); - const { data: orchestraConfig } = useOrchestraConfig(countryCode); + const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability(); + const { data: orchestraConfig } = useOrchestraConfig(countryCode, !isResolvingCountry); const isCashAppAvailable = orchestraConfig?.isAvailable === true; const { isBuyCryptoAvailable } = useGeoCompliance(); const { handleBuyCryptoPress } = useBuyCryptoEntry(); diff --git a/components/Orchestra/OrchestraAmount.tsx b/components/Orchestra/OrchestraAmount.tsx index 495235b9d..2b95df88a 100644 --- a/components/Orchestra/OrchestraAmount.tsx +++ b/components/Orchestra/OrchestraAmount.tsx @@ -47,12 +47,16 @@ export const OrchestraAmount = () => { track(TRACKING_EVENTS.ORCHESTRA_AMOUNT_VIEWED); }, []); - const { countryCode } = useCashAppDepositAvailability(); + const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability(); + // Held until geo settles. Asking before then sends no country, and the server + // correctly answers "not available" to that — which the effect below would + // read as a verdict on the user rather than on an unfinished lookup, and + // bounce them to the error screen a beat before the real answer arrived. const { data: config, error: configError, isPending: configPending, - } = useOrchestraConfig(countryCode); + } = useOrchestraConfig(countryCode, !isResolvingCountry); const { mutate: createOrder, isPending: creatingOrder } = useCreateOrchestraOnramp(); // Without config there is no band to validate against and no asset to name, diff --git a/components/Orchestra/OrchestraInvoice.tsx b/components/Orchestra/OrchestraInvoice.tsx index 8d57fa042..86433f036 100644 --- a/components/Orchestra/OrchestraInvoice.tsx +++ b/components/Orchestra/OrchestraInvoice.tsx @@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; 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 { formatSats, formatSmallestUnits, formatUsd } from '@/lib/orchestraFormat'; @@ -57,7 +58,10 @@ export const OrchestraInvoice = () => { const setModal = useOrchestraNavigation(); const order = useOrchestraStore(state => state.order); const amountUsd = useOrchestraStore(state => state.amountUsd); - const { data: config } = useOrchestraConfig(); + // Same country as the amount screen, so this shares its cache entry rather + // than firing a second request under a different key. + const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability(); + const { data: config } = useOrchestraConfig(countryCode, !isResolvingCountry); const secondsLeft = useSecondsUntil(order?.expiresAt); const hasExpired = secondsLeft === 0; diff --git a/components/Orchestra/OrchestraOrderStatus.tsx b/components/Orchestra/OrchestraOrderStatus.tsx index 930f71259..632322389 100644 --- a/components/Orchestra/OrchestraOrderStatus.tsx +++ b/components/Orchestra/OrchestraOrderStatus.tsx @@ -11,6 +11,7 @@ import { Text } from '@/components/ui/text'; import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; +import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; import { useOrchestraConfig } from '@/hooks/useOrchestra'; import { useOrchestraOrderStream } from '@/hooks/useOrchestraOrderStream'; import { track } from '@/lib/analytics'; @@ -58,7 +59,10 @@ export const OrchestraOrderStatus = () => { const setModal = useOrchestraNavigation(); const reset = useOrchestraStore(state => state.reset); const storedOrder = useOrchestraStore(state => state.order); - const { data: config } = useOrchestraConfig(); + // Same country as the amount screen, so this shares its cache entry rather + // than firing a second request under a different key. + const { countryCode, isResolving: isResolvingCountry } = useCashAppDepositAvailability(); + const { data: config } = useOrchestraConfig(countryCode, !isResolvingCountry); const { status, order, isUnreadable } = useOrchestraOrderStream(storedOrder?.orderId); diff --git a/hooks/__tests__/useCashAppDepositAvailability.test.tsx b/hooks/__tests__/useCashAppDepositAvailability.test.tsx index 6fca0ffb5..4f140e7d6 100644 --- a/hooks/__tests__/useCashAppDepositAvailability.test.tsx +++ b/hooks/__tests__/useCashAppDepositAvailability.test.tsx @@ -97,4 +97,47 @@ describe('useCashAppDepositAvailability', () => { isResolved: false, }); }); + + describe('isResolving', () => { + it('reports resolving until the lookup settles, so nothing is decided early', async () => { + // The race this guards: a caller that treats the first render as an + // answer sends no country to /config, gets a correct "not available" for + // that question, and reads it as a verdict on the user. + let resolveGeo: (value: null) => void = () => {}; + mockDetectGeo.mockReturnValue( + new Promise(resolve => { + resolveGeo = resolve as (value: null) => void; + }), + ); + + let latest: Result | undefined; + const Probe = () => { + latest = useCashAppDepositAvailability(); + return null; + }; + await act(async () => { + create(); + }); + + expect(latest?.isResolving).toBe(true); + expect(latest?.countryCode).toBeUndefined(); + + await act(async () => { + resolveGeo(null); + }); + + // Settled with no answer is still settled — callers may now act on it. + expect(latest?.isResolving).toBe(false); + expect(latest?.isAvailable).toBe(false); + }); + + it('is settled immediately when the country is already known', async () => { + storeCountry('US'); + + const result = await renderHook(); + + expect(result.isResolving).toBe(false); + expect(mockDetectGeo).not.toHaveBeenCalled(); + }); + }); }); diff --git a/hooks/useCashAppDepositAvailability.ts b/hooks/useCashAppDepositAvailability.ts index 7261cdf3e..93bcecb6c 100644 --- a/hooks/useCashAppDepositAvailability.ts +++ b/hooks/useCashAppDepositAvailability.ts @@ -26,13 +26,27 @@ export const useCashAppDepositAvailability = () => { const countryInfo = useCountryStore(state => state.countryInfo); const storedCode = countryInfo?.countryCode; const [detected, setDetected] = useState<{ countryCode: string; region?: string }>(); + /** + * Whether the lookup has finished, separately from whether it found anything. + * + * These are different answers and callers need to tell them apart: "we don't + * know yet" must not be acted on, while "we looked and couldn't tell" is a + * real, final answer. Conflating them let a caller treat the first render — + * before detectGeo resolves — as a country the user isn't in. + */ + const [isSettled, setIsSettled] = useState(false); useEffect(() => { - if (storedCode) return; + if (storedCode) { + setIsSettled(true); + return; + } let cancelled = false; void detectGeo().then(geo => { - if (cancelled || !geo) return; - setDetected({ countryCode: geo.countryCode, region: geo.region }); + if (cancelled) return; + if (geo) setDetected({ countryCode: geo.countryCode, region: geo.region }); + // Settled either way: a failed lookup is an answer, not a pending one. + setIsSettled(true); }); return () => { cancelled = true; @@ -44,6 +58,8 @@ export const useCashAppDepositAvailability = () => { return { isAvailable: countryCode === 'US', + /** True while the lookup is still in flight; nothing should be decided yet. */ + isResolving: !isSettled, /** False while the country is still being resolved. */ isResolved: Boolean(countryCode), countryCode, From 68a734e9a91795afa140f5645205fa59ee6a58dc Mon Sep 17 00:00:00 2001 From: mul53 Date: Fri, 25 Sep 2026 15:53:05 +0200 Subject: [PATCH 9/9] fix(orchestra): share the resolved country across screens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gating /config on the geo lookup fixed the false "not available" bounce, but left each screen restarting the lookup from scratch: the hook kept its result in component state, so arriving at the invoice screen held its config call off for a frame and flashed empty amounts. detectGeo already memoises the request; what was missing was the settled result. Holding it at module scope lets a later mount start with the answer in hand — its first render is already settled, which is what the new test asserts rather than whatever state the mount eventually reaches. Exports a reset for tests, because module state would otherwise leak between cases. Also replaces the reassign-an-outer-binding pattern in this spec with collection into an array: the react-compiler rule counts the former as a render side-effect, and the file had been failing that rule. Co-Authored-By: Claude Opus 5 --- .../useCashAppDepositAvailability.test.tsx | 53 +++++++++++++++---- hooks/useCashAppDepositAvailability.ts | 36 +++++++++++-- 2 files changed, 74 insertions(+), 15 deletions(-) diff --git a/hooks/__tests__/useCashAppDepositAvailability.test.tsx b/hooks/__tests__/useCashAppDepositAvailability.test.tsx index 4f140e7d6..2ecd35c6e 100644 --- a/hooks/__tests__/useCashAppDepositAvailability.test.tsx +++ b/hooks/__tests__/useCashAppDepositAvailability.test.tsx @@ -1,6 +1,9 @@ import React from 'react'; -import { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; +import { + __resetGeoSessionCache, + useCashAppDepositAvailability, +} from '@/hooks/useCashAppDepositAvailability'; import { detectGeo } from '@/lib/geo'; import { useCountryStore } from '@/store/useCountryStore'; @@ -24,15 +27,17 @@ type Result = ReturnType; /** Render the hook and return its latest value. */ const renderHook = async (): Promise => { - let latest: Result | undefined; + // Collected rather than assigned: the react-compiler rule treats reassigning + // an outer binding from a component as a render side-effect. + const seen: Result[] = []; const Probe = () => { - latest = useCashAppDepositAvailability(); + seen.push(useCashAppDepositAvailability()); return null; }; await act(async () => { create(); }); - return latest as Result; + return seen[seen.length - 1]; }; const storeCountry = (countryCode: string, state?: string) => @@ -47,6 +52,7 @@ const storeCountry = (countryCode: string, state?: string) => describe('useCashAppDepositAvailability', () => { beforeEach(() => { jest.clearAllMocks(); + __resetGeoSessionCache(); useCountryStore.getState().clearCountryInfo(); }); @@ -110,25 +116,27 @@ describe('useCashAppDepositAvailability', () => { }), ); - let latest: Result | undefined; + const seen: Result[] = []; const Probe = () => { - latest = useCashAppDepositAvailability(); + seen.push(useCashAppDepositAvailability()); return null; }; await act(async () => { create(); }); - expect(latest?.isResolving).toBe(true); - expect(latest?.countryCode).toBeUndefined(); + const whileDetecting = seen[seen.length - 1]; + expect(whileDetecting.isResolving).toBe(true); + expect(whileDetecting.countryCode).toBeUndefined(); await act(async () => { resolveGeo(null); }); // Settled with no answer is still settled — callers may now act on it. - expect(latest?.isResolving).toBe(false); - expect(latest?.isAvailable).toBe(false); + const afterDetecting = seen[seen.length - 1]; + expect(afterDetecting.isResolving).toBe(false); + expect(afterDetecting.isAvailable).toBe(false); }); it('is settled immediately when the country is already known', async () => { @@ -140,4 +148,29 @@ describe('useCashAppDepositAvailability', () => { expect(mockDetectGeo).not.toHaveBeenCalled(); }); }); + + it('a second mount starts settled, so a later screen does not re-gate', async () => { + // Without the shared result each screen restarted unsettled and held its + // /config call off for a frame, flashing empty amounts on arrival. + mockDetectGeo.mockResolvedValue({ + countryCode: 'US', + countryName: 'United States', + }); + + await renderHook(); + + // Capture every render, so the assertion is about the *first* one rather + // than whatever state the mount settles into. + const seen: Result[] = []; + const Probe = () => { + seen.push(useCashAppDepositAvailability()); + return null; + }; + await act(async () => { + create(); + }); + + expect(seen[0].isResolving).toBe(false); + expect(seen[0].countryCode).toBe('US'); + }); }); diff --git a/hooks/useCashAppDepositAvailability.ts b/hooks/useCashAppDepositAvailability.ts index 93bcecb6c..b05e4c8ec 100644 --- a/hooks/useCashAppDepositAvailability.ts +++ b/hooks/useCashAppDepositAvailability.ts @@ -3,6 +3,24 @@ import { useEffect, useState } from 'react'; import { detectGeo } from '@/lib/geo'; import { useCountryStore } from '@/store/useCountryStore'; +/** + * The lookup's outcome, shared across every mount for the session. + * + * `detectGeo` already memoises the request, but each hook instance still began + * unsettled and had to wait a microtask for the cached promise — so arriving at + * a later screen gated its config call off for a frame and flashed empty + * amounts. Holding the settled result here lets a second mount start with the + * answer already in hand. + */ +let sessionGeo: { countryCode: string; region?: string } | undefined; +let sessionSettled = false; + +/** Test seam: the module cache would otherwise leak between cases. */ +export const __resetGeoSessionCache = () => { + sessionGeo = undefined; + sessionSettled = false; +}; + /** * Whether the Cash App / Lightning deposit can be offered here. * @@ -25,7 +43,7 @@ import { useCountryStore } from '@/store/useCountryStore'; export const useCashAppDepositAvailability = () => { const countryInfo = useCountryStore(state => state.countryInfo); const storedCode = countryInfo?.countryCode; - const [detected, setDetected] = useState<{ countryCode: string; region?: string }>(); + const [detected, setDetected] = useState(sessionGeo); /** * Whether the lookup has finished, separately from whether it found anything. * @@ -34,18 +52,26 @@ export const useCashAppDepositAvailability = () => { * real, final answer. Conflating them let a caller treat the first render — * before detectGeo resolves — as a country the user isn't in. */ - const [isSettled, setIsSettled] = useState(false); + const [isSettled, setIsSettled] = useState(sessionSettled); useEffect(() => { - if (storedCode) { + if (storedCode || sessionSettled) { setIsSettled(true); return; } let cancelled = false; void detectGeo().then(geo => { - if (cancelled) return; - if (geo) setDetected({ countryCode: geo.countryCode, region: geo.region }); + // The cache is set even if this instance unmounted — the answer is the + // session's, not this component's. + if (cancelled) { + sessionGeo = geo ? { countryCode: geo.countryCode, region: geo.region } : undefined; + sessionSettled = true; + return; + } + sessionGeo = geo ? { countryCode: geo.countryCode, region: geo.region } : undefined; // Settled either way: a failed lookup is an answer, not a pending one. + sessionSettled = true; + if (geo) setDetected(sessionGeo); setIsSettled(true); }); return () => {