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..418f37a3f 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, isResolving: isResolvingCountry } = useCashAppDepositAvailability(); + const { data: orchestraConfig } = useOrchestraConfig(countryCode, !isResolvingCountry); + 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..2b95df88a --- /dev/null +++ b/components/Orchestra/OrchestraAmount.tsx @@ -0,0 +1,228 @@ +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, 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, !isResolvingCountry); + 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..86433f036 --- /dev/null +++ b/components/Orchestra/OrchestraInvoice.tsx @@ -0,0 +1,243 @@ +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 { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; +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); + // 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; + + 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..632322389 --- /dev/null +++ b/components/Orchestra/OrchestraOrderStatus.tsx @@ -0,0 +1,281 @@ +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 { useCashAppDepositAvailability } from '@/hooks/useCashAppDepositAvailability'; +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); + // 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); + + // 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/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/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..2ecd35c6e --- /dev/null +++ b/hooks/__tests__/useCashAppDepositAvailability.test.tsx @@ -0,0 +1,176 @@ +import React from 'react'; + +import { + __resetGeoSessionCache, + 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 => { + // 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 = () => { + seen.push(useCashAppDepositAvailability()); + return null; + }; + await act(async () => { + create(); + }); + return seen[seen.length - 1]; +}; + +const storeCountry = (countryCode: string, state?: string) => + useCountryStore.getState().setCountryInfo({ + countryCode, + countryName: countryCode, + state, + isAvailable: true, + source: 'manual', + }); + +describe('useCashAppDepositAvailability', () => { + beforeEach(() => { + jest.clearAllMocks(); + __resetGeoSessionCache(); + 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, + }); + }); + + 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; + }), + ); + + const seen: Result[] = []; + const Probe = () => { + seen.push(useCashAppDepositAvailability()); + return null; + }; + await act(async () => { + create(); + }); + + 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. + 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 () => { + storeCountry('US'); + + const result = await renderHook(); + + expect(result.isResolving).toBe(false); + 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 new file mode 100644 index 000000000..b05e4c8ec --- /dev/null +++ b/hooks/useCashAppDepositAvailability.ts @@ -0,0 +1,97 @@ +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. + * + * 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(sessionGeo); + /** + * 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(sessionSettled); + + useEffect(() => { + if (storedCode || sessionSettled) { + setIsSettled(true); + return; + } + let cancelled = false; + void detectGeo().then(geo => { + // 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 () => { + cancelled = true; + }; + }, [storedCode]); + + const countryCode = (storedCode ?? detected?.countryCode)?.toUpperCase(); + const region = countryInfo?.state ?? detected?.region; + + 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, + /** 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..cdc7f93fd 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 '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 }), +}));