diff --git a/.env.example b/.env.example index 77bbe0cf1..0583018e6 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,8 @@ EXPO_PUBLIC_FIREBASE_DATABASE_URL= EXPO_PUBLIC_AMPLITUDE_API_KEY= EXPO_PUBLIC_AMPLITUDE_PROXY_URL= EXPO_PUBLIC_MERKL_CAMPAIGN_ID= +# Comma-separated Merkl campaign ids of the WFUSE yield-boost campaigns (soUSD, soETH, soFUSE) +EXPO_PUBLIC_MERKL_YIELD_BOOST_CAMPAIGN_IDS= EXPO_PUBLIC_FLASH_VAULT_MANAGER_API_BASE_URL=http://localhost:5003 EXPO_PUBLIC_COINGECKO_API_KEY= @@ -33,6 +35,9 @@ EXPO_PUBLIC_ALGEBRA_INFO_GRAPH=https://api.studio.thegraph.com/query/78455/algeb EXPO_PUBLIC_ENVIRONMENT=preview # Sandbox: skip the TransFi buy-crypto KYC gate on the client (pair with backend TRANSFI_SKIP_KYC). Never set in prod. EXPO_PUBLIC_TRANSFI_SKIP_KYC=false +# Comma-separated ISO alpha-2 codes TransFi prohibits; the cash deposit rows are hidden there. +# https://docs.transfi.com/docs/prohibited-countries +EXPO_PUBLIC_TRANSFI_PROHIBITED_COUNTRIES=UA,RU,BY EXPO_PUBLIC_THIRDWEB_CLIENT_ID= EXPO_PUBLIC_PASSKEY_CERTIFICATES_HOST=solid.xyz diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx index c86290832..8f9cd1850 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx @@ -29,8 +29,8 @@ const APP_SCHEME = 'solid://'; * `mediaCapturePermissionGrantType` answers the WebView's own camera prompt, * which is asked separately from the OS permission. */ -export const OnramperWidget = ({ onOutcome }: OnramperWidgetProps) => { - const { data: session, isPending, isError, refetch } = useOnramperWidget(); +export const OnramperWidget = ({ destination = 'wallet', onOutcome }: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(destination); const [isOpeningBrowser, setIsOpeningBrowser] = useState(false); /** diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx index e4560c5d4..0f7fbdcc6 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx @@ -16,8 +16,8 @@ import { * missing entry raises no error — the step just quietly does nothing, deep * inside a provider's flow where we have no visibility at all. */ -export const OnramperWidget = (_props: OnramperWidgetProps) => { - const { data: session, isPending, isError, refetch } = useOnramperWidget(); +export const OnramperWidget = ({ destination = 'wallet' }: OnramperWidgetProps) => { + const { data: session, isPending, isError, refetch } = useOnramperWidget(destination); if (isPending) return ; if (isError || !session) { diff --git a/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx b/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx index 114974e55..dc74f610b 100644 --- a/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx +++ b/components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx @@ -3,7 +3,15 @@ import { ActivityIndicator, View } from 'react-native'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; +import type { OnramperDestination } from '@/lib/api'; + export interface OnramperWidgetProps { + /** + * What the purchase funds. The card funding screens pass `card`, which the + * backend resolves to the user's card deposit address; everything else funds + * the wallet. + */ + destination?: OnramperDestination; /** * Called when a provider redirects back to us, on the platforms that redirect * at all. Advisory only — not every provider honours a redirect, so the diff --git a/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx b/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx index 334f7ebeb..ad797a377 100644 --- a/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx +++ b/components/BuyCrypto/Transfi/BuyCryptoFlow.tsx @@ -44,9 +44,10 @@ export const BuyCryptoFlowContent = ({ // Onramper's hosted widget. It lives here rather than in a switch of its // own because this is the generic embed point: the card funding modals // render whatever DepositModal they are handed, so one case reaches all - // three of them. + // three of them. Being opened from a card funding modal is also why it + // delivers to the card deposit address rather than the wallet. case DEPOSIT_MODAL.OPEN_ONRAMPER_WIDGET.name: - return ; + return ; default: return null; } diff --git a/components/BuyCrypto/Transfi/TransfiError.tsx b/components/BuyCrypto/Transfi/TransfiError.tsx index 0217c687f..9fcf01021 100644 --- a/components/BuyCrypto/Transfi/TransfiError.tsx +++ b/components/BuyCrypto/Transfi/TransfiError.tsx @@ -1,6 +1,7 @@ -import { useEffect } from 'react'; -import { View } from 'react-native'; +import { useEffect, useState } from 'react'; +import { ActivityIndicator, Platform, View } from 'react-native'; import { useRouter } from 'expo-router'; +import { openBrowserAsync } from 'expo-web-browser'; import { AlertTriangle, Clock, LifeBuoy, ShieldAlert, XCircle } from 'lucide-react-native'; import { useBuyCryptoNavigation } from '@/components/BuyCrypto/Transfi/BuyCryptoNavigation'; @@ -10,16 +11,23 @@ import { DEPOSIT_MODAL } from '@/constants/modals'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBuyCryptoKycRoute } from '@/hooks/useBuyCryptoKycRoute'; +import { useUpgradeTransfiKyc } from '@/hooks/useTransfi'; import { track } from '@/lib/analytics'; import { + asTransfiError, canCompleteProfile, + kycUpgradeLevel, type TransfiError as TransfiErrorType, transfiErrorTitle, } from '@/lib/transfiErrors'; +import { TransfiKycLevel } from '@/lib/types'; import { useTransfiStore } from '@/store/useTransfiStore'; const SUPPORT_EMAIL = 'support@solid.xyz'; +const UPGRADE_UNAVAILABLE_MESSAGE = + 'We couldn’t open the verification page. Please try again in a moment.'; + const ICON_BY_ACTION = { retry: AlertTriangle, adjust_amount: AlertTriangle, @@ -35,6 +43,13 @@ const formatFiat = (value: number | undefined, currency: string) => ? undefined : `${new Intl.NumberFormat(undefined, { maximumFractionDigits: 2 }).format(value)} ${currency}`; +/** + * Where a KYC upgrade stands once the user has asked for it: TransFi's page + * handed to them (or stopped by a popup blocker, so they have to open it + * themselves), or TransFi already reviewing a submission for that level. + */ +type UpgradeState = { state: 'opened' | 'blocked'; url: string } | { state: 'in_review' }; + /** * Terminal screen for a buy-crypto failure. * @@ -50,8 +65,12 @@ export const TransfiError = () => { const setModal = useBuyCryptoNavigation(); const error = useTransfiStore(state => state.error); const errorOrigin = useTransfiStore(state => state.errorOrigin); + const setError = useTransfiStore(state => state.setError); const reset = useTransfiStore(state => state.reset); const routeToKyc = useBuyCryptoKycRoute(); + const { mutateAsync: upgradeKyc, isPending: isUpgrading } = useUpgradeTransfiKyc(); + const [upgrade, setUpgrade] = useState(); + const [upgradeError, setUpgradeError] = useState(); useEffect(() => { if (!error) return; @@ -83,8 +102,67 @@ export const TransfiError = () => { ); } - const Icon = ICON_BY_ACTION[error.action]; - const primary = resolvePrimaryAction(error); + const upgradeLevel = kycUpgradeLevel(error); + const inReview = upgrade?.state === 'in_review'; + const Icon = inReview ? Clock : ICON_BY_ACTION[error.action]; + const primary = resolvePrimaryAction(error, upgrade); + + /** + * Hand TransFi's verification page to the browser rather than framing it — as + * with the hosted retry, the flow uses the camera and its own redirects. + */ + const openVerificationPage = async (url: string) => { + let opened = false; + if (Platform.OS === 'web') { + // A null handle means a popup blocker stopped it; the screen then leads + // with a button so the user can open it themselves. + opened = Boolean(window.open(url, '_blank', 'noopener,noreferrer')); + } else { + try { + await openBrowserAsync(url); + opened = true; + } catch (openError) { + console.error('Failed to open the TransFi verification page:', openError); + } + } + setUpgrade({ state: opened ? 'opened' : 'blocked', url }); + if (opened) track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_PAGE_OPENED, { code: error.code }); + }; + + /** + * Ask TransFi for its page for the level this refusal names. A refusal with a + * verdict of its own — an account TransFi won't serve, no profile to upgrade — + * replaces this screen's error; anything transient stays inline so the button + * can be pressed again. + */ + const startUpgrade = async (level: TransfiKycLevel) => { + setUpgradeError(undefined); + try { + const result = await upgradeKyc(level); + if (result.status === 'pending') { + track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_IN_REVIEW, { level: result.level }); + setUpgrade({ state: 'in_review' }); + return; + } + if (!result.kycUrl) { + setUpgradeError(UPGRADE_UNAVAILABLE_MESSAGE); + return; + } + await openVerificationPage(result.kycUrl); + } catch (upgradeFailure) { + const failure = asTransfiError(upgradeFailure); + track(TRACKING_EVENTS.BUY_CRYPTO_KYC_UPGRADE_FAILED, { + code: failure.code, + action: failure.action, + }); + if (failure.action === 'retry') { + setUpgradeError(UPGRADE_UNAVAILABLE_MESSAGE); + return; + } + setUpgrade(undefined); + setError(failure, errorOrigin ?? undefined); + } + }; const handlePrimary = () => { if (!primary) return; @@ -111,22 +189,42 @@ export const TransfiError = () => { case 'kyc': void routeToKyc(); break; + case 'upgrade': + if (upgradeLevel) void startUpgrade(upgradeLevel); + break; + case 'reopen_upgrade': + if (upgrade && upgrade.state !== 'in_review') void openVerificationPage(upgrade.url); + break; } }; + // The limits only cap this purchase, so a smaller one still goes through + // while the next KYC level is outstanding. + const buySmallerAmount = () => { + track(TRACKING_EVENTS.BUY_CRYPTO_ERROR_ACTION_PRESSED, { + code: error.code, + choice: 'smaller_amount', + }); + setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO_AMOUNT); + }; + const limits = describeLimits(error); + const title = inReview ? 'Verification in review' : transfiErrorTitle(error); + const message = upgradeMessage(upgrade) ?? error.message; + const hasSecondary = Boolean(upgradeLevel); return ( - + - - {transfiErrorTitle(error)} - - {error.message} + {title} + {message} + {upgradeError ? ( + {upgradeError} + ) : null} @@ -155,14 +253,44 @@ export const TransfiError = () => { {primary ? ( - + ) : null} + {hasSecondary ? ( + ) : null} -