diff --git a/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx b/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx
index e87bc6e2..255a0024 100644
--- a/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx
+++ b/components/DepositOption/DepositBuyCryptoOptions/DepositBuyCryptoOptions.native.tsx
@@ -1,65 +1,16 @@
-import { useCallback, useMemo } from 'react';
import { View } from 'react-native';
-import { Image } from 'expo-image';
import DepositOption from '@/components/DepositOption/DepositOption';
-import { DEPOSIT_MODAL } from '@/constants/modals';
-import { getAsset } from '@/lib/assets';
-import { DepositMethod } from '@/lib/types';
+import useDepositBuyCryptoOptions from '@/hooks/useDepositBuyCryptoOptions';
import { getVaultDepositConfig } from '@/lib/vaults';
-import { useDepositStore } from '@/store/useDepositStore';
const DepositBuyCryptoOptions = () => {
- const setModal = useDepositStore(state => state.setModal);
+ const { buyCryptoOptions } = useDepositBuyCryptoOptions();
const depositConfig = getVaultDepositConfig();
- // const handleBankDepositPress = useCallback(() => {
- // setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT);
- // }, [setModal]);
-
- // const handleCreditCardPress = useCallback(() => {
- // setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO);
- // }, [setModal]);
-
- const buyCryptoOptions = useMemo(
- () => [
- // {
- // text: 'Debit/Credit Card',
- // subtitle: 'Google Pay, card or bank account',
- // icon: (
- //
- // ),
- // onPress: handleCreditCardPress,
- // method: 'credit_card' as DepositMethod,
- // },
- // {
- // text: 'Bank Deposit',
- // subtitle: 'Make a transfer from your bank.',
- // icon: (
- //
- // ),
- // onPress: handleBankDepositPress,
- // isComingSoon: false,
- // method: 'bank_transfer' as DepositMethod,
- // },
- ],
- [
- // handleCreditCardPress,
- // handleBankDepositPress
- ],
- );
-
return (
- {/* {buyCryptoOptions
+ {buyCryptoOptions
.filter(option => !option.method || depositConfig.methods.includes(option.method))
.map(option => (
{
subtitle={option.subtitle}
icon={option.icon}
onPress={option.onPress}
- // isComingSoon={option.isComingSoon}
+ chipText={option.chipText}
/>
- ))} */}
+ ))}
);
};
diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx
new file mode 100644
index 00000000..deaec6f2
--- /dev/null
+++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal.tsx
@@ -0,0 +1,132 @@
+import { useState } from 'react';
+import { ActivityIndicator, View } from 'react-native';
+import { Info } from 'lucide-react-native';
+
+import CopyToClipboard from '@/components/CopyToClipboard';
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { DEPOSIT_MODAL } from '@/constants/modals';
+import { useOnrampAutomation } from '@/hooks/useOnrampAutomation';
+import type { OnrampAutomationRail } from '@/lib/types';
+import { useDepositStore } from '@/store/useDepositStore';
+
+const TAB_CONFIG: { key: OnrampAutomationRail; label: string }[] = [
+ { key: 'ach', label: 'ACH' },
+ { key: 'wire', label: 'Wire' },
+];
+
+const RAIL_FOOTER: Record = {
+ ach: 'ACH cutoff is 4:00 PM ET. Funds typically settle in 1–3 business days.',
+ wire: 'Wire cutoff is 5:45 PM ET. Funds typically settle the same business day.',
+};
+
+const Row = ({
+ label,
+ value,
+ withDivider = false,
+}: {
+ label: string;
+ value: string;
+ withDivider?: boolean;
+}) => (
+
+
+
+ {label}
+
+
+
+ {value}
+
+ {value ? : null}
+
+
+ {withDivider && }
+
+);
+
+export const VirtualAccountDetailsModal = () => {
+ const setModal = useDepositStore(state => state.setModal);
+ const { data: automation, isLoading } = useOnrampAutomation();
+ const [rail, setRail] = useState('ach');
+
+ if (isLoading) {
+ return (
+
+
+
+ );
+ }
+
+ if (!automation) {
+ return (
+
+ Could not load your bank details.
+
+
+ );
+ }
+
+ const { depositAddress } = automation;
+
+ return (
+
+
+ Deposit USD
+
+ Send a transfer from your bank — funds arrive as soUSD in your Solid balance.
+
+
+
+
+ {TAB_CONFIG.map(tab => {
+ const isActive = rail === tab.key;
+ return (
+
+ );
+ })}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {RAIL_FOOTER[rail]}
+
+
+
+
+ );
+};
diff --git a/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx b/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx
new file mode 100644
index 00000000..328d9cde
--- /dev/null
+++ b/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal.tsx
@@ -0,0 +1,100 @@
+import { useCallback, useEffect, useState } from 'react';
+import { ActivityIndicator, Pressable, View } from 'react-native';
+import { Check } from 'lucide-react-native';
+
+import { Button } from '@/components/ui/button';
+import { Text } from '@/components/ui/text';
+import { DEPOSIT_MODAL } from '@/constants/modals';
+import {
+ useCreateOnrampAutomation,
+ useOnrampAutomation,
+} from '@/hooks/useOnrampAutomation';
+import { useDepositStore } from '@/store/useDepositStore';
+
+const TOS_POINTS = [
+ 'A persistent virtual bank account will be issued in your name for ACH and Wire deposits.',
+ 'Incoming USD is converted to USDC by Rain and automatically deposited into the soUSD vault on your behalf.',
+ 'You agree to Rain Payments’ Terms of Service and Privacy Policy and confirm you are the account holder.',
+ 'Deposits are subject to ACH (cutoff 4:00 PM ET) and Wire (cutoff 5:45 PM ET) banking hours. Settlement may take 1–3 business days.',
+];
+
+export const VirtualAccountTosModal = () => {
+ const setModal = useDepositStore(state => state.setModal);
+ const { data: existingAutomation } = useOnrampAutomation();
+ const createMutation = useCreateOnrampAutomation();
+ const [agreed, setAgreed] = useState(false);
+
+ // Defensive: if an automation already exists, skip ToS straight to details.
+ useEffect(() => {
+ if (existingAutomation) {
+ setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS);
+ }
+ }, [existingAutomation, setModal]);
+
+ const handleAccept = useCallback(() => {
+ createMutation.mutate('ach', {
+ onSuccess: () => {
+ setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS);
+ },
+ });
+ }, [createMutation, setModal]);
+
+ return (
+
+
+ Before you continue
+
+ Review the terms of the Rain virtual bank account.
+
+
+
+
+ {TOS_POINTS.map(point => (
+
+
+ {point}
+
+ ))}
+
+
+ setAgreed(prev => !prev)}
+ accessibilityRole="checkbox"
+ accessibilityState={{ checked: agreed }}
+ >
+
+ {agreed && }
+
+
+ I agree to the terms above and authorize Rain to issue a virtual bank account on my behalf.
+
+
+
+ {createMutation.isError && (
+
+ Something went wrong creating your bank account. Please try again.
+
+ )}
+
+
+
+ );
+};
diff --git a/constants/modals.ts b/constants/modals.ts
index 8bfaded0..c0332e75 100644
--- a/constants/modals.ts
+++ b/constants/modals.ts
@@ -75,6 +75,14 @@ export const DEPOSIT_MODAL = {
name: 'open_token_selector',
number: 17,
},
+ OPEN_VIRTUAL_ACCOUNT_DETAILS: {
+ name: 'open_virtual_account_details',
+ number: 18,
+ },
+ OPEN_VIRTUAL_ACCOUNT_TOS: {
+ name: 'open_virtual_account_tos',
+ number: 19,
+ },
};
export const SEND_MODAL = {
diff --git a/hooks/useDepositBuyCryptoOptions.tsx b/hooks/useDepositBuyCryptoOptions.tsx
index acfd7f29..9fd715f8 100644
--- a/hooks/useDepositBuyCryptoOptions.tsx
+++ b/hooks/useDepositBuyCryptoOptions.tsx
@@ -1,76 +1,70 @@
-import { useCallback, useMemo } from 'react';
+import { useCallback, useEffect, useMemo } from 'react';
import { Platform } from 'react-native';
import { Image } from 'expo-image';
+import { useRouter } from 'expo-router';
import { DEPOSIT_MODAL } from '@/constants/modals';
+import { path } from '@/constants/path';
import { TRACKING_EVENTS } from '@/constants/tracking-events';
+import { useCardStatus } from '@/hooks/useCardStatus';
+import { useOnrampAutomation } from '@/hooks/useOnrampAutomation';
import { track } from '@/lib/analytics';
import { getAsset } from '@/lib/assets';
-import { DepositMethod } from '@/lib/types';
+import { DepositMethod, RainApplicationStatus } from '@/lib/types';
import { useDepositStore } from '@/store/useDepositStore';
-import { useDimension } from './useDimension';
-
const useDepositBuyCryptoOptions = () => {
+ const router = useRouter();
const setModal = useDepositStore(state => state.setModal);
- const { isScreenMedium } = useDimension();
+ const { data: cardStatus } = useCardStatus();
+ useEffect(() => {
+ console.warn(cardStatus);
+ }, [cardStatus]);
+ const isRainApproved = cardStatus?.rainApplicationStatus === RainApplicationStatus.APPROVED;
+ const { data: existingAutomation } = useOnrampAutomation(isRainApproved);
+
+ const handleBankDepositPress = useCallback(() => {
+ track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
+ deposit_method: 'bank_transfer',
+ });
- // const handleBankDepositPress = useCallback(() => {
- // track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
- // deposit_method: 'bank_transfer',
- // });
- // setModal(DEPOSIT_MODAL.OPEN_BANK_TRANSFER_AMOUNT);
- // }, [setModal]);
+ if (!isRainApproved) {
+ setModal(DEPOSIT_MODAL.CLOSE);
+ router.push(path.KYC);
+ return;
+ }
- // const handleCreditCardPress = useCallback(() => {
- // track(TRACKING_EVENTS.DEPOSIT_METHOD_SELECTED, {
- // deposit_method: 'credit_card',
- // });
- // setModal(DEPOSIT_MODAL.OPEN_BUY_CRYPTO);
- // }, [setModal]);
+ if (existingAutomation) {
+ setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS);
+ return;
+ }
+
+ setModal(DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_TOS);
+ }, [existingAutomation, isRainApproved, router, setModal]);
const buyCryptoOptions = useMemo(
() => [
- // {
- // text: 'Debit/Credit Card',
- // subtitle: isScreenMedium
- // ? 'Apple pay, Google Pay, or your\ncredit card'
- // : 'Apple pay, Google Pay, or your credit card',
- // icon: (
- //
- // ),
- // onPress: handleCreditCardPress,
- // method: 'credit_card' as DepositMethod,
- // },
- // {
- // text: 'Bank Deposit',
- // subtitle: 'Make a transfer from your bank',
- // chipText: 'Cheapest',
- // icon: (
- //
- // ),
- // onPress: handleBankDepositPress,
- // method: 'bank_transfer' as DepositMethod,
- // },
- ],
- [
- // handleCreditCardPress,
- // // handleBankDepositPress,
- isScreenMedium,
+ {
+ text: 'Bank Deposit',
+ subtitle: 'Wire or ACH from your bank.',
+ chipText: 'Cheapest',
+ icon: (
+
+ ),
+ onPress: handleBankDepositPress,
+ method: 'bank_transfer' as DepositMethod,
+ },
],
+ [handleBankDepositPress],
);
const filteredOptions =
Platform.OS === 'ios'
- ? buyCryptoOptions.filter((option: any) => option.method !== 'credit_card')
+ ? buyCryptoOptions.filter(option => option.method !== 'credit_card')
: buyCryptoOptions;
return { buyCryptoOptions: filteredOptions };
diff --git a/hooks/useDepositOption.tsx b/hooks/useDepositOption.tsx
index 6038d96d..20f908bc 100644
--- a/hooks/useDepositOption.tsx
+++ b/hooks/useDepositOption.tsx
@@ -19,6 +19,8 @@ import DepositDirectlyTokens from '@/components/DepositOption/DepositDirectlyTok
import DepositExternalWalletOptions from '@/components/DepositOption/DepositExternalWalletOptions';
import DepositOptions from '@/components/DepositOption/DepositOptions';
import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress';
+import { VirtualAccountDetailsModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountDetailsModal';
+import { VirtualAccountTosModal } from '@/components/DepositOption/VirtualAccountDetails/VirtualAccountTosModal';
import { DepositTokenSelector, DepositToVaultForm } from '@/components/DepositToVault';
import SavingsDepositTokenSelector from '@/components/DepositToVault/SavingsDepositTokenSelector';
import TransactionStatus from '@/components/TransactionStatus';
@@ -128,6 +130,9 @@ const useDepositOption = ({
const isDepositDirectlyTokens =
currentModal.name === DEPOSIT_MODAL.OPEN_DEPOSIT_DIRECTLY_TOKENS.name;
const isTokenSelector = currentModal.name === DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR.name;
+ const isVirtualAccountDetails =
+ currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_DETAILS.name;
+ const isVirtualAccountTos = currentModal.name === DEPOSIT_MODAL.OPEN_VIRTUAL_ACCOUNT_TOS.name;
const isClose = currentModal.name === DEPOSIT_MODAL.CLOSE.name;
const shouldAnimate = previousModal.name !== DEPOSIT_MODAL.CLOSE.name;
const isForward = currentModal.number > previousModal.number;
@@ -252,6 +257,14 @@ const useDepositOption = ({
return ;
}
+ if (isVirtualAccountTos) {
+ return ;
+ }
+
+ if (isVirtualAccountDetails) {
+ return ;
+ }
+
if (isTokenSelector) {
if (depositFromSolid) {
return ;
@@ -280,6 +293,8 @@ const useDepositOption = ({
if (isDepositDirectlyAddress) return 'deposit-directly-address';
if (isDepositDirectlyTokens) return 'deposit-directly-tokens';
if (isTokenSelector) return 'token-selector';
+ if (isVirtualAccountDetails) return 'virtual-account-details';
+ if (isVirtualAccountTos) return 'virtual-account-tos';
return 'deposit-options';
};
@@ -297,6 +312,8 @@ const useDepositOption = ({
if (isDepositDirectlyTokens) return 'Choose token';
if (isTokenSelector && depositFromSolid) return 'Deposit';
if (isTokenSelector) return 'Select a token';
+ if (isVirtualAccountDetails) return 'Bank Deposit';
+ if (isVirtualAccountTos) return 'Bank Deposit';
if ((isNetworks || isFormAndAddress) && depositFromSolid) return 'Deposit';
if (isFormAndAddress && !depositFromSolid) return 'Add funds';
return 'Add funds';
diff --git a/hooks/useOnrampAutomation.ts b/hooks/useOnrampAutomation.ts
new file mode 100644
index 00000000..864a4ff5
--- /dev/null
+++ b/hooks/useOnrampAutomation.ts
@@ -0,0 +1,30 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { createOnrampAutomation, getOnrampAutomation } from '@/lib/api';
+import { OnrampAutomationRail } from '@/lib/types';
+import { withRefreshToken } from '@/lib/utils';
+
+const ONRAMP_AUTOMATION_KEY = 'onrampAutomation';
+
+export function useOnrampAutomation(enabled = true) {
+ return useQuery({
+ queryKey: [ONRAMP_AUTOMATION_KEY],
+ queryFn: () => withRefreshToken(() => getOnrampAutomation()),
+ enabled,
+ retry: 1,
+ });
+}
+
+export function useCreateOnrampAutomation() {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: async (rail: OnrampAutomationRail = 'ach') => {
+ const data = await withRefreshToken(() => createOnrampAutomation(rail));
+ if (!data) throw new Error('Failed to create onramp automation');
+ return data;
+ },
+ onSuccess: data => {
+ queryClient.setQueryData([ONRAMP_AUTOMATION_KEY], data);
+ },
+ });
+}
diff --git a/lib/api.ts b/lib/api.ts
index b78bba57..3307225d 100644
--- a/lib/api.ts
+++ b/lib/api.ts
@@ -71,6 +71,8 @@ import {
LifiQuoteResponse,
LifiStatusResponse,
MppCredentialsResponse,
+ OnrampAutomationRail,
+ OnrampAutomationResponseDto,
Points,
PromotionsBannerResponse,
ProvisioningSessionRequest,
@@ -877,6 +879,53 @@ export const getCardContracts = async (): Promise =>
return response.json();
};
+/**
+ * Rain onramp automation: persistent virtual bank account (ACH + Wire).
+ * Returns 404 when the user has not yet created an automation.
+ */
+export const getOnrampAutomation = async (): Promise => {
+ const jwt = getJWTToken();
+
+ const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/onramp-automations`, {
+ credentials: 'include',
+ headers: {
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ });
+
+ if (response.status === 404) return null;
+ if (!response.ok) throw response;
+
+ return response.json();
+};
+
+/**
+ * Creates an onramp automation for the current user. Idempotent — the backend
+ * returns the existing automation if one is already active. Throws a Response
+ * with status 412 if Rain KYC is incomplete.
+ */
+export const createOnrampAutomation = async (
+ rail: OnrampAutomationRail = 'ach',
+): Promise => {
+ const jwt = getJWTToken();
+
+ const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/onramp-automations`, {
+ method: 'POST',
+ credentials: 'include',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...getPlatformHeaders(),
+ ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}),
+ },
+ body: JSON.stringify({ rail }),
+ });
+
+ if (!response.ok) throw response;
+
+ return response.json();
+};
+
/** Rain MPP: GET wallet eligibility for push provisioning. Throw Response on non-OK. */
export const getWalletEligibility = async (): Promise => {
const jwt = getJWTToken();
diff --git a/lib/types.ts b/lib/types.ts
index e1bdef81..6cd996f5 100644
--- a/lib/types.ts
+++ b/lib/types.ts
@@ -637,6 +637,40 @@ export interface RainContractResponseDto {
onramp?: RainContractOnrampDto;
}
+export type OnrampAutomationRail = 'ach' | 'wire';
+
+export interface OnrampAutomationDepositAddressDto {
+ type: 'fiat';
+ beneficiaryName: string;
+ beneficiaryAddress: string;
+ beneficiaryBankName: string;
+ beneficiaryBankAddress: string;
+ accountNumber: string;
+ routingNumber: string;
+}
+
+export interface OnrampAutomationSourceDto {
+ currency: 'usd';
+ rail: OnrampAutomationRail;
+}
+
+export interface OnrampAutomationDestinationDto {
+ currency: string;
+ rail: string;
+ address: { type: 'onchain'; address: string };
+}
+
+export interface OnrampAutomationResponseDto {
+ id: string;
+ rainAutomationId: string;
+ status: 'active' | 'deleted' | 'failed';
+ source: OnrampAutomationSourceDto;
+ destination: OnrampAutomationDestinationDto;
+ depositAddress: OnrampAutomationDepositAddressDto;
+ createdAt: string;
+ updatedAt: string;
+}
+
export enum LayerZeroTransactionStatus {
INFLIGHT = 'INFLIGHT',
CONFIRMING = 'CONFIRMING',