Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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/<domain>.
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.
95 changes: 95 additions & 0 deletions __tests__/orchestraErrors.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
77 changes: 77 additions & 0 deletions __tests__/orchestraFormat.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof Intl.NumberFormat>
) {
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');
});
});
48 changes: 22 additions & 26 deletions components/DepositOption/DepositCashOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

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

Expand All @@ -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, isResolving: isResolvingCountry } = useCashAppDepositAvailability();
const { data: orchestraConfig } = useOrchestraConfig(countryCode, !isResolvingCountry);
const isCashAppAvailable = orchestraConfig?.isAvailable === true;
const { isBuyCryptoAvailable } = useGeoCompliance();
const { handleBuyCryptoPress } = useBuyCryptoEntry();

Expand All @@ -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) => {
Expand Down Expand Up @@ -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 => (
Expand Down Expand Up @@ -153,10 +152,7 @@ const DepositCashOptions = () => {
</Pressable>
</CardFundGroup>

<VirtualAccountApplyDialog
isOpen={isVirtualAccountApplyOpen}
onClose={() => setIsVirtualAccountApplyOpen(false)}
/>
<VirtualAccountApplyDialog isOpen={isApplyOpen} onClose={closeApply} />
</>
);
};
Expand Down
90 changes: 90 additions & 0 deletions components/DepositOption/DepositUsdOptions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<CardFundGroup>
<CardFundRow
className="min-h-[93px]"
icon={
<View
className="items-center justify-center rounded-full bg-[#333333]"
style={{ width: ICON_SIZE, height: ICON_SIZE }}
>
<Building2 size={18} color="#FFFFFF" />
</View>
}
title="Wire transfer, ACH"
subtitle="Your own US account details"
onPress={openVirtualAccount}
chips={BANK_CHIPS}
/>
<CardFundRow
className="min-h-[93px]"
icon={
<View
className="items-center justify-center rounded-full bg-[#333333]"
style={{ width: ICON_SIZE, height: ICON_SIZE }}
>
<Zap size={18} color="#94F27F" />
</View>
}
title="Cash App"
subtitle="Pay from your Cash App balance"
onPress={handleCashAppPress}
chips={CASH_APP_CHIPS}
/>
</CardFundGroup>

<VirtualAccountApplyDialog isOpen={isApplyOpen} onClose={closeApply} />
</>
);
};

export default DepositUsdOptions;
Loading
Loading