diff --git a/CHANGELOG.md b/CHANGELOG.md index b833d3923e1..cffdb39606e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased (develop) +- changed: Update Infinite ONRAMP to the new headless transfer API: ACH push payment to a per-customer virtual bank account, with the deposit beneficiary surfaced on the bank routing scene. +- fixed: Always reset the Infinite confirmation slider once `onConfirm` settles so a successful transfer with missing deposit instructions no longer leaves the slider spinning. +- fixed: Pop the Infinite ramp stack to the top after the user dismisses the bank routing details scene, instead of stepping back through the confirmation scene. +- fixed: Allow the Infinite bank routing instructions and warning text to wrap and stop them from shrinking under large system fonts. + ## 4.49.0 (staging) - added: Honor `af` affiliate parameter on `deep.edge.app` deep links, activating the promotion alongside any inner payload (e.g. private-key import). diff --git a/src/components/scenes/RampBankRoutingDetailsScene.tsx b/src/components/scenes/RampBankRoutingDetailsScene.tsx index 75afdae0a0c..e94818b7f83 100644 --- a/src/components/scenes/RampBankRoutingDetailsScene.tsx +++ b/src/components/scenes/RampBankRoutingDetailsScene.tsx @@ -16,12 +16,13 @@ import { SceneContainer } from '../layout/SceneContainer' import { EdgeRow } from '../rows/EdgeRow' import { showToast } from '../services/AirshipInstance' import { cacheStyles, type Theme, useTheme } from '../services/ThemeContext' -import { EdgeText, Paragraph } from '../themed/EdgeText' +import { EdgeText } from '../themed/EdgeText' export interface BankInfo { name: string accountNumber: string routingNumber: string + beneficiaryName?: string } export interface RampBankRoutingDetailsParams { @@ -57,9 +58,9 @@ export const RampBankRoutingDetailsScene: React.FC = props => { size={theme.rem(2.5)} color={theme.primaryText} /> - + {lstrings.ramp_bank_routing_instructions} - + @@ -82,6 +83,13 @@ export const RampBankRoutingDetailsScene: React.FC = props => { + {bank.beneficiaryName != null && bank.beneficiaryName !== '' ? ( + + ) : null} = props => { - + {lstrings.ramp_bank_routing_warning} @@ -127,7 +139,8 @@ const getStyles = cacheStyles((theme: Theme) => ({ color: theme.iconTappable }, instructionText: { - flexShrink: 1 + flexShrink: 1, + marginLeft: theme.rem(0.5) }, cardContent: { padding: theme.rem(0.5) diff --git a/src/components/scenes/RampConfirmationScene.tsx b/src/components/scenes/RampConfirmationScene.tsx index 5ad1680ec05..cee5f66f3e1 100644 --- a/src/components/scenes/RampConfirmationScene.tsx +++ b/src/components/scenes/RampConfirmationScene.tsx @@ -48,10 +48,10 @@ export const RampConfirmationScene: React.FC = props => { setIsConfirming(true) try { await onConfirm() - } catch (err) { + } catch (err: unknown) { setError(err) - reset() // Reset the slider on error } finally { + reset() setIsConfirming(false) } }) diff --git a/src/locales/en_US.ts b/src/locales/en_US.ts index 6b289adf405..a7b39e93561 100644 --- a/src/locales/en_US.ts +++ b/src/locales/en_US.ts @@ -2545,6 +2545,7 @@ const strings = { 'Please send the exact amount shown below to the following bank account.', ramp_send_amount_label: 'Amount to Send', ramp_bank_details_section_title: 'Bank Details', + ramp_bank_beneficiary_name_label: 'Beneficiary Name', ramp_bank_name_label: 'Bank Name', ramp_account_number_label: 'Account Number', ramp_routing_number_label: 'Routing Number', diff --git a/src/locales/strings/enUS.json b/src/locales/strings/enUS.json index deedd2840cd..2195ce7703e 100644 --- a/src/locales/strings/enUS.json +++ b/src/locales/strings/enUS.json @@ -1987,6 +1987,7 @@ "ramp_bank_routing_instructions": "Please send the exact amount shown below to the following bank account.", "ramp_send_amount_label": "Amount to Send", "ramp_bank_details_section_title": "Bank Details", + "ramp_bank_beneficiary_name_label": "Beneficiary Name", "ramp_bank_name_label": "Bank Name", "ramp_account_number_label": "Account Number", "ramp_routing_number_label": "Routing Number", diff --git a/src/plugins/ramps/infinite/infiniteApi.ts b/src/plugins/ramps/infinite/infiniteApi.ts index 3e53d9d4fb1..6d501d7efd8 100644 --- a/src/plugins/ramps/infinite/infiniteApi.ts +++ b/src/plugins/ramps/infinite/infiniteApi.ts @@ -52,6 +52,7 @@ const USE_DUMMY_DATA: Record = { createQuote: false, createTransfer: false, getTransferStatus: false, + getDepositAddress: false, createCustomer: false, verifyOtp: false, getKycStatus: false, @@ -335,28 +336,35 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { return asInfiniteTransferResponse(data) } - // Dummy response - New format - const dummyResponse: InfiniteTransferResponse = { - id: `transfer_${params.type.toLowerCase()}_${Date.now()}`, - sourceDepositInstructions: - params.type === 'ONRAMP' - ? { + // Dummy response - New format. ONRAMP returns null id + depositAddressId. + const dummyResponse: InfiniteTransferResponse = + params.type === 'ONRAMP' + ? { + id: null, + depositAddressId: `vba_${Date.now().toString(16)}`, + sourceDepositInstructions: { amount: params.amount, bankAccountNumber: '8312008517', bankRoutingNumber: '021000021', bankName: 'JPMorgan Chase Bank', + bankBeneficiaryName: 'Edge Wallet, Inc.', toAddress: null } - : { + } + : { + id: `tfr_${Date.now().toString(16)}`, + depositAddressId: undefined, + sourceDepositInstructions: { amount: params.amount, bankAccountNumber: null, bankRoutingNumber: null, bankName: null, + bankBeneficiaryName: null, toAddress: `0xdeadbeef2${params.source.currency}${ params.source.network }${Date.now().toString(16)}` } - } + } return dummyResponse }, @@ -382,12 +390,49 @@ export const makeInfiniteApi = (config: InfiniteApiConfig): InfiniteApi => { // Dummy response - simulate a completed transfer const dummyResponse: InfiniteTransferResponse = { id: transferId, + depositAddressId: undefined, sourceDepositInstructions: undefined } return dummyResponse }, + getDepositAddress: async (depositAddressId: string) => { + // Reload deposit instructions for an existing ONRAMP virtual bank + // account. Auth required; only onboarded wallets get a 200 here. + if (authState.token == null || isTokenExpired()) { + throw new Error('Authentication required') + } + + if (!USE_DUMMY_DATA.getDepositAddress) { + const response = await fetchInfinite( + `/v1/headless/transfers/deposit-address/${depositAddressId}`, + { + headers: makeHeaders({ includeAuth: true }) + } + ) + + const data = await response.text() + return asInfiniteTransferResponse(data) + } + + // Dummy response - same shape as ONRAMP create + const dummyResponse: InfiniteTransferResponse = { + id: null, + depositAddressId, + sourceDepositInstructions: { + amount: 0, + bankAccountNumber: '8312008517', + bankRoutingNumber: '021000021', + bankName: 'JPMorgan Chase Bank', + bankBeneficiaryName: 'Edge Wallet, Inc.', + toAddress: null + } + } + + return dummyResponse + }, + // Customer methods createCustomer: async ( params: InfiniteCustomerRequest diff --git a/src/plugins/ramps/infinite/infiniteApiTypes.ts b/src/plugins/ramps/infinite/infiniteApiTypes.ts index 2d4afa93e6a..534efd68e91 100644 --- a/src/plugins/ramps/infinite/infiniteApiTypes.ts +++ b/src/plugins/ramps/infinite/infiniteApiTypes.ts @@ -75,21 +75,24 @@ export const asInfiniteQuoteResponse = asJSON( ) // Transfer response - New format for headless API +// ONRAMP create returns id: null and a depositAddressId (vba_…). OFFRAMP and +// follow-up GETs against /transfers/:transferId return id as a string. export const asInfiniteTransferResponse = asJSON( asObject({ - id: asString, + id: asEither(asString, asNull), + depositAddressId: asOptional(asString), sourceDepositInstructions: asOptional( asObject({ amount: asNumber, bankAccountNumber: asOptional(asString, null), bankRoutingNumber: asOptional(asString, null), bankName: asOptional(asString, null), + bankBeneficiaryName: asOptional(asString, null), toAddress: asOptional(asString, null) // UNUSED fields: // network: asString, // currency: asString, // depositMessage: asOptional(asString, null), - // bankBeneficiaryName: asOptional(asString, null), // fromAddress: asOptional(asString, null) }) ) @@ -111,11 +114,57 @@ export const asInfiniteTransferResponse = asJSON( // accountId: asOptional(asString, null), // toAddress: asOptional(asString, null) // }), + // fees: asObject({ + // infiniteFee: asNumber, + // partnerFee: asNumber, + // total: asNumber, + // currency: asString + // }), // createdAt: asString, // updatedAt: asString }) ) +// Transfer request - discriminated by direction. ONRAMP no longer accepts an +// account id; Infinite provisions a virtual bank account and returns deposit +// instructions in the create response. +export interface InfiniteOnrampTransferRequest { + type: 'ONRAMP' + amount: number + source: { + currency: string + network: string + } + destination: { + currency: string + network: string + toAddress: string + } + clientReferenceId?: string + developerFee?: string +} + +export interface InfiniteOfframpTransferRequest { + type: 'OFFRAMP' + amount: number + source: { + currency: string + network: string + fromAddress: string + } + destination: { + currency: string + network: string + accountId: string + } + clientReferenceId?: string + developerFee?: string +} + +export type InfiniteTransferRequest = + | InfiniteOnrampTransferRequest + | InfiniteOfframpTransferRequest + // Customer types export const asInfiniteCustomerType = asValue('individual', 'business') @@ -443,27 +492,16 @@ export interface InfiniteApi { }) => Promise // Transfer methods - createTransfer: (params: { - type: InfiniteQuoteFlow - amount: number - source: { - currency: string - network: string - accountId?: string - fromAddress?: string - } - destination: { - currency: string - network: string - accountId?: string - toAddress?: string - } - clientReferenceId?: string - developerFee?: string - }) => Promise + createTransfer: ( + params: InfiniteTransferRequest + ) => Promise getTransferStatus: (transferId: string) => Promise + getDepositAddress: ( + depositAddressId: string + ) => Promise + // Customer methods createCustomer: ( params: InfiniteCustomerRequest diff --git a/src/plugins/ramps/infinite/infiniteRampPlugin.ts b/src/plugins/ramps/infinite/infiniteRampPlugin.ts index a1de64e86a2..78f9aa8402b 100644 --- a/src/plugins/ramps/infinite/infiniteRampPlugin.ts +++ b/src/plugins/ramps/infinite/infiniteRampPlugin.ts @@ -45,8 +45,8 @@ import { kycWorkflow } from './workflows/kycWorkflow' const pluginId = 'infinite' const partnerIcon = `${EDGE_CONTENT_SERVER_URI}/infinite.png` const pluginDisplayName = 'Infinite' -// Extend as more become supported: -const DEFAULT_PAYMENT_TYPE: FiatPaymentType = 'wire' +// ACH push payment is the only supported buy rail today. +const BUY_PAYMENT_TYPE: FiatPaymentType = 'ach' // Storage keys const INFINITE_PRIVATE_KEY = 'infinite_auth_private_key' @@ -196,7 +196,7 @@ export const infiniteRampPlugin: RampPluginFactory = ( } // Global constraints pre-check - const paymentTypes: FiatPaymentType[] = [DEFAULT_PAYMENT_TYPE] + const paymentTypes: FiatPaymentType[] = [BUY_PAYMENT_TYPE] const constraintOk = validateRampCheckSupportRequest( pluginId, request, @@ -303,7 +303,7 @@ export const infiniteRampPlugin: RampPluginFactory = ( const quoteConstraintOk = validateRampQuoteRequest( pluginId, request, - DEFAULT_PAYMENT_TYPE + BUY_PAYMENT_TYPE ) if (!quoteConstraintOk) return [] @@ -625,7 +625,7 @@ export const infiniteRampPlugin: RampPluginFactory = ( fiatAmount: (responseFiatAmount ?? 0).toString(), direction: request.direction, regionCode: request.regionCode, - paymentType: 'wire', // Infinite uses wire bank transfers + paymentType: BUY_PAYMENT_TYPE, expirationDate: quoteResponse.expiresAt != null ? new Date(quoteResponse.expiresAt) @@ -667,13 +667,20 @@ export const infiniteRampPlugin: RampPluginFactory = ( vault }) - // Ensure we have a bank account - const bankAccountResult = await bankAccountWorkflow({ - countryCode: request.regionCode.countryCode, - infiniteApi, - navigationFlow, - vault - }) + // ONRAMP is push-payment: Infinite provisions a virtual bank + // account and the user pushes funds to it, so we don't collect + // their bank account details. OFFRAMP still requires a destination + // bank account on file with Infinite. + let bankAccountId: string | undefined + if (request.direction === 'sell') { + const bankAccountResult = await bankAccountWorkflow({ + countryCode: request.regionCode.countryCode, + infiniteApi, + navigationFlow, + vault + }) + bankAccountId = bankAccountResult.bankAccountId + } // Get fresh quote before confirmation using existing params const freshQuote = await infiniteApi.createQuote(quoteParams) @@ -702,8 +709,7 @@ export const infiniteRampPlugin: RampPluginFactory = ( request, freshQuote, coreWallet, - bankAccountId: bankAccountResult.bankAccountId, - flow, + bankAccountId, infiniteNetwork, cleanFiatCode } @@ -713,6 +719,11 @@ export const infiniteRampPlugin: RampPluginFactory = ( return } + // ONRAMP create returns id: null and a depositAddressId; OFFRAMP + // returns a tfr_… id. Fall back through both for analytics. + const orderId = + result.transfer.id ?? result.transfer.depositAddressId ?? '' + // Log the success event based on direction if (request.direction === 'buy') { onLogEvent('Buy_Success', { @@ -726,7 +737,7 @@ export const infiniteRampPlugin: RampPluginFactory = ( exchangeAmount: freshQuote.target.amount.toString() }), fiatProviderId: pluginId, - orderId: result.transfer.id + orderId } }) } else { @@ -741,7 +752,7 @@ export const infiniteRampPlugin: RampPluginFactory = ( exchangeAmount: freshQuote.source.amount.toString() }), fiatProviderId: pluginId, - orderId: result.transfer.id + orderId } }) } diff --git a/src/plugins/ramps/infinite/utils/navigationFlow.ts b/src/plugins/ramps/infinite/utils/navigationFlow.ts index 074cabbe797..ebb41a8d270 100644 --- a/src/plugins/ramps/infinite/utils/navigationFlow.ts +++ b/src/plugins/ramps/infinite/utils/navigationFlow.ts @@ -3,6 +3,7 @@ import type { NavigationBase } from '../../../../types/routerTypes' export interface NavigationFlow { navigate: NavigationBase['navigate'] goBack: () => void + popToTop: () => void } export const makeNavigationFlow = ( @@ -26,5 +27,10 @@ export const makeNavigationFlow = ( hasNavigated = false } - return { navigate, goBack } + const popToTop = (): void => { + navigation.popToTop() + hasNavigated = false + } + + return { navigate, goBack, popToTop } } diff --git a/src/plugins/ramps/infinite/workflows/confirmationWorkflow.ts b/src/plugins/ramps/infinite/workflows/confirmationWorkflow.ts index 12062eaeaa3..bcc527428b5 100644 --- a/src/plugins/ramps/infinite/workflows/confirmationWorkflow.ts +++ b/src/plugins/ramps/infinite/workflows/confirmationWorkflow.ts @@ -4,7 +4,8 @@ import { showToast } from '../../../../components/services/AirshipInstance' import type { RampQuoteRequest } from '../../rampPluginTypes' import type { InfiniteApi, - InfiniteQuoteFlow, + InfiniteOfframpTransferRequest, + InfiniteOnrampTransferRequest, InfiniteQuoteResponse, InfiniteTransferResponse } from '../infiniteApiTypes' @@ -28,8 +29,8 @@ export interface ConfirmationParams { // New parameters for transfer creation freshQuote: InfiniteQuoteResponse coreWallet: EdgeCurrencyWallet - bankAccountId: string - flow: InfiniteQuoteFlow + /** Required for OFFRAMP; unused for ONRAMP push-payment flow. */ + bankAccountId?: string infiniteNetwork: string cleanFiatCode: string } @@ -51,7 +52,6 @@ export const confirmationWorkflow = async ( freshQuote, coreWallet, bankAccountId, - flow, infiniteNetwork, cleanFiatCode } = confirmationParams @@ -64,18 +64,19 @@ export const confirmationWorkflow = async ( onConfirm: async () => { // Create the transfer here - let errors bubble up if (request.direction === 'buy') { - // For buy (onramp), source is bank account + // ONRAMP is push-payment: Infinite provisions a virtual bank account + // and returns the deposit instructions in the create response. The + // user's own bank account is not part of the request. const [receiveAddress] = await coreWallet.getAddresses({ tokenId: request.tokenId }) - const transferParams = { - type: flow, + const transferParams: InfiniteOnrampTransferRequest = { + type: 'ONRAMP', amount: freshQuote.source.amount, source: { currency: cleanFiatCode.toLowerCase(), - network: 'wire', // Default to wire for bank transfers - accountId: bankAccountId + network: 'ACH' }, destination: { currency: request.displayCurrencyCode.toLowerCase(), @@ -87,34 +88,46 @@ export const confirmationWorkflow = async ( const transfer = await infiniteApi.createTransfer(transferParams) - // Show deposit instructions for bank transfer with replace const instructions = transfer.sourceDepositInstructions - if (instructions?.bankName != null && instructions.amount != null) { - navigationFlow.navigate('rampBankRoutingDetails', { - bank: { - name: instructions.bankName, - accountNumber: instructions.bankAccountNumber ?? '', - routingNumber: instructions.bankRoutingNumber ?? '' - }, - fiatCurrencyCode: cleanFiatCode, - fiatAmount: instructions.amount.toString(), - onDone: () => { - navigationFlow.goBack() - } - }) + if (instructions?.bankName == null || instructions.amount == null) { + throw new Error( + `Transfer ${ + transfer.id ?? transfer.depositAddressId ?? '' + } created but deposit instructions are missing` + ) } + navigationFlow.navigate('rampBankRoutingDetails', { + bank: { + name: instructions.bankName, + accountNumber: instructions.bankAccountNumber ?? '', + routingNumber: instructions.bankRoutingNumber ?? '', + beneficiaryName: instructions.bankBeneficiaryName ?? undefined + }, + fiatCurrencyCode: cleanFiatCode, + fiatAmount: instructions.amount.toString(), + onDone: () => { + navigationFlow.popToTop() + } + }) + resolve({ confirmed: true, transfer }) } else { // TODO: This whole else block is a WIP implementation! + if (bankAccountId == null) { + throw new Error( + 'Infinite OFFRAMP requires a destination bank account id' + ) + } + // For sell (offramp), destination is bank account const [receiveAddress] = await coreWallet.getAddresses({ tokenId: request.tokenId }) - const transferParams = { - type: flow, + const transferParams: InfiniteOfframpTransferRequest = { + type: 'OFFRAMP', amount: freshQuote.source.amount, source: { currency: request.displayCurrencyCode.toLowerCase(),