Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
90bfcea
feat(savings): claim the FUSE yield boost from the savings vault screen
claude Sep 27, 2026
5f6d14d
Merge pull request #2592 from Solid-Money/claude/merkl-campaign-imple…
MusabShakeel576 Sep 27, 2026
a4b43a8
feat(deposit): offer Cash App under Fund your card
mul53 Sep 27, 2026
49d59d8
feat(deposit): Apple Pay under USD, funding the card from card screens
mul53 Sep 28, 2026
9eef154
Merge pull request #2595 from Solid-Money/claude/orchestra-cash-app-o…
mul53 Sep 28, 2026
899b266
Merge qa into claude/onramper-widget-config
mul53 Sep 28, 2026
a685a29
Merge pull request #2597 from Solid-Money/claude/onramper-widget-config
mul53 Sep 28, 2026
109290d
feat(buy-crypto): send a KYC-limit refusal to TransFi's verification …
mul53 Sep 28, 2026
a54845f
Merge pull request #2600 from Solid-Money/claude/transfi-kyc-upgrade
mul53 Sep 28, 2026
ec230f7
fix(deposit): list Cash App under USD on Fund your card
mul53 Sep 28, 2026
88d8c7f
Merge pull request #2601 from Solid-Money/claude/cash-app-usd-deposit…
mul53 Sep 28, 2026
ee2f523
fix(deposit): hide the USD bank rail from Wirex cardholders
mul53 Sep 29, 2026
6ebd82e
Merge pull request #2603 from Solid-Money/claude/wirex-deposit-kyc-re…
mul53 Sep 29, 2026
1d06fd9
feat(deposit): hide TransFi cash deposits in prohibited countries
mul53 Sep 29, 2026
bd9ce06
Merge pull request #2605 from Solid-Money/claude/admiring-villani-01fb4a
mul53 Sep 29, 2026
2e5d81e
List every cash corridor behind "Show more"
mul53 Sep 29, 2026
0bc6a65
Merge pull request #2607 from Solid-Money/claude/cash-deposit-show-al…
mul53 Sep 29, 2026
fcc2e82
feat(deposit): offer Apple Pay on qa builds only
mul53 Sep 29, 2026
83ea675
Merge pull request #2608 from Solid-Money/claude/apple-pay-qa-only
mul53 Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions components/BuyCrypto/OnramperWidget/OnramperWidget.native.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);

/**
Expand Down
4 changes: 2 additions & 2 deletions components/BuyCrypto/OnramperWidget/OnramperWidget.web.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OnramperWidgetLoading />;
if (isError || !session) {
Expand Down
8 changes: 8 additions & 0 deletions components/BuyCrypto/OnramperWidget/OnramperWidgetStates.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions components/BuyCrypto/Transfi/BuyCryptoFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <OnramperWidget />;
return <OnramperWidget destination="card" />;
default:
return null;
}
Expand Down
185 changes: 170 additions & 15 deletions components/BuyCrypto/Transfi/TransfiError.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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.
*
Expand All @@ -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<UpgradeState>();
const [upgradeError, setUpgradeError] = useState<string>();

useEffect(() => {
if (!error) return;
Expand Down Expand Up @@ -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;
Expand All @@ -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 (
<View className="flex-1 gap-6">
<View className="items-center gap-4 pt-2">
<View className="items-center justify-center rounded-full bg-card p-5">
<Icon size={40} color={error.action === 'wait' ? '#94F27F' : '#F87171'} />
<Icon size={40} color={inReview || error.action === 'wait' ? '#94F27F' : '#F87171'} />
</View>
<View className="items-center gap-2 px-2">
<Text className="text-center text-2xl font-bold text-primary">
{transfiErrorTitle(error)}
</Text>
<Text className="text-center text-base text-muted-foreground">{error.message}</Text>
<Text className="text-center text-2xl font-bold text-primary">{title}</Text>
<Text className="text-center text-base text-muted-foreground">{message}</Text>
{upgradeError ? (
<Text className="text-center text-sm text-red-500">{upgradeError}</Text>
) : null}
</View>
</View>

Expand Down Expand Up @@ -155,14 +253,44 @@ export const TransfiError = () => {

<View className="mt-auto gap-3">
{primary ? (
<Button className="h-14 rounded-2xl" variant="brand" onPress={handlePrimary}>
<Text className="text-base font-bold text-primary-foreground">{primary.label}</Text>
<Button
className="h-14 rounded-2xl"
variant="brand"
disabled={isUpgrading}
onPress={handlePrimary}
>
{isUpgrading ? (
<ActivityIndicator size="small" color="#000000" />
) : (
<Text className="text-base font-bold text-primary-foreground">{primary.label}</Text>
)}
</Button>
) : null}
{hasSecondary ? (
<Button
className="h-14 rounded-2xl"
variant={primary ? 'secondary' : 'brand'}
onPress={buySmallerAmount}
>
<Text
className={
primary
? 'text-base font-semibold text-primary'
: 'text-base font-bold text-primary-foreground'
}
>
Buy a smaller amount
</Text>
</Button>
) : null}
<Button className="h-14 rounded-2xl" variant={primary ? 'ghost' : 'brand'} onPress={goHome}>
<Button
className="h-14 rounded-2xl"
variant={primary || hasSecondary ? 'ghost' : 'brand'}
onPress={goHome}
>
<Text
className={
primary
primary || hasSecondary
? 'text-base font-semibold text-muted-foreground'
: 'text-base font-bold text-primary-foreground'
}
Expand All @@ -176,7 +304,7 @@ export const TransfiError = () => {
};

type PrimaryAction = {
key: 'retry' | 'amount' | 'payment_method' | 'profile' | 'kyc';
key: 'retry' | 'amount' | 'payment_method' | 'profile' | 'kyc' | 'upgrade' | 'reopen_upgrade';
label: string;
};

Expand All @@ -185,7 +313,20 @@ type PrimaryAction = {
* isn't one. A "Try again" button on an unsupported country is worse than no
* button: it invites the user to keep hitting the same wall.
*/
const resolvePrimaryAction = (error: TransfiErrorType): PrimaryAction | undefined => {
const resolvePrimaryAction = (
error: TransfiErrorType,
upgrade: UpgradeState | undefined,
): PrimaryAction | undefined => {
// A limit refusal: the next KYC level is on TransFi's own page. Our identity
// flow would only find the user already verified and send them back here.
if (kycUpgradeLevel(error)) {
if (!upgrade) return { key: 'upgrade', label: 'Verify identity' };
if (upgrade.state === 'in_review') return undefined;
return {
key: 'reopen_upgrade',
label: upgrade.state === 'blocked' ? 'Open verification page' : 'Reopen verification page',
};
}
switch (error.action) {
case 'adjust_amount':
return { key: 'amount', label: 'Change amount' };
Expand All @@ -206,6 +347,20 @@ const resolvePrimaryAction = (error: TransfiErrorType): PrimaryAction | undefine
}
};

/** What the screen says once the upgrade has been asked for; the server's copy before that. */
const upgradeMessage = (upgrade: UpgradeState | undefined): string | undefined => {
switch (upgrade?.state) {
case 'in_review':
return 'Your verification is in review. You can still buy smaller amounts in the meantime.';
case 'opened':
return 'We’ve opened our payment partner’s verification page. Once you’ve submitted your details there, they’ll review them. You can still buy smaller amounts in the meantime.';
case 'blocked':
return 'Your browser blocked the verification window. Open it to upgrade your KYC.';
default:
return undefined;
}
};

/** "Enter between 22 EUR and 86,351.62 EUR", when the failure carried limits. */
const describeLimits = (error: TransfiErrorType): string | undefined => {
const { minLimit, maxLimit, fiatCurrency } = error.details;
Expand Down
Loading
Loading