diff --git a/app/(protected)/(tabs)/activity/[clientTxId].tsx b/app/(protected)/(tabs)/activity/[clientTxId].tsx index 9ae6c3480..a037ed484 100644 --- a/app/(protected)/(tabs)/activity/[clientTxId].tsx +++ b/app/(protected)/(tabs)/activity/[clientTxId].tsx @@ -3,8 +3,8 @@ import { Linking, Pressable, View } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; import * as Sentry from '@sentry/react-native'; import { useQuery } from '@tanstack/react-query'; -import { format, minutesToSeconds } from 'date-fns'; -import { ArrowUpRight, ChevronLeft, X } from 'lucide-react-native'; +import { format, formatDistanceStrict, minutesToSeconds } from 'date-fns'; +import { ArrowUpRight, X } from 'lucide-react-native'; import { mainnet } from 'viem/chains'; import Diamond from '@/assets/images/diamond'; @@ -13,6 +13,7 @@ import CopyToClipboard from '@/components/CopyToClipboard'; import EstimatedTime from '@/components/EstimatedTime'; import PageLayout from '@/components/PageLayout'; import RenderTokenIcon from '@/components/RenderTokenIcon'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { Underline } from '@/components/ui/underline'; @@ -92,6 +93,21 @@ const Value = memo(function Value({ children, className }: ValueProps) { return {children}; }); +const EscrowTimeLeft = memo(function EscrowTimeLeft({ payoutAt }: { payoutAt: string }) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const interval = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(interval); + }, []); + + const target = useMemo(() => new Date(payoutAt).getTime(), [payoutAt]); + + if (target - now <= 0) return Releasing soon; + + return {formatDistanceStrict(target, now)}; +}); + const Back = memo(function Back({ title, className }: BackProps) { const router = useRouter(); const params = useLocalSearchParams<{ tab?: string; from?: string }>(); @@ -106,12 +122,11 @@ const Back = memo(function Back({ title, className }: BackProps) { }, [params.from, params.tab, router]); return ( - - - - + + + + {title} - ); }); @@ -162,7 +177,11 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ activity, cardProvider, }: CardTransactionDetailProps) { - const merchantName = transaction.merchant_name || transaction.description || 'Unknown'; + const merchantName = ( + transaction.merchant_name?.trim() || + transaction.description?.trim() || + 'Unknown' + ); const merchantLocation = [transaction.merchant_city, transaction.merchant_country] .filter(Boolean) .join(' ') || undefined; @@ -171,6 +190,8 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ const txHash = transaction.crypto_transaction_details?.tx_hash; const isApproved = transaction.status === 'approved'; + const isDeclined = transaction.status === 'declined'; + const isReversed = transaction.status === 'reversed'; const postedDate = useMemo(() => { const dateStr = isApproved ? transaction.authorized_at || transaction.posted_at @@ -193,18 +214,37 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ const cashbackInfo = getCashbackAmount(transaction.id, cashbacks); + const statusLabel = isApproved + ? 'Pending' + : isDeclined + ? 'Declined' + : isReversed + ? 'Reversed' + : 'Confirmed'; + const statusColor = isApproved + ? 'text-yellow-500' + : isDeclined + ? 'text-red-400' + : ''; + const rows = useMemo(() => { const allRows = [ { key: 'from', label: , value: Card }, { key: 'status', label: , - value: ( - - {isApproved ? 'Pending' : 'Confirmed'} - - ), + value: {statusLabel}, }, + isDeclined && + transaction.declined_reason && { + key: 'reason', + label: , + value: ( + + {toTitleCase(transaction.declined_reason)} + + ), + }, cashbackInfo && { key: 'cashback', label: ( @@ -217,12 +257,24 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ - {cashbackInfo.isPending && cashbackInfo.amount !== 'Pending' - ? `${cashbackInfo.amount} (Pending)` - : cashbackInfo.amount} + {cashbackInfo.amount === 'Pending' + ? cashbackInfo.isEscrowed + ? 'Escrowed' + : 'Pending' + : cashbackInfo.isEscrowed + ? `${cashbackInfo.amount} (Escrowed)` + : cashbackInfo.isPending + ? `${cashbackInfo.amount} (Pending)` + : cashbackInfo.amount} ), }, + cashbackInfo?.isEscrowed && + cashbackInfo.payoutAt && { + key: 'cashback-escrow-time-left', + label: , + value: , + }, txHash && { key: 'explorer', label: , @@ -240,7 +292,15 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ ].filter(Boolean) as { key: string; label: React.ReactNode; value: React.ReactNode }[]; return allRows; - }, [cashbackInfo, txHash, handleExplorerPress, isApproved]); + }, [ + cashbackInfo, + txHash, + handleExplorerPress, + statusLabel, + statusColor, + isDeclined, + transaction.declined_reason, + ]); const tokenIcon = useMemo( () => getTokenIcon({ tokenSymbol: transaction.currency?.toUpperCase(), size: 75 }), @@ -253,7 +313,7 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ {merchantLocation && ( - {merchantLocation} + {merchantLocation} )} diff --git a/app/(protected)/(tabs)/add-referrer.tsx b/app/(protected)/(tabs)/add-referrer.tsx index 6395cd3b8..f9961c5f4 100644 --- a/app/(protected)/(tabs)/add-referrer.tsx +++ b/app/(protected)/(tabs)/add-referrer.tsx @@ -1,10 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { ActivityIndicator, Pressable, TextInput, View } from 'react-native'; +import { ActivityIndicator, TextInput, View } from 'react-native'; import { router } from 'expo-router'; -import { ArrowLeft } from 'lucide-react-native'; import InfoError from '@/assets/images/info-error'; import PageLayout from '@/components/PageLayout'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; @@ -68,9 +68,7 @@ export default function AddReferrer() { - router.back()} className="web:hover:opacity-70"> - - + Enter your friend's referral code diff --git a/app/(protected)/(tabs)/bridge-kyc.tsx b/app/(protected)/(tabs)/bridge-kyc.tsx index 491e5750d..7e4dee4ae 100644 --- a/app/(protected)/(tabs)/bridge-kyc.tsx +++ b/app/(protected)/(tabs)/bridge-kyc.tsx @@ -1,9 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; -import { Pressable, StyleSheet, View } from 'react-native'; +import { StyleSheet, View } from 'react-native'; import { useLocalSearchParams, useRouter } from 'expo-router'; -import { ArrowLeft } from 'lucide-react-native'; import PageLayout from '@/components/PageLayout'; +import { BackButton } from '@/components/ui/back-button'; import { Text } from '@/components/ui/text'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { track } from '@/lib/analytics'; @@ -246,12 +246,7 @@ export default function BridgeKyc({ onSuccess }: BridgeKycParams = {}) { - (router.canGoBack() ? router.back() : router.replace('/'))} - className="web:hover:opacity-70" - > - - + Verify identity diff --git a/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx b/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx index 359a2627f..9943e63e7 100644 --- a/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx +++ b/app/(protected)/(tabs)/card-onboard/country-verification-required.tsx @@ -1,9 +1,10 @@ import React from 'react'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { useRouter } from 'expo-router'; -import { ArrowLeft, ShieldAlert } from 'lucide-react-native'; +import { ShieldAlert } from 'lucide-react-native'; import PageLayout from '@/components/PageLayout'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; @@ -42,9 +43,7 @@ export default function CountryVerificationRequired() { {/* Header */} - - - + Verification Required diff --git a/app/(protected)/(tabs)/card-onboard/country_selection.tsx b/app/(protected)/(tabs)/card-onboard/country_selection.tsx index d78bf34be..da4d4ec3f 100644 --- a/app/(protected)/(tabs)/card-onboard/country_selection.tsx +++ b/app/(protected)/(tabs)/card-onboard/country_selection.tsx @@ -9,12 +9,13 @@ import { View, } from 'react-native'; import { useRouter } from 'expo-router'; -import { ArrowLeft, ChevronDown } from 'lucide-react-native'; +import { ChevronDown } from 'lucide-react-native'; import { useShallow } from 'zustand/react/shallow'; import CountryFlagImage from '@/components/CountryFlagImage'; import { NotificationEmailModalDialog } from '@/components/NotificationEmailModal/NotificationEmailModalDialog'; import PageLayout from '@/components/PageLayout'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { COUNTRIES, Country } from '@/constants/countries'; @@ -330,9 +331,7 @@ export default function CountrySelection() { /> - - - + Solid card diff --git a/app/(protected)/(tabs)/card/activate/country_selection.tsx b/app/(protected)/(tabs)/card/activate/country_selection.tsx index e863629bd..a7520d378 100644 --- a/app/(protected)/(tabs)/card/activate/country_selection.tsx +++ b/app/(protected)/(tabs)/card/activate/country_selection.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ActivityIndicator, Modal, Pressable, ScrollView, TextInput, View } from 'react-native'; import { useRouter } from 'expo-router'; -import { ArrowLeft, ChevronDown } from 'lucide-react-native'; +import { ChevronDown } from 'lucide-react-native'; import { useShallow } from 'zustand/react/shallow'; import CountryFlagImage from '@/components/CountryFlagImage'; import PageLayout from '@/components/PageLayout'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { COUNTRIES, Country } from '@/constants/countries'; @@ -260,9 +261,7 @@ export default function ActivateCountrySelection() { - - - + Solid card diff --git a/app/(protected)/(tabs)/card/deposit.tsx b/app/(protected)/(tabs)/card/deposit.tsx index 70061b450..63597f03d 100644 --- a/app/(protected)/(tabs)/card/deposit.tsx +++ b/app/(protected)/(tabs)/card/deposit.tsx @@ -3,10 +3,10 @@ import { ActivityIndicator, Pressable, View } from 'react-native'; import Toast from 'react-native-toast-message'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; -import { ArrowLeft } from 'lucide-react-native'; import { Address, formatUnits } from 'viem'; import PageLayout from '@/components/PageLayout'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; @@ -216,9 +216,7 @@ const DepositToCard = () => { - router.back()} className="web:hover:opacity-70"> - - + Deposit to card diff --git a/app/(protected)/(tabs)/card/details.tsx b/app/(protected)/(tabs)/card/details.tsx index 189acf128..ffd3cd2bf 100644 --- a/app/(protected)/(tabs)/card/details.tsx +++ b/app/(protected)/(tabs)/card/details.tsx @@ -13,12 +13,10 @@ import * as Clipboard from 'expo-clipboard'; import { Image } from 'expo-image'; import { LinearGradient } from 'expo-linear-gradient'; import { useRouter } from 'expo-router'; -import { useQuery } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, Copy, - CreditCard, KeyRound, Plus, Settings, @@ -29,10 +27,6 @@ import { BorrowPositionCard } from '@/components/Card/BorrowPositionCard'; import { CircularActionButton } from '@/components/Card/CircularActionButton'; import DepositToCardModal from '@/components/Card/DepositToCardModal'; import ManagePinModal from '@/components/Card/ManagePinModal'; -import CancelPhysicalCardModal from '@/components/Card/CancelPhysicalCardModal'; -import OrderPhysicalCardModal, { - PHYSICAL_CARD_STATUS_QUERY_KEY, -} from '@/components/Card/OrderPhysicalCardModal'; import WithdrawToCardModal from '@/components/Card/WithdrawToCardModal'; import PageLayout from '@/components/PageLayout'; import { Button } from '@/components/ui/button'; @@ -52,11 +46,11 @@ import { useCardProvider } from '@/hooks/useCardProvider'; import { useCardWithdrawals } from '@/hooks/useCardWithdrawals'; import { useCustomer } from '@/hooks/useCustomer'; import { useDimension } from '@/hooks/useDimension'; -import { freezeCard, getPhysicalCardStatus, unfreezeCard } from '@/lib/api'; +import { freezeCard, unfreezeCard } from '@/lib/api'; import { getAsset } from '@/lib/assets'; import { EXPO_PUBLIC_ENVIRONMENT } from '@/lib/config'; import { CardHolderName, CardProvider, CardStatus, FreezeInitiator, KycStatus } from '@/lib/types'; -import { cn, withRefreshToken } from '@/lib/utils/utils'; +import { cn } from '@/lib/utils/utils'; import { CardDepositSource, useCardDepositStore } from '@/store/useCardDepositStore'; export default function CardDetails() { @@ -72,18 +66,8 @@ export default function CardDetails() { const [isLoadingCardDetails, setIsLoadingCardDetails] = useState(false); const [shouldRevealDetails, setShouldRevealDetails] = useState(false); const [isAddToWalletModalOpen, setIsAddToWalletModalOpen] = useState(false); - const [isOrderPhysicalCardModalOpen, setIsOrderPhysicalCardModalOpen] = useState(false); - const [isCancelPhysicalCardModalOpen, setIsCancelPhysicalCardModalOpen] = useState(false); const flipAnimation = useRef(new Animated.Value(0)).current; - const { data: physicalCardStatusData } = useQuery({ - queryKey: [PHYSICAL_CARD_STATUS_QUERY_KEY], - queryFn: () => withRefreshToken(() => getPhysicalCardStatus()), - enabled: provider === CardProvider.RAIN, - }); - - const hasPhysicalCard = physicalCardStatusData?.hasPhysicalCard ?? false; - const availableBalance = cardDetails?.balances.available; const availableAmount = Number(availableBalance?.amount || '0').toString(); const isCardFrozen = cardDetails?.status === CardStatus.FROZEN; @@ -158,12 +142,6 @@ export default function CardDetails() { onFreezeToggle={handleFreezeToggle} isWithdrawFromCardAllowed={isWithdrawFromCardAllowed} isRain={provider === CardProvider.RAIN} - hasPhysicalCard={hasPhysicalCard} - onPhysicalCardPress={() => - hasPhysicalCard - ? setIsCancelPhysicalCardModalOpen(true) - : setIsOrderPhysicalCardModalOpen(true) - } /> ) : ( @@ -222,16 +200,6 @@ export default function CardDetails() { onOpenChange={setIsAddToWalletModalOpen} trigger={null} /> - - ); } @@ -262,12 +230,6 @@ export default function CardDetails() { onFreezeToggle={handleFreezeToggle} isWithdrawFromCardAllowed={isWithdrawFromCardAllowed} isRain={provider === CardProvider.RAIN} - hasPhysicalCard={hasPhysicalCard} - onPhysicalCardPress={() => - hasPhysicalCard - ? setIsCancelPhysicalCardModalOpen(true) - : setIsOrderPhysicalCardModalOpen(true) - } /> @@ -283,16 +245,6 @@ export default function CardDetails() { onOpenChange={setIsAddToWalletModalOpen} trigger={null} /> - - ); } @@ -311,8 +263,6 @@ interface DesktopHeaderProps { onFreezeToggle: () => Promise; isWithdrawFromCardAllowed: boolean; isRain: boolean; - hasPhysicalCard: boolean; - onPhysicalCardPress: () => void; } function DesktopHeader({ @@ -325,8 +275,6 @@ function DesktopHeader({ onFreezeToggle, isWithdrawFromCardAllowed, isRain, - hasPhysicalCard, - onPhysicalCardPress, }: DesktopHeaderProps) { const [isManageOpen, setIsManageOpen] = useState(false); const manageRef = useRef(null); @@ -430,22 +378,6 @@ function DesktopHeader({ )} )} - {isRain && ( - - )} {isWithdrawFromCardAllowed && ( Promise; isWithdrawFromCardAllowed: boolean; isRain: boolean; - hasPhysicalCard: boolean; - onPhysicalCardPress: () => void; } function CardActions({ @@ -869,8 +799,6 @@ function CardActions({ onFreezeToggle, isWithdrawFromCardAllowed, isRain, - hasPhysicalCard, - onPhysicalCardPress, }: CardActionsProps) { const [isManageSheetOpen, setIsManageSheetOpen] = useState(false); const showManageButton = isRain || !isCardFrozen || canUnfreeze; @@ -962,20 +890,6 @@ function CardActions({ )} - {isRain && ( - - - - - - {hasPhysicalCard ? 'Cancel' : 'Physical'} - - - )} {isWithdrawFromCardAllowed && ( - - router.canGoBack() ? router.back() : router.replace(path.CARD_DETAILS) - } - className="web:hover:opacity-70" - > - - + Solid card transactions diff --git a/app/(protected)/(tabs)/card/pending.tsx b/app/(protected)/(tabs)/card/pending.tsx index 5dbf3c5af..136b5cd10 100644 --- a/app/(protected)/(tabs)/card/pending.tsx +++ b/app/(protected)/(tabs)/card/pending.tsx @@ -1,9 +1,55 @@ +import { useEffect } from 'react'; +import { useRouter } from 'expo-router'; + import { CardStatusPage } from '@/components/Card/CardStatusPage'; +import { path } from '@/constants/path'; +import { useCardStatus } from '@/hooks/useCardStatus'; +import { CardStatus, KycStatus, RainApplicationStatus } from '@/lib/types'; +import { hasCard } from '@/lib/utils'; + +const POLL_INTERVAL_MS = 5000; export default function CardPending() { + const router = useRouter(); + const { data: cardStatusResponse } = useCardStatus({ refetchInterval: POLL_INTERVAL_MS }); + + useEffect(() => { + if (!cardStatusResponse) return; + + // User already has a card (e.g. status synced after this tab was open). + if (hasCard(cardStatusResponse) && cardStatusResponse.status !== CardStatus.PENDING) { + router.replace(path.CARD_DETAILS); + return; + } + + const { kycStatus, rainApplicationStatus } = cardStatusResponse; + + // Still under manual review — keep showing the pending page. + if (kycStatus === KycStatus.UNDER_REVIEW) return; + + // Didit approved. + if (kycStatus === KycStatus.APPROVED) { + if (rainApplicationStatus === RainApplicationStatus.APPROVED) { + router.replace(path.CARD_READY); + } else { + // Rain still needs to finish (pending, needsInformation, etc.) — let + // the activate page render the appropriate next step / status. + router.replace(path.CARD_ACTIVATE); + } + return; + } + + // Any other terminal/incomplete state (rejected, offboarded, incomplete, + // resubmitted, etc.) — bounce back to the activate page so the user sees + // the error or retry CTA. + if (kycStatus && kycStatus !== KycStatus.NOT_STARTED) { + router.replace(`${String(path.CARD_ACTIVATE)}?kycStatus=${kycStatus}` as any); + } + }, [cardStatusResponse, router]); + return ( ; + +const initialConsents: ConsentState = { + agreedToEsign: false, + agreedToAccountOpeningPrivacy: false, + isTermsOfServiceAccepted: false, + agreedToCertify: false, + agreedToNoSolicitation: false, +}; + +const ESIGN_CONSENT_URL = + 'https://support.solid.xyz/en/articles/14167249-e-sign-electronic-communications-notice'; +const ACCOUNT_OPENING_PRIVACY_URL = + 'https://support.solid.xyz/en/articles/14285527-account-opening-privacy-notice-fuse-network-lt-solid-xyz'; +const US_CARD_TERMS_URL = + 'https://support.solid.xyz/en/articles/14285503-fuse-network-ltd-card-terms-for-u-s-consumer-program'; +const INTL_CARD_TERMS_URL = + 'https://support.solid.xyz/en/articles/14167076-card-terms-for-international-consumer-program'; +const ISSUER_PRIVACY_URL = 'https://www.third-national.com/privacypolicy'; + +const underlineProps = { + textClassName: 'text-sm font-bold text-white' as const, + borderColor: 'rgba(255, 255, 255, 1)' as const, +}; export default function CardReady() { const router = useRouter(); const queryClient = useQueryClient(); const [activating, setActivating] = useState(false); + const [consents, setConsents] = useState(initialConsents); + + const countryCode = useCountryStore(state => state.countryInfo?.countryCode); + const isUS = countryCode?.toUpperCase() === 'US'; + const cardTermsUrl = isUS ? US_CARD_TERMS_URL : INTL_CARD_TERMS_URL; + + const requiredKeys = useMemo( + () => + isUS + ? [ + 'agreedToEsign', + 'agreedToAccountOpeningPrivacy', + 'isTermsOfServiceAccepted', + 'agreedToCertify', + 'agreedToNoSolicitation', + ] + : [ + 'agreedToEsign', + 'isTermsOfServiceAccepted', + 'agreedToCertify', + 'agreedToNoSolicitation', + ], + [isUS], + ); + + const allAccepted = useMemo( + () => requiredKeys.every(key => consents[key]), + [requiredKeys, consents], + ); + + const toggle = (key: ConsentKey) => setConsents(prev => ({ ...prev, [key]: !prev[key] })); const handleActivateCard = async () => { + if (!allAccepted) return; + try { setActivating(true); + + await withRefreshToken(() => + submitCardConsents({ + ...consents, + // Non-US users never see this consent; send false so the field is always present. + agreedToAccountOpeningPrivacy: isUS ? consents.agreedToAccountOpeningPrivacy : false, + }), + ); + const card = await withRefreshToken(() => createCard()); if (!card) throw new Error('Failed to create card'); @@ -49,15 +127,67 @@ export default function CardReady() { }; return ( - + + + toggle('agreedToEsign')}> + I accept the{' '} + Linking.openURL(ESIGN_CONSENT_URL)}> + E-Sign Consent + + . + + + {isUS && ( + toggle('agreedToAccountOpeningPrivacy')} + > + I accept the{' '} + Linking.openURL(ACCOUNT_OPENING_PRIVACY_URL)} + > + Account Opening Privacy Notice + + . + + )} + + toggle('isTermsOfServiceAccepted')} + > + I accept the{' '} + Linking.openURL(cardTermsUrl)}> + Solid Card Terms + {' '} + and the{' '} + Linking.openURL(ISSUER_PRIVACY_URL)}> + Issuer Privacy Policy + + . + + + toggle('agreedToCertify')}> + I certify that the information I have provided is accurate and that I will abide by all + the rules and requirements related to my Solid Spend Card. + + + toggle('agreedToNoSolicitation')} + > + I acknowledge that applying for the Solid Spend Card does not constitute unauthorized + solicitation. + + + + + ) : ( + <> + setDepositOpen(true)} + onGenerateApiKey={handleGenerate} + onCopyPrompt={handleCopyPrompt} + /> + + + + + + + revokeApiKey.mutate(id)} + revokingId={ + revokeApiKey.isPending ? (revokeApiKey.variables as string) : undefined + } + /> + + + + + How to use + + + + )} + + setRevealedKey(null)} + apiKey={revealedKey} + /> + setDepositOpen(false)} + agentEoaAddress={agentEoaAddress} + /> + + + ); +} + +interface ProvisionedHeaderProps { + isScreenMedium: boolean; + isGenerating: boolean; + onDeposit: () => void; + onGenerateApiKey: () => void; + onCopyPrompt: () => void; +} + +function ProvisionedHeader({ + isScreenMedium, + isGenerating, + onDeposit, + onGenerateApiKey, + onCopyPrompt, +}: ProvisionedHeaderProps) { + if (isScreenMedium) { + return ( + + + Agent Wallet + Your Solid Wallet is now Agentic + + + + + + + + ); + } + + return ( + + + Agent Wallet + Your Solid Wallet is now Agentic + + + } label="Deposit" onPress={onDeposit} /> + + ) : ( + + ) + } + label="API key" + onPress={onGenerateApiKey} + variant="dark" + disabled={isGenerating} + /> + } + label="Prompt" + onPress={onCopyPrompt} + variant="dark" + /> + + + ); +} + +interface CircleActionProps { + icon: React.ReactNode; + label: string; + onPress: () => void; + variant?: 'brand' | 'dark'; + disabled?: boolean; +} + +function CircleAction({ icon, label, onPress, variant = 'brand', disabled }: CircleActionProps) { + return ( + + + {icon} + + {label} + + ); +} + +interface BalanceCardProps { + balance?: bigint; + balanceLoading: boolean; +} + +/** + * Mirrors /card/details `SpendingBalanceCard` shape — rounded-[20px] base + * with a LinearGradient overlay, big balance up top, secondary stat under + * it. Blue palette so the agent page reads distinctly from the green card + * page, purple savings, and yellow rewards. + */ +function BalanceCard({ balance, balanceLoading }: BalanceCardProps) { + const formatted = balanceLoading ? null : formatUsdc(balance); + return ( + + + + + Spendable balance + {balanceLoading ? ( + + ) : ( + {formatted} + )} + + + Earning + Yield on idle USDC + + + + ); +} + +interface ApiKeysCardProps { + address?: string; + apiKeys: Parameters[0]['apiKeys']; + isLoading: boolean; + onRevoke: (id: string) => void; + revokingId?: string; +} + +function ApiKeysCard({ address, apiKeys, isLoading, onRevoke, revokingId }: ApiKeysCardProps) { + return ( + + + API Keys + + Authenticate AI tools that pay through your agent wallet. + + + {address ? ( + + Agent wallet address + + + {eclipseAddress(address, 8, 6)} + + + + + ) : null} + + + ); +} diff --git a/app/notifications.native.tsx b/app/notifications.native.tsx index f6e3eb53a..ecf6e97ab 100644 --- a/app/notifications.native.tsx +++ b/app/notifications.native.tsx @@ -1,10 +1,10 @@ import { useState } from 'react'; -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useRouter } from 'expo-router'; -import { ArrowLeft } from 'lucide-react-native'; import Notification from '@/assets/images/notification'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; @@ -14,10 +14,6 @@ export default function Notifications() { const router = useRouter(); const [isLoading, setIsLoading] = useState(false); - const handleBack = () => { - router.back(); - }; - const handleContinue = async () => { setIsLoading(true); @@ -36,12 +32,7 @@ export default function Notifications() { {/* Header with back button */} - - - + {/* Content - centered */} diff --git a/app/signup/email.tsx b/app/signup/email.tsx index 25ae42ba6..3f94958c7 100644 --- a/app/signup/email.tsx +++ b/app/signup/email.tsx @@ -5,12 +5,12 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { Image } from 'expo-image'; import { Link, useRouter } from 'expo-router'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ArrowLeft } from 'lucide-react-native'; import { z } from 'zod'; import { useShallow } from 'zustand/react/shallow'; import InfoError from '@/assets/images/info-error'; import { DesktopCarousel } from '@/components/Onboarding'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import Input from '@/components/ui/input'; @@ -207,12 +207,9 @@ export default function SignupEmail() { {/* Back button - positioned above form on desktop */} {isDesktop && ( - - - + + + )} {/* Header */} @@ -316,12 +313,7 @@ export default function SignupEmail() { {/* Header with back button */} - - - + {/* Content - flex between to push button to bottom */} diff --git a/app/signup/otp.tsx b/app/signup/otp.tsx index 53c5d1cc7..ad32d4178 100644 --- a/app/signup/otp.tsx +++ b/app/signup/otp.tsx @@ -1,16 +1,16 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { ActivityIndicator, Pressable, View } from 'react-native'; +import { ActivityIndicator, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import { zodResolver } from '@hookform/resolvers/zod'; -import { ArrowLeft } from 'lucide-react-native'; import { z } from 'zod'; import { useShallow } from 'zustand/react/shallow'; import InfoError from '@/assets/images/info-error'; import { DesktopCarousel } from '@/components/Onboarding'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { OtpInput } from '@/components/ui/otp-input'; import { Text } from '@/components/ui/text'; @@ -240,12 +240,9 @@ export default function SignupOtp() { {/* Back button - positioned above form on desktop */} {isDesktop && ( - - - + + + )} {/* Header */} @@ -321,12 +318,7 @@ export default function SignupOtp() { {/* Header with back button */} - - - + {/* Content - positioned at top, centered horizontally */} diff --git a/app/signup/passkey.tsx b/app/signup/passkey.tsx index 167e1c72c..b1e134843 100644 --- a/app/signup/passkey.tsx +++ b/app/signup/passkey.tsx @@ -1,17 +1,17 @@ import { useEffect, useState } from 'react'; -import { ActivityIndicator, Linking, Platform, Pressable, View } from 'react-native'; +import { ActivityIndicator, Linking, Platform, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; import * as Sentry from '@sentry/react-native'; import { useTurnkey } from '@turnkey/react-native-wallet-kit'; -import { ArrowLeft } from 'lucide-react-native'; import { useShallow } from 'zustand/react/shallow'; import LoginKeyIcon from '@/assets/images/login_key_icon'; import PasskeySvg from '@/assets/images/passkey-svg'; import { DesktopCarousel } from '@/components/Onboarding'; +import { BackButton } from '@/components/ui/back-button'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { Underline } from '@/components/ui/underline'; @@ -137,12 +137,9 @@ export default function SignupPasskey() { {/* Back button - positioned above form on desktop */} {isDesktop && ( - - - + + + )} {/* Passkey Icon */} @@ -192,12 +189,7 @@ export default function SignupPasskey() { {/* Header with back button */} - - - + {/* Content - positioned at top, centered horizontally */} diff --git a/app/welcome.tsx b/app/welcome.tsx index 24118c09e..fbe09d496 100644 --- a/app/welcome.tsx +++ b/app/welcome.tsx @@ -1,9 +1,10 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; import { Image } from 'expo-image'; import { useLocalSearchParams, useRouter } from 'expo-router'; +import { useTurnkey } from '@turnkey/react-native-wallet-kit'; import { useShallow } from 'zustand/react/shallow'; import LoginKeyIcon from '@/assets/images/login_key_icon'; @@ -19,17 +20,24 @@ import { useUserStore } from '@/store/useUserStore'; export default function Welcome() { const { handleRemoveUsers, handleSelectUserById } = useUser(); - const { users, _hasHydrated } = useUserStore( - useShallow(state => ({ - users: state.users, - _hasHydrated: state._hasHydrated, - })), - ); + const { users, _hasHydrated, pendingAuthUserId, selectUserById, unselectUser, setPendingAuthUserId } = + useUserStore( + useShallow(state => ({ + users: state.users, + _hasHydrated: state._hasHydrated, + pendingAuthUserId: state.pendingAuthUserId, + selectUserById: state.selectUserById, + unselectUser: state.unselectUser, + setPendingAuthUserId: state.setPendingAuthUserId, + })), + ); + const { httpClient } = useTurnkey(); const router = useRouter(); const { isDesktop } = useDimension(); - const [loadingUserId, setLoadingUserId] = useState(null); const { session } = useLocalSearchParams<{ session: string }>(); const passkeyUsers = users.filter(user => user.hasPasskey !== false); + const selectedUserId = users.find(u => u.selected)?.userId; + const isAuthInFlight = useRef(false); // Redirect to onboarding if no users exist (e.g., after session expired with empty user list) useEffect(() => { @@ -51,13 +59,26 @@ export default function Welcome() { } }, [session]); - const handleSelectUser = useCallback( - async (userId: string) => { - setLoadingUserId(userId); - try { - await handleSelectUserById(userId); - } catch (error: any) { - // Show error toast if passkey authentication fails + // After a user is clicked, the store's selected user changes which causes + // TurnkeyProvider to re-mount with that user's credentialId in + // `passkeyConfig.allowCredentials`. This effect waits for the new + // httpClient to be ready and only then triggers the passkey prompt — so the + // authenticator sees the filtered credential list and only the selected + // user's passkey is offered. + useEffect(() => { + if (!pendingAuthUserId) return; + if (!httpClient) return; + if (selectedUserId !== pendingAuthUserId) return; + if (isAuthInFlight.current) return; + + const userId = pendingAuthUserId; + isAuthInFlight.current = true; + + handleSelectUserById(userId) + .catch((error: any) => { + // Revert the pre-selection so the welcome screen reflects the + // un-authenticated state and TurnkeyProvider drops the filter. + unselectUser(); Toast.show({ type: 'error', text1: 'Authentication failed', @@ -66,11 +87,34 @@ export default function Welcome() { badgeText: '', }, }); - } finally { - setLoadingUserId(null); - } + }) + .finally(() => { + // Keep `pendingAuthUserId` set while auth is in flight so every + // button stays disabled, then clear it once we settle. + setPendingAuthUserId(null); + isAuthInFlight.current = false; + }); + }, [ + pendingAuthUserId, + httpClient, + selectedUserId, + handleSelectUserById, + unselectUser, + setPendingAuthUserId, + ]); + + const handleSelectUser = useCallback( + (userId: string) => { + if (pendingAuthUserId || isAuthInFlight.current) return; + + // Marking the user as selected flips TurnkeyProvider's credentialId and + // triggers a re-mount of TurnkeyProviderKit with + // `allowCredentials: [{ id: user.credentialId, ... }]`. The effect above + // picks up once the remount finishes and fires the passkey prompt. + selectUserById(userId); + setPendingAuthUserId(userId); }, - [handleSelectUserById], + [pendingAuthUserId, selectUserById, setPendingAuthUserId], ); const handleUseAnotherAccount = useCallback(() => { @@ -101,7 +145,7 @@ export default function Welcome() { variant="brand" className="h-14 justify-between rounded-xl border-0 px-6" onPress={() => handleSelectUser(user.userId)} - disabled={loadingUserId !== null} + disabled={pendingAuthUserId !== null} > diff --git a/assets/images/cards-desktop.png b/assets/images/cards-desktop.png index 657127657..79e1bdb86 100644 Binary files a/assets/images/cards-desktop.png and b/assets/images/cards-desktop.png differ diff --git a/assets/images/dollar-green.png b/assets/images/dollar-green.png new file mode 100644 index 000000000..3995be390 Binary files /dev/null and b/assets/images/dollar-green.png differ diff --git a/assets/images/globe-green.png b/assets/images/globe-green.png new file mode 100644 index 000000000..f67c5bea6 Binary files /dev/null and b/assets/images/globe-green.png differ diff --git a/assets/images/star-green.png b/assets/images/star-green.png new file mode 100644 index 000000000..f016cbdc8 Binary files /dev/null and b/assets/images/star-green.png differ diff --git a/components/Activity/CardTransactions.tsx b/components/Activity/CardTransactions.tsx index f43959814..3002db0b0 100644 --- a/components/Activity/CardTransactions.tsx +++ b/components/Activity/CardTransactions.tsx @@ -161,6 +161,7 @@ export default function CardTransactions() { .join(' ') || undefined; const initials = getInitials(merchantName); const isPurchase = transaction.category === CardTransactionCategory.PURCHASE; + const isDeclined = transaction.status === 'declined'; const color = getColorForTransaction(merchantName); const cashbackInfo = getCashbackAmount(transaction.id, cashbacks); @@ -207,14 +208,23 @@ export default function CardTransactions() { - {cashbackInfo.isPending ? 'Cashback (Pending)' : 'Cashback'} + {cashbackInfo.isEscrowed + ? 'Cashback (Escrowed)' + : cashbackInfo.isPending + ? 'Cashback (Pending)' + : 'Cashback'} )} - + {formatCardAmount(transaction.amount, provider)} {cashbackInfo && cashbackInfo.amount !== 'Pending' && ( diff --git a/components/Agent/AgentDepositBorrowForm.tsx b/components/Agent/AgentDepositBorrowForm.tsx new file mode 100644 index 000000000..c1c9bd38b --- /dev/null +++ b/components/Agent/AgentDepositBorrowForm.tsx @@ -0,0 +1,154 @@ +import { useMemo, useState } from 'react'; +import { ActivityIndicator, Linking, View } from 'react-native'; +import Toast from 'react-native-toast-message'; + +import { BorrowSlider } from '@/components/Card/BorrowSlider'; +import TokenDetails from '@/components/TokenCard/TokenDetails'; +import { Button } from '@/components/ui/button'; +import Skeleton from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { useAaveBorrowPosition } from '@/hooks/useAaveBorrowPosition'; +import useBorrowAndDepositToAgent from '@/hooks/useBorrowAndDepositToAgent'; +import { isProduction } from '@/lib/config'; +import { Status } from '@/lib/types'; +import { formatNumber } from '@/lib/utils'; + +const SO_USD_LTV = 70n; + +type Props = { + agentEoaAddress?: string; + onSuccess: () => void; +}; + +const AgentDepositBorrowForm = ({ agentEoaAddress, onSuccess }: Props) => { + const [sliderValue, setSliderValue] = useState(0); + const { + totalSupplied, + totalBorrowed, + borrowAPY, + isLoading: positionLoading, + } = useAaveBorrowPosition(); + const { borrowAndDeposit, bridgeStatus } = useBorrowAndDepositToAgent(agentEoaAddress); + + const maxBorrowAmount = useMemo( + () => Math.max(0, totalSupplied * 0.69 - totalBorrowed), + [totalSupplied, totalBorrowed], + ); + + const collateralRequired = useMemo(() => { + if (sliderValue <= 0) return 0; + return (sliderValue * 100) / Number(SO_USD_LTV); + }, [sliderValue]); + + const submitting = bridgeStatus === Status.PENDING; + const amountValid = sliderValue > 0 && sliderValue <= maxBorrowAmount; + + const handleSubmit = async () => { + if (!amountValid || !agentEoaAddress) return; + try { + await borrowAndDeposit(sliderValue.toString()); + Toast.show({ + type: 'success', + text1: 'Deposit submitted', + text2: + 'Borrowed against soUSD on Fuse and sent via Stargate. Funds arrive on Base in ~1–5 min.', + props: { badgeText: 'Success' }, + }); + onSuccess(); + } catch (err) { + Toast.show({ + type: 'error', + text1: 'Deposit failed', + text2: err instanceof Error ? err.message : 'Unknown error', + props: { badgeText: 'Error' }, + }); + } + }; + + return ( + + {!isProduction && ( + + + Borrow contract available only in production + + + soUSD and the Aave borrow market aren't deployed in this environment. The borrow + flow will fail — use the external-wallet option to fund the agent on Base for testing. + + + )} + + + + + + + + Borrow rate + + {positionLoading ? ( + + ) : ( + + {formatNumber(borrowAPY, 2)}% + + )} + + + + + + Collateral Required + + + + {!sliderValue ? ( + + ) : ( + + {formatNumber(collateralRequired)} soUSD + + )} + + + + + Use your soUSD as collateral to borrow USDC and fund your agent wallet on Base while + earning yield.{' '} + { + Linking.openURL( + 'https://support.solid.xyz/en/articles/13545322-borrow-against-your-savings', + ); + }} + className="text-base font-medium leading-5 text-[#94F27F] web:hover:opacity-70" + > + Learn more. + + + + + + + + ); +}; + +export default AgentDepositBorrowForm; diff --git a/components/Agent/AgentDepositExternalForm.tsx b/components/Agent/AgentDepositExternalForm.tsx new file mode 100644 index 000000000..cc93ddee3 --- /dev/null +++ b/components/Agent/AgentDepositExternalForm.tsx @@ -0,0 +1,54 @@ +import { View } from 'react-native'; +import { Image } from 'expo-image'; +import { Info } from 'lucide-react-native'; + +import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; +import { Text } from '@/components/ui/text'; + +const baseIcon = require('@/assets/images/base.png'); + +type Props = { + agentEoaAddress: string; +}; + +const AgentDepositExternalForm = ({ agentEoaAddress }: Props) => { + return ( + + + + + Base + + + Send only USDC on Base to this address. Other tokens or chains may result in permanent + loss of funds. + + + } + /> + + + + + Funds sent here arrive at your agent wallet immediately and do not earn yield. To keep + earning yield on the principal, use the Borrow against savings option instead. + + + + ); +}; + +export default AgentDepositExternalForm; diff --git a/components/Agent/AgentDepositModal.tsx b/components/Agent/AgentDepositModal.tsx new file mode 100644 index 000000000..45b08b966 --- /dev/null +++ b/components/Agent/AgentDepositModal.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Platform, Pressable, View } from 'react-native'; +import { ChevronDown, Leaf, Wallet as WalletIcon } from 'lucide-react-native'; + +import AgentDepositBorrowForm from '@/components/Agent/AgentDepositBorrowForm'; +import AgentDepositExternalForm from '@/components/Agent/AgentDepositExternalForm'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Text } from '@/components/ui/text'; + +const MODAL_OPEN: ModalState = { name: 'agent-deposit', number: 1 }; +const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; + +type AgentDepositSource = 'borrow' | 'external'; + +type Props = { + open: boolean; + onClose: () => void; + agentEoaAddress?: string; +}; + +const AgentDepositModal = ({ open, onClose, agentEoaAddress }: Props) => { + const [source, setSource] = useState('borrow'); + + useEffect(() => { + if (!open) setSource('borrow'); + }, [open]); + + return ( + { + if (!value) onClose(); + }} + trigger={null} + title="Deposit to Agent Wallet" + contentKey="agent-deposit" + containerClassName="min-h-[42rem] overflow-y-auto flex-1" + > + + + From + + + + {source === 'external' && agentEoaAddress ? ( + + ) : ( + + )} + + + ); +}; + +const SOURCE_LABEL: Record = { + borrow: 'Borrow against Savings', + external: 'External Wallet', +}; + +const SOURCE_TOKEN: Record = { + borrow: '', + external: 'USDC', +}; + +const SourceIcon = ({ value }: { value: AgentDepositSource }) => + value === 'borrow' ? ( + + ) : ( + + ); + +const SourceSelector = ({ + value, + onChange, +}: { + value: AgentDepositSource; + onChange: (next: AgentDepositSource) => void; +}) => + Platform.OS === 'web' ? ( + + ) : ( + + ); + +const SourceSelectorWeb = ({ + value, + onChange, +}: { + value: AgentDepositSource; + onChange: (next: AgentDepositSource) => void; +}) => ( + + + + + + {SOURCE_LABEL[value]} + + + {SOURCE_TOKEN[value] ? ( + {SOURCE_TOKEN[value]} + ) : null} + + + + + + onChange('borrow')} + className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer" + > + + Borrow against Savings + + onChange('external')} + className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer" + > + + External Wallet + + + +); + +const SourceSelectorNative = ({ + value, + onChange, +}: { + value: AgentDepositSource; + onChange: (next: AgentDepositSource) => void; +}) => { + const [isOpen, setIsOpen] = useState(false); + + const select = useCallback( + (next: AgentDepositSource) => { + onChange(next); + setIsOpen(false); + }, + [onChange], + ); + + return ( + + setIsOpen(open => !open)} + > + + + {SOURCE_LABEL[value]} + + + {SOURCE_TOKEN[value] ? ( + {SOURCE_TOKEN[value]} + ) : null} + + + + {isOpen && ( + + select('borrow')} + > + + Borrow against Savings + + select('external')} + > + + External Wallet + + + )} + + ); +}; + +export default AgentDepositModal; diff --git a/components/Agent/ApiKeyList.tsx b/components/Agent/ApiKeyList.tsx new file mode 100644 index 000000000..04f19ac4d --- /dev/null +++ b/components/Agent/ApiKeyList.tsx @@ -0,0 +1,65 @@ +import { ActivityIndicator, View } from 'react-native'; + +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; +import { AgentApiKeySummary } from '@/lib/types'; + +type Props = { + apiKeys: AgentApiKeySummary[] | undefined; + isLoading: boolean; + onRevoke: (id: string) => void; + revokingId?: string; +}; + +const formatDate = (iso?: string) => { + if (!iso) return '—'; + return new Date(iso).toLocaleDateString(); +}; + +const ApiKeyList = ({ apiKeys, isLoading, onRevoke, revokingId }: Props) => { + if (isLoading) { + return ( + + + + ); + } + + const active = (apiKeys ?? []).filter(k => !k.revokedAt); + if (active.length === 0) { + return ( + + No API keys yet. Generate one to start integrating with your AI tool. + + ); + } + + return ( + + {active.map(k => ( + + + sk_solid_live_•••••{k.prefix} + + {k.name ? `${k.name} · ` : ''}created {formatDate(k.createdAt)} + {k.lastUsedAt ? ` · last used ${formatDate(k.lastUsedAt)}` : ''} + + + + + ))} + + ); +}; + +export default ApiKeyList; diff --git a/components/Agent/ApiKeyRevealModal.tsx b/components/Agent/ApiKeyRevealModal.tsx new file mode 100644 index 000000000..78b868c42 --- /dev/null +++ b/components/Agent/ApiKeyRevealModal.tsx @@ -0,0 +1,50 @@ +import { View } from 'react-native'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; +import { Button } from '@/components/ui/button'; +import { Text } from '@/components/ui/text'; + +type Props = { + open: boolean; + onClose: () => void; + apiKey: string | null; +}; + +const MODAL_STATE: ModalState = { name: 'agent-api-key-reveal', number: 1 }; +const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; + +const ApiKeyRevealModal = ({ open, onClose, apiKey }: Props) => { + return ( + !isOpen && onClose()} + trigger={null} + title="Your new API key" + contentKey="agent-api-key-reveal" + shouldAnimate={false} + > + + + This is the only time you'll see the full key. Copy it now and store it securely. If + you lose it, generate a new one. + + {apiKey ? ( + + + {apiKey} + + + + ) : null} + + + + ); +}; + +export default ApiKeyRevealModal; diff --git a/components/Agent/IntegrationSnippet.tsx b/components/Agent/IntegrationSnippet.tsx new file mode 100644 index 000000000..6b0426d7a --- /dev/null +++ b/components/Agent/IntegrationSnippet.tsx @@ -0,0 +1,28 @@ +import { View } from 'react-native'; + +import CopyToClipboard from '@/components/CopyToClipboard'; +import { Text } from '@/components/ui/text'; +import { buildAgentIntegrationCurl } from '@/constants/agentPromptTemplate'; +import { EXPO_PUBLIC_FLASH_API_BASE_URL } from '@/lib/config'; + +const IntegrationSnippet = () => { + const snippet = buildAgentIntegrationCurl({ baseUrl: EXPO_PUBLIC_FLASH_API_BASE_URL }); + return ( + + + Paste this curl example into a script or n8n node, or copy the AI prompt template from the + page header to wire up Claude Desktop / ChatGPT instructions. + + + + + {snippet} + + + + + + ); +}; + +export default IntegrationSnippet; diff --git a/components/Card/ActivateCard/ActivateCardHeader.tsx b/components/Card/ActivateCard/ActivateCardHeader.tsx index 58ee6975f..26c0e7609 100644 --- a/components/Card/ActivateCard/ActivateCardHeader.tsx +++ b/components/Card/ActivateCard/ActivateCardHeader.tsx @@ -1,6 +1,6 @@ -import { Pressable, View } from 'react-native'; -import { ArrowLeft } from 'lucide-react-native'; +import { View } from 'react-native'; +import { BackButton } from '@/components/ui/back-button'; import { Text } from '@/components/ui/text'; interface ActivateCardHeaderProps { @@ -10,9 +10,7 @@ interface ActivateCardHeaderProps { export function ActivateCardHeader({ onBack }: ActivateCardHeaderProps) { return ( - - - + Solid card diff --git a/components/Card/CardDepositExternalForm.tsx b/components/Card/CardDepositExternalForm.tsx index 7c8a82b66..82ff6896e 100644 --- a/components/Card/CardDepositExternalForm.tsx +++ b/components/Card/CardDepositExternalForm.tsx @@ -158,6 +158,7 @@ export default function CardDepositExternalForm() { type: 'error', text1: 'External deposits not available', text2: 'This card does not support deposits from external wallet', + props: { badgeText: '' }, }); return; } diff --git a/components/Card/CardDepositInternalForm.tsx b/components/Card/CardDepositInternalForm.tsx index d3bea95cb..0a37eb4e3 100644 --- a/components/Card/CardDepositInternalForm.tsx +++ b/components/Card/CardDepositInternalForm.tsx @@ -10,13 +10,21 @@ import { import { ActivityIndicator, Linking, Platform, Pressable, TextInput, View } from 'react-native'; import Toast from 'react-native-toast-message'; import { Image } from 'expo-image'; -import { ChevronDown, Info, Leaf, Wallet as WalletIcon } from 'lucide-react-native'; +import { + ChevronDown, + ChevronRight, + Fuel, + Info, + Leaf, + Wallet as WalletIcon, +} from 'lucide-react-native'; import { Address, erc20Abi, formatUnits, parseUnits, TransactionReceipt } from 'viem'; import { fuse, mainnet } from 'viem/chains'; import { useReadContract } from 'wagmi'; import { z } from 'zod'; import { useShallow } from 'zustand/react/shallow'; +import DepositPublicAddress from '@/components/DepositOption/DepositPublicAddress'; import Max from '@/components/Max'; import TokenDetails from '@/components/TokenCard/TokenDetails'; import { Button } from '@/components/ui/button'; @@ -38,15 +46,32 @@ import useBorrowAndDepositToCard from '@/hooks/useBorrowAndDepositToCard'; import useBridgeToCard from '@/hooks/useBridgeToCard'; import { useCardContracts } from '@/hooks/useCardContracts'; import useCardDeposit from '@/hooks/useCardDeposit'; +import useDepositFromSolidUsdc from '@/hooks/useDepositFromSolidUsdc'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardProvider } from '@/hooks/useCardProvider'; import { usePreviewDepositToCard } from '@/hooks/usePreviewDepositToCard'; import useSwapAndBridgeToCard from '@/hooks/useSwapAndBridgeToCard'; import useUser from '@/hooks/useUser'; +import { WalletTokenButton } from '@/components/WalletTokenSelector'; +import { BRIDGE_TOKENS } from '@/constants/bridge'; +import { useDepositStore } from '@/store/useDepositStore'; import { track } from '@/lib/analytics'; import { getAsset } from '@/lib/assets'; -import { ADDRESSES, EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, isProduction } from '@/lib/config'; -import { CardProvider, Status, TransactionStatus, TransactionType } from '@/lib/types'; +import { + ADDRESSES, + EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, + EXPO_PUBLIC_MINIMUM_SPONSOR_AMOUNT, + isProduction, +} from '@/lib/config'; +import { + CardProvider, + DepositCategory, + Status, + TokenBalance, + TokenType, + TransactionStatus, + TransactionType, +} from '@/lib/types'; import { cn, formatNumber, @@ -59,6 +84,8 @@ import { CardDepositSource, useCardDepositStore } from '@/store/useCardDepositSt import { BorrowSlider } from './BorrowSlider'; +const BASE_USDC_TOKEN_URL = `https://basescan.org/token/${ADDRESSES.base.usdc}`; + type FormData = { amount: string; from: CardDepositSource }; type SourceSelectorProps = { @@ -91,12 +118,14 @@ function SourceSelectorNative({ const getDisplayText = useCallback(() => { if (value === CardDepositSource.WALLET) return 'Wallet'; if (value === CardDepositSource.SAVINGS) return 'Savings'; + if (value === CardDepositSource.EXTERNAL) return 'External Wallet'; return 'Borrow against Savings'; }, [value]); const getTokenSymbol = useCallback(() => { if (from === CardDepositSource.WALLET) return walletTokenSymbol; if (from === CardDepositSource.SAVINGS) return 'soUSD'; + if (from === CardDepositSource.EXTERNAL) return 'USDC'; return ''; }, [from, walletTokenSymbol]); @@ -107,7 +136,7 @@ function SourceSelectorNative({ onPress={() => setIsOpen(!isOpen)} > - {value === CardDepositSource.WALLET ? ( + {value === CardDepositSource.WALLET || value === CardDepositSource.EXTERNAL ? ( ) : value === CardDepositSource.SAVINGS ? ( @@ -159,6 +188,16 @@ function SourceSelectorNative({ Wallet + { + onChange(CardDepositSource.EXTERNAL); + setIsOpen(false); + }} + > + + External Wallet + )} @@ -183,12 +222,14 @@ function SourceSelectorWeb({ const getDisplayText = useCallback(() => { if (value === CardDepositSource.WALLET) return 'Wallet'; if (value === CardDepositSource.SAVINGS) return 'Savings'; + if (value === CardDepositSource.EXTERNAL) return 'External Wallet'; return 'Borrow against Savings'; }, [value]); const getTokenSymbol = useCallback(() => { if (from === CardDepositSource.WALLET) return walletTokenSymbol; if (from === CardDepositSource.SAVINGS) return 'soUSD'; + if (from === CardDepositSource.EXTERNAL) return 'USDC'; return ''; }, [from, walletTokenSymbol]); @@ -197,7 +238,7 @@ function SourceSelectorWeb({ - {value === CardDepositSource.WALLET ? ( + {value === CardDepositSource.WALLET || value === CardDepositSource.EXTERNAL ? ( ) : value === CardDepositSource.SAVINGS ? ( @@ -238,6 +279,13 @@ function SourceSelectorWeb({ Wallet + onChange(CardDepositSource.EXTERNAL)} + className="flex-row items-center gap-2 px-4 py-3 web:cursor-pointer" + > + + External Wallet + ); @@ -289,6 +337,8 @@ type AmountInputProps = { onAmountEntry?: () => void; /** Symbol for Wallet source (e.g. USDC.e or rUSD). */ walletTokenSymbol: string; + /** Optional override for the right-hand token cell (e.g. WalletTokenButton). */ + rightSlot?: React.ReactNode; }; function AmountInput({ @@ -297,6 +347,7 @@ function AmountInput({ from, onAmountEntry, walletTokenSymbol, + rightSlot, }: AmountInputProps) { const getTokenImage = () => { if (from === CardDepositSource.WALLET) return getAsset('images/usdc-4x.png'); @@ -341,14 +392,18 @@ function AmountInput({ /> )} /> - - {getTokenSymbol()} - {getTokenSymbol()} - + {rightSlot ? ( + rightSlot + ) : ( + + {getTokenSymbol()} + {getTokenSymbol()} + + )} ); @@ -526,13 +581,27 @@ export default function CardDepositInternalForm() { // Get all token balances including soUSD const { tokens, isLoading: isBalancesLoading } = useBalances(); - // Get Fuse USDC.e balance (production Wallet) - const { data: fuseUsdcBalance, isLoading: isUsdcBalanceLoading } = useReadContract({ + // Production "From Wallet" card deposit: read USDC balance from the Solid + // Safe AA on the chain the user picked in the token selector. Falls back to + // the Fuse-stargate legacy behaviour when the user hasn't picked anything + // yet (cardDepositSrcChainId is 0 / unsupported). + const cardDepositSrcChainId = useDepositStore(state => state.srcChainId); + const selectedWalletUsdcAddress = + (BRIDGE_TOKENS[cardDepositSrcChainId]?.tokens?.USDC?.address as + | Address + | undefined) ?? undefined; + const hasSelectedWalletUsdc = + !!cardDepositSrcChainId && !!selectedWalletUsdcAddress; + const walletBalanceChainId = hasSelectedWalletUsdc ? cardDepositSrcChainId : fuse.id; + const walletBalanceTokenAddress = hasSelectedWalletUsdc + ? (selectedWalletUsdcAddress as Address) + : USDC_STARGATE; + const { data: walletUsdcBalance, isLoading: isUsdcBalanceLoading } = useReadContract({ abi: erc20Abi, - address: USDC_STARGATE, + address: walletBalanceTokenAddress, functionName: 'balanceOf', args: [user?.safeAddress as Address], - chainId: fuse.id, + chainId: walletBalanceChainId, query: { enabled: !!user?.safeAddress && isProduction }, }); @@ -599,7 +668,7 @@ export default function CardDepositInternalForm() { // Get borrow APY from Aave const { borrowAPY, isLoading: isBorrowAPYLoading } = useAaveBorrowPosition(); - const usdcBalanceAmount = fuseUsdcBalance ? Number(fuseUsdcBalance) / 1e6 : 0; + const usdcBalanceAmount = walletUsdcBalance ? Number(walletUsdcBalance) / 1e6 : 0; const soUsdBalanceAmount = soUsdToken ? Number(soUsdToken.balance) / Math.pow(10, soUsdToken.contractDecimals) : 0; @@ -618,7 +687,11 @@ export default function CardDepositInternalForm() { ? isUsdcBalanceLoading : isTestnetBalanceLoading : isBalancesLoading; - const walletTokenSymbol = isProduction ? 'USDC.e' : getCardDepositTokenSymbol(provider); + const walletTokenSymbol = isProduction + ? cardDepositSrcChainId === fuse.id + ? 'USDC.e' + : 'USDC' + : getCardDepositTokenSymbol(provider); const tokenSymbol = watchedFrom === CardDepositSource.WALLET ? walletTokenSymbol @@ -663,6 +736,9 @@ export default function CardDepositInternalForm() { ADDRESSES.fuse.stargateOftUSDC, ); + const isWalletSourceGaslessGated = + isProduction && watchedFrom === CardDepositSource.WALLET; + const schema = useMemo(() => { return z.object({ amount: z @@ -699,6 +775,38 @@ export default function CardDepositInternalForm() { const { borrowAndDeposit, bridgeStatus: borrowAndDepositStatus } = useBorrowAndDepositToCard(); const { deposit, depositStatus, error: depositError } = useCardDeposit(); + const hasSelectedWalletToken = + watchedFrom === CardDepositSource.WALLET && + isProduction && + hasSelectedWalletUsdc; + const selectedCardWalletToken: TokenBalance | null = useMemo(() => { + if (!hasSelectedWalletToken || !selectedWalletUsdcAddress) return null; + return { + contractTickerSymbol: walletTokenSymbol, + contractName: 'USD Coin', + contractAddress: selectedWalletUsdcAddress, + balance: '0', + contractDecimals: 6, + type: TokenType.ERC20, + chainId: cardDepositSrcChainId, + }; + }, [ + hasSelectedWalletToken, + selectedWalletUsdcAddress, + walletTokenSymbol, + cardDepositSrcChainId, + ]); + const { + deposit: walletCardDeposit, + depositStatus: walletCardDepositStatus, + error: walletCardDepositError, + } = useDepositFromSolidUsdc( + (selectedWalletUsdcAddress ?? '') as Address, + 'USDC', + EXPO_PUBLIC_MINIMUM_SPONSOR_AMOUNT, + DepositCategory.CARD, + ); + // Track form viewed (once on mount) useEffect(() => { if (!hasTrackedFormViewedRef.current) { @@ -802,6 +910,7 @@ export default function CardDepositInternalForm() { type: 'error', text1: 'Deposits not available', text2: 'This card does not support deposits to the funding chain', + props: { badgeText: '' }, }); return; } @@ -900,6 +1009,14 @@ export default function CardDepositInternalForm() { return; } + if (watchedFrom === CardDepositSource.WALLET && isProduction) { + await walletCardDeposit(data.amount); + setTransaction({ amount: Number(data.amount) }); + setModal(CARD_DEPOSIT_MODAL.OPEN_TRANSACTION_STATUS); + reset(); + return; + } + // Check for funding address if (!cardDetails) { Toast.show({ @@ -920,6 +1037,7 @@ export default function CardDepositInternalForm() { type: 'error', text1: 'Deposits not available', text2: 'This card does not support deposits to the funding chain', + props: { badgeText: '' }, }); return; } @@ -985,6 +1103,7 @@ export default function CardDepositInternalForm() { [ watchedFrom, deposit, + walletCardDeposit, estimatedUSDC, exchangeRate, cardDetails, @@ -1014,13 +1133,19 @@ export default function CardDepositInternalForm() { const isFundingAddressLoading = provider === CardProvider.RAIN && contractsLoading; const isWalletDepositPending = !isProduction && watchedFrom === CardDepositSource.WALLET && depositStatus === Status.PENDING; + const isWalletCardDepositPending = + isProduction && + watchedFrom === CardDepositSource.WALLET && + walletCardDepositStatus.status === Status.PENDING; const disabled = bridgeStatus === Status.PENDING || swapAndBridgeStatus === Status.PENDING || isWalletDepositPending || + isWalletCardDepositPending || (watchedFrom !== CardDepositSource.BORROW && isEstimatedUSDCLoading) || (watchedFrom === CardDepositSource.BORROW && isRateLoading) || isFundingAddressLoading || + (isWalletSourceGaslessGated && !hasSelectedWalletToken) || !isValid || !watchedAmount; @@ -1030,8 +1155,9 @@ export default function CardDepositInternalForm() { try { schema.parse({ amount: watchedAmount }); return null; - } catch (error: any) { - return error.errors?.[0]?.message || null; + } catch (error: unknown) { + const err = error as { issues?: { message?: string }[] }; + return err.issues?.[0]?.message ?? null; } }, [watchedAmount, schema]); @@ -1104,6 +1230,31 @@ export default function CardDepositInternalForm() { } }, [showBorrowOption, watchedFrom, setValue]); + const fundingAddress = useMemo( + () => getCardFundingAddress(cardDetails, provider, contracts ?? undefined), + [cardDetails, provider, contracts], + ); + + const externalWalletDescription = useMemo( + () => ( + + + Transfer USDC on Base chain. + + Linking.openURL(BASE_USDC_TOKEN_URL)} + className="web:hover:opacity-50" + > + + See token address + + + + + ), + [], + ); + return ( - {watchedFrom === CardDepositSource.BORROW ? ( + {watchedFrom === CardDepositSource.EXTERNAL ? ( + + {isFundingAddressLoading ? ( + + + + ) : ( + + )} + + ) : watchedFrom === CardDepositSource.BORROW ? ( setModal(CARD_DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR)} + /> + ) : undefined + } /> )} - + {watchedFrom !== CardDepositSource.EXTERNAL && } - {watchedFrom !== CardDepositSource.BORROW && ( - + {watchedFrom !== CardDepositSource.BORROW && + watchedFrom !== CardDepositSource.EXTERNAL && ( + + )} + + {isWalletSourceGaslessGated && ( + + + + Gasless deposit + + )} - + {watchedFrom !== CardDepositSource.EXTERNAL && ( + + )} - {watchedFrom === CardDepositSource.BORROW ? ( + {watchedFrom === CardDepositSource.EXTERNAL ? null : watchedFrom === + CardDepositSource.BORROW ? ( { const isOptions = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_OPTIONS.name; const isInternal = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM.name; const isExternal = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_EXTERNAL_FORM.name; + const isTokenSelector = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_TOKEN_SELECTOR.name; const isTransactionStatus = currentModal.name === CARD_DEPOSIT_MODAL.OPEN_TRANSACTION_STATUS.name; const shouldAnimate = previousModal.name !== CARD_DEPOSIT_MODAL.CLOSE.name; const isForward = currentModal.number > previousModal.number; @@ -90,16 +94,18 @@ const CardDepositModalProvider = () => { const getTitle = useCallback(() => { if (isTransactionStatus) return undefined; + if (isTokenSelector) return 'Select token'; return 'Deposit to Card'; - }, [isTransactionStatus]); + }, [isTransactionStatus, isTokenSelector]); const getContentKey = useCallback(() => { if (isTransactionStatus) return 'transaction-status'; if (isOptions) return 'options'; if (isInternal) return 'internal'; if (isExternal) return 'external'; + if (isTokenSelector) return 'token-selector'; return 'options'; - }, [isTransactionStatus, isOptions, isInternal, isExternal]); + }, [isTransactionStatus, isOptions, isInternal, isExternal, isTokenSelector]); const getContent = useCallback(() => { if (isTransactionStatus) { @@ -118,12 +124,14 @@ const CardDepositModalProvider = () => { if (isOptions) return ; if (isInternal) return ; if (isExternal) return ; + if (isTokenSelector) return ; return ; }, [ isTransactionStatus, isOptions, isInternal, isExternal, + isTokenSelector, transaction.amount, handleTransactionStatusPress, ]); @@ -144,8 +152,15 @@ const CardDepositModalProvider = () => { ); const handleBackPress = useCallback(() => { + if (isTokenSelector) { + // Preserve the Wallet source so the internal form re-mounts on the + // wallet option (the only path that opens the token selector). + setSource(CardDepositSource.WALLET); + setModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM); + return; + } setModal(CARD_DEPOSIT_MODAL.CLOSE); - }, [setModal]); + }, [isTokenSelector, setSource, setModal]); return ( { title={getTitle()} containerClassName="min-h-[42rem] overflow-y-auto flex-1" contentKey={getContentKey()} - showBackButton={isInternal && !isTransactionStatus} + showBackButton={(isInternal || isTokenSelector) && !isTransactionStatus} onBackPress={handleBackPress} shouldAnimate={shouldAnimate} isForward={isForward} diff --git a/components/Card/CardDepositTokenSelector.tsx b/components/Card/CardDepositTokenSelector.tsx new file mode 100644 index 000000000..8add14d63 --- /dev/null +++ b/components/Card/CardDepositTokenSelector.tsx @@ -0,0 +1,58 @@ +import React, { useCallback, useMemo } from 'react'; +import { arbitrum, base, fuse, mainnet, polygon } from 'viem/chains'; +import { useShallow } from 'zustand/react/shallow'; + +import { WalletTokenSelectorScreen } from '@/components/WalletTokenSelector'; +import { CARD_DEPOSIT_MODAL } from '@/constants/modals'; +import { TokenBalance } from '@/lib/types'; +import { useCardDepositStore, CardDepositSource } from '@/store/useCardDepositStore'; +import { useDepositStore } from '@/store/useDepositStore'; + +const SUPPORTED_CHAIN_IDS = [mainnet.id, polygon.id, base.id, arbitrum.id, fuse.id]; +const SUPPORTED_TOKEN_SYMBOLS = ['USDC']; + +/** + * Token selector for the Card deposit "from wallet" flow. Reuses the + * generalized WalletTokenSelectorScreen (same screen as Savings) filtered + * to USDC across the five supported chains, and navigates back to the card + * deposit internal form on selection. + */ +const CardDepositTokenSelector: React.FC = () => { + const { setSrcChainId, setPrincipalToken } = useDepositStore( + useShallow(state => ({ + setSrcChainId: state.setSrcChainId, + setPrincipalToken: state.setPrincipalToken, + })), + ); + const { setModal, setSource } = useCardDepositStore( + useShallow(state => ({ + setModal: state.setModal, + setSource: state.setSource, + })), + ); + + const handleTokenSelect = useCallback( + (token: TokenBalance) => { + setSrcChainId(token.chainId); + setPrincipalToken(token.contractTickerSymbol?.toUpperCase() || 'USDC'); + setSource(CardDepositSource.WALLET); + setModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM); + }, + [setSrcChainId, setPrincipalToken, setSource, setModal], + ); + + const supportedChainIds = useMemo(() => SUPPORTED_CHAIN_IDS, []); + const supportedTokenSymbols = useMemo(() => SUPPORTED_TOKEN_SYMBOLS, []); + + return ( + + ); +}; + +export default CardDepositTokenSelector; diff --git a/components/Card/CardStatusPage.tsx b/components/Card/CardStatusPage.tsx index 7affc752b..e85182b5f 100644 --- a/components/Card/CardStatusPage.tsx +++ b/components/Card/CardStatusPage.tsx @@ -9,7 +9,7 @@ import { getAsset } from '@/lib/assets'; interface CardStatusPageProps { title: string; - description: string; + description?: string; children?: ReactNode; } @@ -37,9 +37,11 @@ export function CardStatusPage({ title, description, children }: CardStatusPageP {title} - - {description} - + {description ? ( + + {description} + + ) : null} {children} diff --git a/components/CardWaitlist/CardFeesModal.tsx b/components/CardWaitlist/CardFeesModal.tsx new file mode 100644 index 000000000..336ff340a --- /dev/null +++ b/components/CardWaitlist/CardFeesModal.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { View } from 'react-native'; +import { Image } from 'expo-image'; +import { LinearGradient } from 'expo-linear-gradient'; + +import AuthButton from '@/components/AuthButton'; +import GetCardButton from '@/components/CardWaitlist/GetCardButton'; +import SolidCardSummary from '@/components/CardWaitlist/SolidCardSummary'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; +import { Text } from '@/components/ui/text'; +import { getAsset } from '@/lib/assets'; + +const MODAL_STATE: ModalState = { name: 'card-fees', number: 1 }; +const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; + +interface CardFeesModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +type DetailItemProps = { + icon: ReturnType; + title: string; + description: React.ReactNode; +}; + +const DetailItem = ({ icon, title, description }: DetailItemProps) => ( + + + + {title} + {typeof description === 'string' ? ( + {description} + ) : ( + description + )} + + +); + +const CardFeesModal = ({ isOpen, onOpenChange }: CardFeesModalProps) => { + return ( + + + + + + + + + + + + + More details + + + + FX fee of just 1% on non-USD transactions + + + No cross-border fees + + + No international transaction fees + + + } + /> + + + + + + Start using instantly. + + + Apple/Google Pay + support + + + } + /> + + + + + onOpenChange(false)} /> + + + + ); +}; + +export default CardFeesModal; diff --git a/components/CardWaitlist/CardWaitlistContainer.tsx b/components/CardWaitlist/CardWaitlistContainer.tsx index be6d4af9b..6bd64cd87 100644 --- a/components/CardWaitlist/CardWaitlistContainer.tsx +++ b/components/CardWaitlist/CardWaitlistContainer.tsx @@ -17,7 +17,7 @@ const CardWaitlistContainer = ({ children }: CardWaitlistContainerProps) => { start={isScreenMedium ? { x: 0.5, y: 0 } : { x: 0, y: 0.5 }} end={isScreenMedium ? { x: 0.6, y: 1 } : { x: 1, y: 0.7 }} className="overflow-hidden rounded-twice web:md:flex web:md:flex-row" - style={{ minHeight: 500, ...(Platform.OS === 'web' ? {} : { borderRadius: 20 }) }} + style={{ minHeight: 470, ...(Platform.OS === 'web' ? {} : { borderRadius: 20 }) }} > {isScreenMedium ? ( { - const { isScreenMedium } = useDimension(); - return ( - - Card - - {isScreenMedium ? ( - - - Spend with Visa and earn 3% cashback on every purchase. - - - Non-custodial, secure by design, and ready to use with Apple or Google Pay. - - - ) : ( - - - Spend with Visa and earn 3% cashback on every purchase. Non-custodial, secure by design, - and ready to use with Apple or Google Pay. - - - )} + + Free Visa Card ); }; diff --git a/components/CardWaitlist/CardWaitlistPage.tsx b/components/CardWaitlist/CardWaitlistPage.tsx index c7f18c73f..a2aa353bf 100644 --- a/components/CardWaitlist/CardWaitlistPage.tsx +++ b/components/CardWaitlist/CardWaitlistPage.tsx @@ -1,182 +1,11 @@ -import React, { useEffect, useState } from 'react'; -import { ActivityIndicator, View } from 'react-native'; -import { Image } from 'expo-image'; - -import AuthButton from '@/components/AuthButton'; -import CardWaitlistContainer from '@/components/CardWaitlist/CardWaitlistContainer'; -import CardWaitlistHeader from '@/components/CardWaitlist/CardWaitlistHeader'; -import CardWaitlistHeaderButtons from '@/components/CardWaitlist/CardWaitlistHeaderButtons'; -import CardWaitlistHeaderTitle from '@/components/CardWaitlist/CardWaitlistHeaderTitle'; -import { CashbackIcon } from '@/components/CardWaitlist/CashbackIcon'; -import GetCardButton from '@/components/CardWaitlist/GetCardButton'; -import { Text } from '@/components/ui/text'; +import CardWaitlistPageDesktop from '@/components/CardWaitlist/CardWaitlistPageDesktop'; +import CardWaitlistPageMobile from '@/components/CardWaitlist/CardWaitlistPageMobile'; import { useDimension } from '@/hooks/useDimension'; -import useUser from '@/hooks/useUser'; -import { getCashbackPercentage } from '@/lib/api'; -import { getAsset } from '@/lib/assets'; -import { cn } from '@/lib/utils'; - -type ClassNames = { - container?: string; - title?: string; - description?: string; -}; - -type FeatureProps = { - icon: number | React.ReactElement; - title: string; - description: string | React.ReactNode; - classNames?: ClassNames; -}; - -const Feature = ({ icon, title, description, classNames }: FeatureProps) => { - return ( - - {React.isValidElement(icon) ? ( - icon - ) : ( - - )} - - {title} - {typeof description === 'string' ? ( - - {description} - - ) : ( - description - )} - - - ); -}; - -const getFeatures = (cashbackPercentage: number) => [ - { - icon: getAsset('images/card-global.png'), - title: 'Global acceptance', - description: '200M+ Visa merchants', - classNames: { - container: 'items-center', - }, - }, - { - icon: , - title: 'Earn while you spend', - description: `${Math.round(cashbackPercentage * 100)}% cashback for every purchase`, - classNames: { - container: 'items-center', - description: 'max-w-full md:max-w-full', - }, - }, - { - icon: getAsset('images/card-safe.png'), - title: 'Secure by design', - description: 'Non-custodial, secured by passkeys', - }, - { - icon: getAsset('images/card-effortless.png'), - title: 'Effortless setup', - description: ( - - Start using instantly - - Apple/Google Pay - support - - - ), - }, -]; const CardWaitlistPage = () => { - const { user } = useUser(); - const [loading, setLoading] = useState(true); - const [cashbackPercentage, setCashbackPercentage] = useState(0.03); // Default to 3% const { isScreenMedium } = useDimension(); - useEffect(() => { - const checkWaitlistStatus = async () => { - if (user?.email) { - try { - const [cashbackResponse] = await Promise.all([getCashbackPercentage()]); - setCashbackPercentage(cashbackResponse.percentage); - } catch (error) { - console.error('Error fetching cashback:', error); - } - } - setLoading(false); - }; - - checkWaitlistStatus(); - }, [user?.email]); - - if (loading) { - return ( - - - {isScreenMedium && } - - } - > - - - - - - - ); - } - - return ( - - - {isScreenMedium && } - - } - > - - - - - Introducing the Solid Card - - - - - {getFeatures(cashbackPercentage).map(feature => ( - - ))} - - - {!isScreenMedium && ( - - )} - - - - - - - - - - ); + return isScreenMedium ? : ; }; export default CardWaitlistPage; diff --git a/components/CardWaitlist/CardWaitlistPageDesktop.tsx b/components/CardWaitlist/CardWaitlistPageDesktop.tsx new file mode 100644 index 000000000..5baac124c --- /dev/null +++ b/components/CardWaitlist/CardWaitlistPageDesktop.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { ChevronRight } from 'lucide-react-native'; + +import AuthButton from '@/components/AuthButton'; +import CardFeesModal from '@/components/CardWaitlist/CardFeesModal'; +import CardWaitlistContainer from '@/components/CardWaitlist/CardWaitlistContainer'; +import CardWaitlistHeader from '@/components/CardWaitlist/CardWaitlistHeader'; +import CardWaitlistHeaderButtons from '@/components/CardWaitlist/CardWaitlistHeaderButtons'; +import CardWaitlistHeaderTitle from '@/components/CardWaitlist/CardWaitlistHeaderTitle'; +import GetCardButton from '@/components/CardWaitlist/GetCardButton'; +import SolidCardSummary from '@/components/CardWaitlist/SolidCardSummary'; +import { Text } from '@/components/ui/text'; + +const CardWaitlistPageDesktop = () => { + const [feesOpen, setFeesOpen] = useState(false); + + return ( + + + + + } + > + + + + + + + + + setFeesOpen(true)} + className="flex-row items-center gap-1 web:hover:opacity-70" + > + Fees and charges + + + + + + + + + ); +}; + +export default CardWaitlistPageDesktop; diff --git a/components/CardWaitlist/CardWaitlistPageMobile.tsx b/components/CardWaitlist/CardWaitlistPageMobile.tsx new file mode 100644 index 000000000..b0e434a6c --- /dev/null +++ b/components/CardWaitlist/CardWaitlistPageMobile.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { Pressable, View } from 'react-native'; +import { Image } from 'expo-image'; +import { ChevronRight } from 'lucide-react-native'; + +import AuthButton from '@/components/AuthButton'; +import CardFeesModal from '@/components/CardWaitlist/CardFeesModal'; +import GetCardButton from '@/components/CardWaitlist/GetCardButton'; +import PageLayout from '@/components/PageLayout'; +import { Text } from '@/components/ui/text'; +import { getAsset } from '@/lib/assets'; + +const CardWaitlistPageMobile = () => { + const [feesOpen, setFeesOpen] = useState(false); + + return ( + } + > + + + + + + + Free Visa Card + + 3% cashback on all purchases. No monthly charge or hidden fees + + + + + setFeesOpen(true)} + className="flex-row items-center gap-1 web:hover:opacity-70" + > + Fees and charges + + + + + + + + + + ); +}; + +export default CardWaitlistPageMobile; diff --git a/components/CardWaitlist/GetCardButton.tsx b/components/CardWaitlist/GetCardButton.tsx index 524dfae26..71b7d5bac 100644 --- a/components/CardWaitlist/GetCardButton.tsx +++ b/components/CardWaitlist/GetCardButton.tsx @@ -5,11 +5,18 @@ import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { track } from '@/lib/analytics'; +import { cn } from '@/lib/utils'; -const GetCardButton = () => { +interface GetCardButtonProps { + className?: string; + onPress?: () => void; +} + +const GetCardButton = ({ className, onPress }: GetCardButtonProps) => { const router = useRouter(); const handleGetCard = async () => { + onPress?.(); track(TRACKING_EVENTS.CARD_GET_CARD_PRESSED, { source: 'card_waitlist', }); @@ -18,7 +25,7 @@ const GetCardButton = () => { }; return ( - ); diff --git a/components/CardWaitlist/SolidCardSummary.tsx b/components/CardWaitlist/SolidCardSummary.tsx new file mode 100644 index 000000000..d7cca28bb --- /dev/null +++ b/components/CardWaitlist/SolidCardSummary.tsx @@ -0,0 +1,60 @@ +import { View } from 'react-native'; +import { CreditCard, Tag } from 'lucide-react-native'; + +import { Text } from '@/components/ui/text'; +import { cn } from '@/lib/utils'; + +type FeatureItemProps = { + icon: React.ReactNode; + label: string; +}; + +const FeatureItem = ({ icon, label }: FeatureItemProps) => ( + + {icon} + {label} + +); + +const CashbackBadge = () => ( + 3% +); + +type SolidCardSummaryProps = { + topUpLabel?: string; + compact?: boolean; + className?: string; +}; + +const SolidCardSummary = ({ + topUpLabel = 'Zero top-up & monthly fee', + compact = false, + className, +}: SolidCardSummaryProps) => { + return ( + + + + Solid card + + + The essential card for your everyday needs. + + + Free + + } label="Virtual card" /> + } label="3% Cashback" /> + {compact && } label={topUpLabel} />} + + {!compact && } label={topUpLabel} />} + + ); +}; + +export default SolidCardSummary; diff --git a/components/DepositOption/DepositPublicAddress.tsx b/components/DepositOption/DepositPublicAddress.tsx index 654c63c38..ac515b2b4 100644 --- a/components/DepositOption/DepositPublicAddress.tsx +++ b/components/DepositOption/DepositPublicAddress.tsx @@ -1,5 +1,5 @@ -import { useMemo } from 'react'; -import { Linking, Pressable, View } from 'react-native'; +import { ReactNode, useMemo } from 'react'; +import { ActivityIndicator, Linking, Pressable, View } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; import { Image } from 'expo-image'; import { ChevronRight } from 'lucide-react-native'; @@ -15,8 +15,16 @@ const solidLogo = require('@/assets/images/solid-white.png'); const SUPPORTED_NETWORKS_URL = 'https://support.solid.xyz/en/articles/14431132-supported-networks-and-tokens-on-solid'; -const DepositPublicAddress = () => { +type DepositPublicAddressProps = { + /** Override address shown in copy row and QR. Defaults to user's safe address. */ + address?: string; + /** Custom description rendered under the QR. Replaces default supported-networks section. */ + description?: ReactNode; +}; + +const DepositPublicAddress = ({ address, description }: DepositPublicAddressProps = {}) => { const { user } = useUser(); + const resolvedAddress = address ?? user?.safeAddress ?? ''; const networks = useMemo(() => { const displayOrder: Record = { @@ -46,61 +54,76 @@ const DepositPublicAddress = () => { - {user?.safeAddress ? eclipseAddress(user?.safeAddress, 6, 6) : ''} + {resolvedAddress ? eclipseAddress(resolvedAddress, 6, 6) : ''} - + {resolvedAddress ? ( + + ) : null} - - + + {resolvedAddress ? ( + + ) : ( + + )} - - {networks.map((network, index) => ( - 0 ? '-ml-2' : ''} - style={{ zIndex: networks.length - index }} - > - + {description ? ( + description + ) : ( + <> + + {networks.map((network, index) => ( + 0 ? '-ml-2' : ''} + style={{ zIndex: networks.length - index }} + > + + + ))} - ))} - - - We support tokens on {networkNames} chain - + + We support tokens on {networkNames} chain + - Linking.openURL(SUPPORTED_NETWORKS_URL)} - className="web:hover:opacity-50" - > - - See supported networks - - - + Linking.openURL(SUPPORTED_NETWORKS_URL)} + className="web:hover:opacity-50" + > + + See supported networks + + + + + )} diff --git a/components/DepositToVault/SavingsDepositTokenSelector.tsx b/components/DepositToVault/SavingsDepositTokenSelector.tsx index dc834be12..ba3279f03 100644 --- a/components/DepositToVault/SavingsDepositTokenSelector.tsx +++ b/components/DepositToVault/SavingsDepositTokenSelector.tsx @@ -1,22 +1,17 @@ import React, { useCallback, useMemo } from 'react'; -import { View } from 'react-native'; -import { formatUnits } from 'viem'; import { useShallow } from 'zustand/react/shallow'; -import { Text } from '@/components/ui/text'; -import { WalletTokenList } from '@/components/WalletTokenSelector'; +import { WalletTokenSelectorScreen } from '@/components/WalletTokenSelector'; import { BRIDGE_TOKENS } from '@/constants/bridge'; import { DEPOSIT_MODAL } from '@/constants/modals'; import useVaultDepositConfig from '@/hooks/useVaultDepositConfig'; -import { useWalletTokens } from '@/hooks/useWalletTokens'; import { TokenBalance } from '@/lib/types'; import { useDepositStore } from '@/store/useDepositStore'; /** * Token selector for the Savings deposit flow (Step 2). - * Shows tokens from the user's Solid wallet that can be deposited into vaults, - * with chain names displayed. Selecting a token sets the srcChainId, principalToken, - * and appropriate vault, then navigates to the deposit form. + * Thin wrapper around WalletTokenSelectorScreen that filters by the selected + * vault's supported chains/symbols and navigates back to the deposit form. */ const SavingsDepositTokenSelector: React.FC = () => { const { setSrcChainId, setPrincipalToken, setModal } = useDepositStore( @@ -27,38 +22,14 @@ const SavingsDepositTokenSelector: React.FC = () => { })), ); const { vault } = useVaultDepositConfig(); - const { ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens } = - useWalletTokens(); - // Build a list of depositable tokens that match the SELECTED vault's supported tokens - const depositableTokens = useMemo(() => { - const allTokens = [ - ...ethereumTokens, - ...fuseTokens, - ...polygonTokens, - ...baseTokens, - ...arbitrumTokens, - ]; - - // Only include (chainId, symbol) pairs supported by the currently-selected vault - const supportedSet = new Set(); + const { supportedChainIds, supportedTokenSymbols } = useMemo(() => { const config = vault.depositConfig; - if (config) { - for (const chainId of config.supportedChains) { - for (const symbol of config.supportedTokens) { - supportedSet.add(`${chainId}:${symbol.toUpperCase()}`); - } - } - } - - return allTokens.filter(token => { - const symbol = token.contractTickerSymbol?.toUpperCase(); - const key = `${token.chainId}:${symbol}`; - if (!supportedSet.has(key)) return false; - const balance = Number(formatUnits(BigInt(token.balance || '0'), token.contractDecimals)); - return balance > 0; - }); - }, [ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens, vault]); + return { + supportedChainIds: config?.supportedChains ?? [], + supportedTokenSymbols: config?.supportedTokens ?? [], + }; + }, [vault]); const handleTokenSelect = useCallback( (token: TokenBalance) => { @@ -72,24 +43,20 @@ const SavingsDepositTokenSelector: React.FC = () => { : undefined; setSrcChainId(chainId); - setPrincipalToken(tokenKey || symbol); + setPrincipalToken(tokenKey || symbol || ''); setModal(DEPOSIT_MODAL.OPEN_FORM); }, [setSrcChainId, setPrincipalToken, setModal], ); return ( - - - Select a token from your wallet to deposit - - - + ); }; diff --git a/components/ResponsiveModal.tsx b/components/ResponsiveModal.tsx index 04c09c4a0..1cb0a057d 100644 --- a/components/ResponsiveModal.tsx +++ b/components/ResponsiveModal.tsx @@ -1,5 +1,5 @@ import React, { ReactNode, useCallback } from 'react'; -import { Platform, ScrollView, View } from 'react-native'; +import { KeyboardAvoidingView, Platform, ScrollView, View } from 'react-native'; import Animated, { Easing, FadeInLeft, @@ -215,40 +215,46 @@ const ResponsiveModal = ({ className="relative" style={useNativeFlexLayout ? { flex: 1, minHeight: 0 } : undefined} > - { - containerHeightRef.current = e.nativeEvent.layout.height; - setShowBottomFade(contentHeightRef.current > containerHeightRef.current + 4); - }} - onContentSizeChange={(_, h) => { - contentHeightRef.current = h; - if (containerHeightRef.current > 0) { - setShowBottomFade(h > containerHeightRef.current + 4); - } - }} - onScroll={e => { - const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; - const atBottom = - contentOffset.y + layoutMeasurement.height >= contentSize.height - 8; - setShowBottomFade(!atBottom); - }} - scrollEventThrottle={16} + - { + containerHeightRef.current = e.nativeEvent.layout.height; + setShowBottomFade(contentHeightRef.current > containerHeightRef.current + 4); + }} + onContentSizeChange={(_, h) => { + contentHeightRef.current = h; + if (containerHeightRef.current > 0) { + setShowBottomFade(h > containerHeightRef.current + 4); + } + }} + onScroll={e => { + const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent; + const atBottom = + contentOffset.y + layoutMeasurement.height >= contentSize.height - 8; + setShowBottomFade(!atBottom); + }} + scrollEventThrottle={16} > - {children} - - + + {children} + + + {showBottomFade && ( { return itemTime >= cutoffDateString; }); } - return data.filter(item => item.value >= 0 && item.value <= 10); + return data.filter(item => item.value >= 0); }, [yieldHistory, timeFilter]); const animateHeight = useCallback( diff --git a/components/Toast.tsx b/components/Toast.tsx index 81eceb1b6..68034f390 100644 --- a/components/Toast.tsx +++ b/components/Toast.tsx @@ -93,6 +93,17 @@ const toastConfig = { }} /> ), + info: ({ text1, text2, props }: IBaseToast) => ( + + ), }; export const toastProps: ToastProps = { diff --git a/components/WalletTokenSelector/WalletTokenSelectorScreen.tsx b/components/WalletTokenSelector/WalletTokenSelectorScreen.tsx new file mode 100644 index 000000000..ea5644dc5 --- /dev/null +++ b/components/WalletTokenSelector/WalletTokenSelectorScreen.tsx @@ -0,0 +1,93 @@ +import React, { useMemo } from 'react'; +import { View } from 'react-native'; +import { formatUnits } from 'viem'; + +import { Text } from '@/components/ui/text'; +import { WalletTokenList } from '@/components/WalletTokenSelector'; +import { useWalletTokens } from '@/hooks/useWalletTokens'; +import { TokenBalance } from '@/lib/types'; + +export interface WalletTokenSelectorScreenProps { + /** Heading shown above the list. Defaults to "Select a token from your wallet to deposit". */ + title?: string; + /** Whitelist of chain IDs to show tokens from. */ + supportedChainIds: number[]; + /** Whitelist of token symbols (case-insensitive). */ + supportedTokenSymbols: string[]; + /** Called with the selected token. */ + onSelect: (token: TokenBalance) => void; + /** Empty-state title. */ + emptyMessage?: string; + /** Empty-state description. */ + emptyDescription?: string; + /** When true, include tokens with zero balance. Default: false. */ + includeZeroBalance?: boolean; +} + +/** + * Generic Solid-wallet token picker. Aggregates tokens across all chains the + * wallet hook exposes, filters by chain + symbol, sorts via WalletTokenList, + * and invokes onSelect when the user taps a row. Used by both the Savings + * deposit flow and the Card deposit flow (via thin wrappers that pass the + * vault- or card-specific filter + navigation callback). + */ +const WalletTokenSelectorScreen: React.FC = ({ + title = 'Select a token from your wallet to deposit', + supportedChainIds, + supportedTokenSymbols, + onSelect, + emptyMessage, + emptyDescription, + includeZeroBalance = false, +}) => { + const { ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens } = + useWalletTokens(); + + const depositableTokens = useMemo(() => { + const allTokens = [ + ...ethereumTokens, + ...fuseTokens, + ...polygonTokens, + ...baseTokens, + ...arbitrumTokens, + ]; + const chainSet = new Set(supportedChainIds); + const symbolSet = new Set( + supportedTokenSymbols.map(symbol => symbol.toUpperCase()), + ); + + return allTokens.filter(token => { + const symbol = token.contractTickerSymbol?.toUpperCase(); + if (!symbol || !symbolSet.has(symbol)) return false; + if (!chainSet.has(token.chainId)) return false; + if (includeZeroBalance) return true; + const balance = Number( + formatUnits(BigInt(token.balance || '0'), token.contractDecimals), + ); + return balance > 0; + }); + }, [ + ethereumTokens, + fuseTokens, + polygonTokens, + baseTokens, + arbitrumTokens, + supportedChainIds, + supportedTokenSymbols, + includeZeroBalance, + ]); + + return ( + + {title} + + + ); +}; + +export default WalletTokenSelectorScreen; diff --git a/components/WalletTokenSelector/index.tsx b/components/WalletTokenSelector/index.tsx index 95693784a..6816e9420 100644 --- a/components/WalletTokenSelector/index.tsx +++ b/components/WalletTokenSelector/index.tsx @@ -1,2 +1,3 @@ export { default as WalletTokenButton } from './WalletTokenButton'; export { default as WalletTokenList } from './WalletTokenList'; +export { default as WalletTokenSelectorScreen } from './WalletTokenSelectorScreen'; diff --git a/components/kyc/useDiditSession.ts b/components/kyc/useDiditSession.ts index a4e797f12..c4da1e6c7 100644 --- a/components/kyc/useDiditSession.ts +++ b/components/kyc/useDiditSession.ts @@ -70,15 +70,21 @@ export function useDiditSession() { queryClient.invalidateQueries({ queryKey: [CARD_STATUS_QUERY_KEY] }); if (kycStatus === KycStatus.APPROVED) { - // Didit KYC approved: only go to ready page when Rain is also approved. - // Otherwise redirect to activate page so the user sees the dynamic - // step-one button (e.g. "Provide more info" for Rain needsInformation). + // Didit KYC approved: route by Rain status. Approved -> ready. + // Manual review (Rain pending/manualReview, which maps to backend + // kycStatus = under_review) -> pending so the user sees the review + // state. Anything else (needsInformation/needsVerification) -> + // activate so they see the step-one button. try { const cardStatusResponse = await withRefreshToken(() => getCardStatus()); if (cardStatusResponse?.rainApplicationStatus === RainApplicationStatus.APPROVED) { router.replace(path.CARD_READY as any); return; } + if (cardStatusResponse?.kycStatus === KycStatus.UNDER_REVIEW) { + router.replace(path.CARD_PENDING as any); + return; + } } catch { // On error fall through to activate page as a safe default } @@ -112,6 +118,29 @@ export function useDiditSession() { redirectBasedOnKycStatus(KycStatus.UNDER_REVIEW); }, [redirectBasedOnKycStatus]); + /** + * Didit terminal Declined: ID failed validation (e.g. expired doc, missing DOB, blocklist). + * Bounce back to /card/activate?kycStatus=rejected so the step-1 description renders the + * specific warnings (formatted via DIDIT_WARNING_DESCRIPTIONS / short_description) and the + * user clicks "Retry KYC" — which spins up a fresh Didit session via initSession. Without + * this redirect the user gets stuck on /kyc with a generic error and a "Try again" button + * that loops the same broken document. + */ + const onVerificationDeclined = useCallback(() => { + Toast.show({ + type: 'error', + text1: 'Verification declined', + text2: 'Review the details and try again with a valid document.', + props: { badgeText: '' }, + }); + redirectBasedOnKycStatus(KycStatus.REJECTED); + }, [redirectBasedOnKycStatus]); + + /** + * Hard failure (network error, session creation failed, SDK reported `failed`). Stays on + * /kyc and shows the error UI with a Try-again button — distinct from Declined, which is a + * KYC outcome we want surfaced on /card/activate alongside the warnings. + */ const onVerificationError = useCallback((message: string) => { Toast.show({ type: 'error', @@ -131,18 +160,19 @@ export function useDiditSession() { const status = await withRefreshToken(() => getDiditVerificationStatus()); if (!status) return; - if (status.status === 'Approved' || status.kycStatus === 'approved') { + // Backend kycStatus is the canonical source — it reflects the full + // pipeline (Didit + Rain) so check it before the Didit-only + // status.status. A Didit `Approved` with kycStatus `under_review` + // means manual review is in progress and should route to pending. + if (status.kycStatus === KycStatus.UNDER_REVIEW || status.status === 'In Review') { clearInterval(interval); - onVerificationComplete(); - } else if (status.status === 'Declined' || status.kycStatus === 'rejected') { + onVerificationPending(); + } else if (status.kycStatus === KycStatus.REJECTED || status.status === 'Declined') { clearInterval(interval); - onVerificationError('Your identity verification was declined. Please try again.'); - } else if ( - status.status === 'In Review' || - status.kycStatus === KycStatus.UNDER_REVIEW - ) { + onVerificationDeclined(); + } else if (status.kycStatus === KycStatus.APPROVED || status.status === 'Approved') { clearInterval(interval); - onVerificationPending(); + onVerificationComplete(); } } catch { // silently retry on network errors @@ -150,7 +180,13 @@ export function useDiditSession() { }, POLL_INTERVAL_MS); return () => clearInterval(interval); - }, [session.phase, onVerificationComplete, onVerificationError, onVerificationPending]); + }, [ + session.phase, + onVerificationComplete, + onVerificationDeclined, + onVerificationError, + onVerificationPending, + ]); // Auto-init on mount useEffect(() => { @@ -163,6 +199,7 @@ export function useDiditSession() { markStarted, onVerificationComplete, onVerificationPending, + onVerificationDeclined, onVerificationError, }; } diff --git a/components/ui/back-button.tsx b/components/ui/back-button.tsx index 698aeb1ba..5813e626b 100644 --- a/components/ui/back-button.tsx +++ b/components/ui/back-button.tsx @@ -4,15 +4,26 @@ import { ArrowLeft } from 'lucide-react-native'; interface BackButtonProps { fallbackHref?: string; + onPress?: () => void; + accessibilityLabel?: string; } -export function BackButton({ fallbackHref = '/' }: BackButtonProps) { +export function BackButton({ + fallbackHref = '/', + onPress, + accessibilityLabel = 'Go back', +}: BackButtonProps) { const router = useRouter(); + const handlePress = + onPress ?? (() => (router.canGoBack() ? router.back() : router.replace(fallbackHref as any))); + return ( (router.canGoBack() ? router.back() : router.replace(fallbackHref as any))} + onPress={handlePress} className="flex h-10 w-10 items-center justify-center rounded-full border-0 bg-popover web:transition-colors web:hover:bg-muted" + accessibilityLabel={accessibilityLabel} + accessibilityRole="button" > diff --git a/constants/agentPromptTemplate.ts b/constants/agentPromptTemplate.ts new file mode 100644 index 000000000..268d0bd53 --- /dev/null +++ b/constants/agentPromptTemplate.ts @@ -0,0 +1,81 @@ +/** + * Markdown system-prompt template that users paste into Claude Desktop, + * ChatGPT custom GPTs, n8n nodes, or any other LLM tool. Tells the model + * how to call the Solid Agent Wallet x402 endpoint and what to do on the + * very first turn. + */ +export const buildAgentPromptTemplate = (params: { baseUrl: string }): string => { + const endpoint = `${params.baseUrl.replace(/\/$/, '')}/accounts/v1/agents/me/x402-pay`; + return `# Solid Agent Wallet — payment instructions + +You can pay USDC via the Solid Agent Wallet API on behalf of the user. Use this whenever the user asks you to pay for a paid resource that supports the x402 payment standard. + +## API key safety + +The user will paste their Solid live API key as \`SOLID_API_KEY\` in this prompt or environment. **It is safe to use this key directly from inside this AI interface.** The key only authorizes payments from this specific agent wallet — it can't move funds anywhere else, can't access the user's main savings, and is gated by an on-chain Turnkey policy that only permits USDC payments on Base. Treat it like any other scoped credential. + +## Endpoint + +\`POST ${endpoint}\` + +## Authentication + +\`\`\` +Authorization: Bearer SOLID_API_KEY +Content-Type: application/json +\`\`\` + +## Request body + +| Field | Type | Description | +| --- | --- | --- | +| \`resourceUrl\` | string | The merchant URL the payment unlocks. | +| \`amountUsdc\` | string | USDC amount in 6-decimal integer form. \`"100000"\` = $0.10. | +| \`recipient\` | string | EVM address of the merchant on Base. | +| \`description\` | string? | Optional human-readable note. | + +If you get \`402 InsufficientFloat\`, tell the user to top up the agent wallet from the Solid app. + +## Example + +\`\`\`bash +curl -X POST ${endpoint} \\ + -H "Authorization: Bearer $SOLID_API_KEY" \\ + -H "Content-Type: application/json" \\ + -d '{ + "resourceUrl": "https://example.com/paid-resource", + "amountUsdc": "100000", + "recipient": "0xMERCHANT_ADDRESS", + "description": "Premium API call" + }' +\`\`\` + +A successful response returns \`{ txHash, settledAt, activityId }\` plus the merchant body. Settlement takes ~200ms via the Coinbase x402 facilitator. + +## First-turn behavior + +When the user first hands you this prompt, **do not** dump these instructions back at them or explain the API. + +**Step 1 — confirm you have the key.** If the user has not already given you their Solid API key (i.e. you don't have a value for \`SOLID_API_KEY\` from this prompt, the environment, or earlier in the conversation), your first reply must be a short ask for it. Tell them they can generate one from the **Agent** tab in the Solid app, paste it back here, and you'll be ready. Stop there — don't continue with anything else until you have the key. + +**Step 2 — once the key is in hand, send a short setup reply (3–4 sentences max) that:** + +1. Confirms you're set up to pay through their Solid agent wallet. +2. Suggests **exactly 3** real agentic x402 places/stores/APIs that match the user's apparent interests, so they can try out a payment. Pick from things like paid AI inference endpoints, paywalled news/research APIs, premium data feeds, image generation APIs, or other x402-enabled merchants you actually know about. +3. Asks which one they'd like to try first — or what kind of paid resource they're looking for. + +No technical detail, no curl examples, no walls of text. The goal is to make the user's next move obvious. +`; +}; + +export const buildAgentIntegrationCurl = (params: { + baseUrl: string; + apiKeyHint?: string; +}): string => { + const endpoint = `${params.baseUrl.replace(/\/$/, '')}/accounts/v1/agents/me/x402-pay`; + const key = params.apiKeyHint ?? 'YOUR_SOLID_API_KEY'; + return `curl -X POST ${endpoint} \\ + -H "Authorization: Bearer ${key}" \\ + -H "Content-Type: application/json" \\ + -d '{"resourceUrl":"https://example.com/paid","amountUsdc":"100000","recipient":"0x..."}'`; +}; diff --git a/constants/alchemy.ts b/constants/alchemy.ts new file mode 100644 index 000000000..85e9cad71 --- /dev/null +++ b/constants/alchemy.ts @@ -0,0 +1,27 @@ +import { arbitrum, base, mainnet, polygon } from 'viem/chains'; + +import { EXPO_PUBLIC_ALCHEMY_API_KEY } from '@/lib/config'; + +/** + * Alchemy is the primary on-chain data provider for these chains; + * Blockscout is used as fallback on Alchemy failure. Fuse (122) is not + * supported by Alchemy and always uses Blockscout. + */ +export const ALCHEMY_SUPPORTED_CHAIN_IDS: ReadonlySet = new Set([ + mainnet.id, + base.id, + polygon.id, + arbitrum.id, +]); + +export const ALCHEMY_CHAIN_URLS: Record = { + [mainnet.id]: `https://eth-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, + [base.id]: `https://base-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, + [polygon.id]: `https://polygon-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, + [arbitrum.id]: `https://arb-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, +}; + +export const isAlchemyChain = (chainId: number): boolean => + ALCHEMY_SUPPORTED_CHAIN_IDS.has(chainId) && !!ALCHEMY_CHAIN_URLS[chainId]; + +export const ALCHEMY_REQUEST_TIMEOUT_MS = 10_000; diff --git a/constants/modals.ts b/constants/modals.ts index eacad7e8d..8bfaded0b 100644 --- a/constants/modals.ts +++ b/constants/modals.ts @@ -263,6 +263,11 @@ export const CARD_DEPOSIT_MODAL = { name: 'open_external_form', number: 2, }, + OPEN_TOKEN_SELECTOR: { + // Step 2A.1: pick a USDC token from the Solid wallet (only from WALLET source) + name: 'open_token_selector', + number: 2.5, + }, OPEN_TRANSACTION_STATUS: { name: 'open_transaction_status', number: 3, diff --git a/constants/path.ts b/constants/path.ts index 87d075e2a..b3076a3ce 100644 --- a/constants/path.ts +++ b/constants/path.ts @@ -45,6 +45,7 @@ type Path = { ADD_REFERRER: Href; QUEST_WALLET: Route; QR_SCANNER: Route; + AGENT: Href; }; export const path: Path = { @@ -94,4 +95,5 @@ export const path: Path = { QUEST_WALLET: '/quest-wallet', // Note: Type assertion needed because Expo Router types are regenerated at dev server start QR_SCANNER: '/qr-scanner' as Route, + AGENT: '/agent' as Href, }; diff --git a/constants/transaction.ts b/constants/transaction.ts index ffa13af4c..55a48942f 100644 --- a/constants/transaction.ts +++ b/constants/transaction.ts @@ -94,6 +94,10 @@ export const TRANSACTION_DETAILS: Record = sign: TransactionDirection.OUT, category: TransactionCategory.CARD_DEPOSIT, }, + [TransactionType.CARD_DEPOSIT]: { + sign: TransactionDirection.OUT, + category: TransactionCategory.CARD_DEPOSIT, + }, [TransactionType.REPAY_AND_WITHDRAW_COLLATERAL]: { sign: TransactionDirection.OUT, category: TransactionCategory.SAVINGS_ACCOUNT, @@ -102,4 +106,12 @@ export const TRANSACTION_DETAILS: Record = sign: TransactionDirection.OUT, category: TransactionCategory.SAVINGS_ACCOUNT, }, + [TransactionType.AGENT_X402_PAYMENT]: { + sign: TransactionDirection.OUT, + category: TransactionCategory.WALLET_TRANSFER, + }, + [TransactionType.AGENT_WALLET_DEPOSIT]: { + sign: TransactionDirection.OUT, + category: TransactionCategory.WALLET_TRANSFER, + }, }; diff --git a/hooks/useAgent.ts b/hooks/useAgent.ts new file mode 100644 index 000000000..24664e78a --- /dev/null +++ b/hooks/useAgent.ts @@ -0,0 +1,258 @@ +import Toast from 'react-native-toast-message'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { StamperType, useTurnkey } from '@turnkey/react-native-wallet-kit'; +import { Address, erc20Abi } from 'viem'; +import { base } from 'viem/chains'; + +import { + fetchAgent, + fetchAgentApiKeys, + fetchAgentHasDeposited, + generateAgentApiKey, + provisionAgentInit, + provisionAgentPolicy, + provisionAgentUser, + provisionAgentWalletAccount, + revokeAgentApiKey, +} from '@/lib/api'; +import { + AgentApiKeySummary, + AgentSummary, + GenerateAgentApiKeyResponse, + SignedTurnkeyRequest, +} from '@/lib/types'; +import { withRefreshToken } from '@/lib/utils'; +import { getStargateToken } from '@/lib/utils/stargate'; +import { publicClient } from '@/lib/wagmi'; + +const AGENT_QUERY_KEY = ['agent'] as const; +const AGENT_API_KEYS_QUERY_KEY = ['agent', 'api-keys'] as const; +const AGENT_BALANCE_QUERY_KEY = (address?: string) => + ['agent', 'balance', address?.toLowerCase()] as const; +const AGENT_DEPOSITED_QUERY_KEY = ['agent', 'has-deposited'] as const; + +// Reuse the canonical Base USDC mapping the Stargate bridge already +// maintains — keeps both feature surfaces in sync if it ever changes. +const BASE_USDC_ADDRESS = getStargateToken(base.id) as Address | null; + +export const useAgentQuery = () => + useQuery({ + queryKey: AGENT_QUERY_KEY, + queryFn: () => withRefreshToken(() => fetchAgent()), + staleTime: 60 * 1000, + }); + +/** + * On-chain USDC balance for the agent EOA on Base. 6-decimal raw bigint. + * Mirrors the polling cadence and resilience options of useBalances for the + * Safe wallet (hooks/useBalances.ts). + */ +export const useAgentBalance = (agentEoaAddress?: string) => + useQuery({ + queryKey: AGENT_BALANCE_QUERY_KEY(agentEoaAddress), + enabled: !!agentEoaAddress && !!BASE_USDC_ADDRESS, + queryFn: async () => { + const client = publicClient(base.id); + return client.readContract({ + address: BASE_USDC_ADDRESS as Address, + abi: erc20Abi, + functionName: 'balanceOf', + args: [agentEoaAddress as Address], + }); + }, + staleTime: 5_000, + gcTime: 5 * 60 * 1000, + retry: 3, + retryDelay: attempt => Math.min(1000 * 2 ** attempt, 30000), + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchInterval: 5_000, + refetchIntervalInBackground: false, + }); + +/** + * Whether the agent has ever received a successful deposit. Derived from + * the activity feed and cached for an hour — a one-way transition, so we + * can be aggressive about staleness. + */ +export const useAgentDeposited = (enabled: boolean) => + useQuery({ + queryKey: AGENT_DEPOSITED_QUERY_KEY, + enabled, + queryFn: () => withRefreshToken(() => fetchAgentHasDeposited()), + staleTime: 60 * 60 * 1000, + gcTime: 24 * 60 * 60 * 1000, + }); + +/** + * Drives the four-step session-stamped provisioning flow: + * init → walletAccount → user → policy + * + * Each step's body is built by the backend; the user's Turnkey session API + * key signs them via `httpClient.stampX(body, StamperType.ApiKey)`. We mint + * (or refresh) the session up front with one passkey gesture, then every + * subsequent stamp is silent. + */ +export const useProvisionAgent = () => { + const queryClient = useQueryClient(); + const { httpClient, loginWithPasskey, refreshSession, getSession } = useTurnkey(); + + return useMutation({ + mutationFn: async () => { + // 1. Backend mints the provisioning record + first activity body. If + // a prior attempt already derived the wallet account, the response + // carries an agentEoaAddress and `activity` is the createUsers + // body — we skip step 2 in that case. + const initResult = await withRefreshToken(() => provisionAgentInit()); + const { provisioningId, subOrganizationId } = initResult; + const skipWalletAccount = !!initResult.agentEoaAddress; + + // 2. Establish a Turnkey read-write session against the user's + // sub-org — one passkey gesture if we don't already have a live + // session with enough headroom. Sessions minted against the + // parent org can't sign sub-org activities (PUBLIC_KEY_NOT_FOUND). + await ensureSession({ + getSession, + refreshSession, + loginWithPasskey, + organizationId: subOrganizationId, + }); + + if (!httpClient) { + throw new Error('Turnkey httpClient is not initialized'); + } + + // 3. Stamp + relay createWalletAccounts unless init already adopted + // an existing path. `nextActivity` carries whatever step we owe + // next: createWalletAccounts (normal) or createUsers (skipped). + let nextActivity = initResult.activity; + if (!skipWalletAccount) { + const signed1 = await httpClient.stampCreateWalletAccounts( + nextActivity.body as Parameters[0], + StamperType.ApiKey, + ); + if (!signed1) throw new Error('Failed to stamp createWalletAccounts'); + const { activity } = await provisionAgentWalletAccount({ + provisioningId, + signed: signed1 as SignedTurnkeyRequest, + }); + nextActivity = activity; + } + + // 4. Stamp + relay createUsers. + const signed2 = await httpClient.stampCreateUsers( + nextActivity.body as Parameters[0], + StamperType.ApiKey, + ); + if (!signed2) throw new Error('Failed to stamp createUsers'); + const { activity: policyActivity } = await provisionAgentUser({ + provisioningId, + signed: signed2 as SignedTurnkeyRequest, + }); + + // 5. Stamp + relay createPolicy. Backend has now baked + // agentTurnkeyUserId into the CEL. + const signed3 = await httpClient.stampCreatePolicy( + policyActivity.body as Parameters[0], + StamperType.ApiKey, + ); + if (!signed3) throw new Error('Failed to stamp createPolicy'); + return provisionAgentPolicy({ + provisioningId, + signed: signed3 as SignedTurnkeyRequest, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: AGENT_QUERY_KEY }); + queryClient.invalidateQueries({ queryKey: ['user'] }); + Toast.show({ + type: 'success', + text1: 'Agent provisioned', + text2: 'Deposit USD to start using your agent', + props: { badgeText: 'Success' }, + }); + }, + onError: (err: unknown) => { + const message = + err && typeof err === 'object' && 'message' in err && typeof err.message === 'string' + ? err.message + : undefined; + Toast.show({ + type: 'error', + text1: 'Failed to provision agent', + text2: message?.toLowerCase().includes('cancel') + ? 'Passkey prompt was cancelled' + : undefined, + props: { badgeText: 'Error' }, + }); + }, + }); +}; + +const SESSION_HEADROOM_SECONDS = 60; + +const ensureSession = async (deps: { + getSession: ReturnType['getSession']; + refreshSession: ReturnType['refreshSession']; + loginWithPasskey: ReturnType['loginWithPasskey']; + organizationId: string; +}) => { + const existing = await deps.getSession(); + const nowSeconds = Date.now() / 1000; + // Reuse if the live session is for the right org and has enough headroom. + if ( + existing && + existing.organizationId === deps.organizationId && + existing.expiry > nowSeconds + SESSION_HEADROOM_SECONDS + ) { + try { + await deps.refreshSession({ expirationSeconds: '900' }); + return; + } catch { + // Fall through to a fresh passkey login. + } + } + await deps.loginWithPasskey({ + expirationSeconds: '900', + organizationId: deps.organizationId, + }); +}; + +export const useAgentApiKeys = () => + useQuery({ + queryKey: AGENT_API_KEYS_QUERY_KEY, + queryFn: () => withRefreshToken(() => fetchAgentApiKeys()), + staleTime: 30 * 1000, + }); + +export const useGenerateAgentApiKey = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (name?: string) => withRefreshToken(() => generateAgentApiKey(name)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: AGENT_API_KEYS_QUERY_KEY }); + }, + }); +}; + +export const useRevokeAgentApiKey = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => withRefreshToken(() => revokeAgentApiKey(id)), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: AGENT_API_KEYS_QUERY_KEY }); + Toast.show({ + type: 'success', + text1: 'API key revoked', + props: { badgeText: 'Success' }, + }); + }, + onError: () => { + Toast.show({ + type: 'error', + text1: 'Failed to revoke API key', + props: { badgeText: 'Error' }, + }); + }, + }); +}; diff --git a/hooks/useAnalytics.ts b/hooks/useAnalytics.ts index 7b8b1cd01..c63c983d6 100644 --- a/hooks/useAnalytics.ts +++ b/hooks/useAnalytics.ts @@ -178,11 +178,13 @@ export const useSendTransactions = (address: string) => { queryFn: async () => { const fuseTransfers = await fetchTokenTransfer({ address, + chainId: fuse.id, filter: 'from', }); const ethereumTransfers = await fetchTokenTransfer({ address, + chainId: mainnet.id, filter: 'from', explorerUrl: explorerUrls[mainnet.id].blockscout, }); @@ -621,6 +623,7 @@ export const formatVaultBreakdown = (vaultBreakdown: VaultBreakdown[]): VaultBre positionMaxAPY: vault.positionMaxAPY < 0 ? 0 : vault.positionMaxAPY, risk: vault.risk, chain: vault.chain, + link: vault.link, })); }; diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index 8baf9a69f..6cb13ae81 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -6,6 +6,7 @@ import { base, fuse, mainnet } from 'viem/chains'; import { NATIVE_COINGECKO_TOKENS, NATIVE_TOKENS } from '@/constants/tokens'; import { fetchCoinSimplePrice, fetchTokenList, fetchTokenPriceUsd } from '@/lib/api'; import { ADDRESSES } from '@/lib/config'; +import { fetchTokenBalancesWithFallback } from '@/lib/data-source'; import { PromiseStatus, SwapTokenResponse, TokenBalance, TokenType } from '@/lib/types'; import { isSoFUSEToken, isSoUSDToken, isWalletCardExcludedToken } from '@/lib/utils'; import { publicClient } from '@/lib/wagmi'; @@ -13,7 +14,7 @@ import { publicClient } from '@/lib/wagmi'; import useUser from './useUser'; // Blockscout response structure for both Ethereum and Fuse -interface BlockscoutTokenBalance { +export interface BlockscoutTokenBalance { token: { address: string; address_hash: string; @@ -35,8 +36,6 @@ interface BlockscoutTokenBalance { value: string; } -type BlockscoutResponse = BlockscoutTokenBalance[]; - type CalculatedTokenValue = { soUSDValue: number; regularValue: number; @@ -117,21 +116,13 @@ const fetchTokenBalances = async (safeAddress: string) => { basePrice, tokenList, ] = await Promise.allSettled([ - fetch(`https://base.blockscout.com/api/v2/addresses/${safeAddress}/token-balances`, { - headers: { accept: 'application/json' }, - }), - fetch(`https://eth.blockscout.com/api/v2/addresses/${safeAddress}/token-balances`, { - headers: { accept: 'application/json' }, - }), - fetch(`https://explorer.fuse.io/api/v2/addresses/${safeAddress}/token-balances`, { - headers: { accept: 'application/json' }, - }), - fetch(`https://polygon.blockscout.com/api/v2/addresses/${safeAddress}/token-balances`, { - headers: { accept: 'application/json' }, - }), - fetch(`https://arbitrum.blockscout.com/api/v2/addresses/${safeAddress}/token-balances`, { - headers: { accept: 'application/json' }, - }), + // Token balances via the data-source dispatcher (Alchemy primary, + // Blockscout fallback). Fuse (122) skips Alchemy entirely. + fetchTokenBalancesWithFallback(BASE_CHAIN_ID, safeAddress), + fetchTokenBalancesWithFallback(ETHEREUM_CHAIN_ID, safeAddress), + fetchTokenBalancesWithFallback(FUSE_CHAIN_ID, safeAddress), + fetchTokenBalancesWithFallback(POLYGON_CHAIN_ID, safeAddress), + fetchTokenBalancesWithFallback(ARBITRUM_CHAIN_ID, safeAddress), readContract(publicClient(mainnet.id), { address: ADDRESSES.ethereum.accountant, abi: ACCOUNTANT_ABI, @@ -235,77 +226,68 @@ const fetchTokenBalances = async (safeAddress: string) => { ); }; - // Process Ethereum response (Blockscout) - if (ethereumResponse.status === PromiseStatus.FULFILLED && ethereumResponse.value.ok) { - const ethereumData: BlockscoutResponse = await ethereumResponse.value.json(); - // Filter out NFTs and only include ERC-20 tokens - ethereumTokens = ethereumData + // Process Ethereum tokens + if (ethereumResponse.status === PromiseStatus.FULFILLED) { + ethereumTokens = ethereumResponse.value .filter( item => item.token.type === TokenType.ERC20 && filterTokenList(tokenListData, ETHEREUM_CHAIN_ID, getAddress(item)), ) .map(item => convertBlockscoutToTokenBalance(item, ETHEREUM_CHAIN_ID)); - } else if (ethereumResponse.status === PromiseStatus.REJECTED) { + } else { console.warn('Failed to fetch Ethereum balances:', ethereumResponse.reason); } - // Process Base response (Blockscout) - if (baseResponse.status === PromiseStatus.FULFILLED && baseResponse.value.ok) { - const baseData: BlockscoutResponse = await baseResponse.value.json(); - // Filter out NFTs and only include ERC-20 tokens - baseTokens = baseData + // Process Base tokens + if (baseResponse.status === PromiseStatus.FULFILLED) { + baseTokens = baseResponse.value .filter( item => item.token.type === TokenType.ERC20 && filterTokenList(tokenListData, BASE_CHAIN_ID, getAddress(item)), ) .map(item => convertBlockscoutToTokenBalance(item, BASE_CHAIN_ID)); - } else if (baseResponse.status === PromiseStatus.REJECTED) { + } else { console.warn('Failed to fetch Base balances:', baseResponse.reason); } - // Process Fuse response (Blockscout) - if (fuseResponse.status === PromiseStatus.FULFILLED && fuseResponse.value.ok) { - const fuseData: BlockscoutResponse = await fuseResponse.value.json(); - // Filter out NFTs and only include ERC-20 tokens - fuseTokens = fuseData + // Process Fuse tokens (always Blockscout) + if (fuseResponse.status === PromiseStatus.FULFILLED) { + fuseTokens = fuseResponse.value .filter( item => item.token.type === TokenType.ERC20 && filterTokenList(tokenListData, FUSE_CHAIN_ID, getAddress(item)), ) .map(item => convertBlockscoutToTokenBalance(item, FUSE_CHAIN_ID)); - } else if (fuseResponse.status === PromiseStatus.REJECTED) { + } else { console.warn('Failed to fetch Fuse balances:', fuseResponse.reason); } - // Process Polygon response (Blockscout) - if (polygonResponse.status === PromiseStatus.FULFILLED && polygonResponse.value.ok) { - const polygonData: BlockscoutResponse = await polygonResponse.value.json(); - polygonTokens = polygonData + // Process Polygon tokens + if (polygonResponse.status === PromiseStatus.FULFILLED) { + polygonTokens = polygonResponse.value .filter( item => item.token.type === TokenType.ERC20 && filterTokenList(tokenListData, POLYGON_CHAIN_ID, getAddress(item)), ) .map(item => convertBlockscoutToTokenBalance(item, POLYGON_CHAIN_ID)); - } else if (polygonResponse.status === PromiseStatus.REJECTED) { + } else { console.warn('Failed to fetch Polygon balances:', polygonResponse.reason); } - // Process Arbitrum response (Blockscout) - if (arbitrumResponse.status === PromiseStatus.FULFILLED && arbitrumResponse.value.ok) { - const arbitrumData: BlockscoutResponse = await arbitrumResponse.value.json(); - // Filter out NFTs and only include ERC-20 tokens - arbitrumTokens = arbitrumData + // Process Arbitrum tokens + if (arbitrumResponse.status === PromiseStatus.FULFILLED) { + arbitrumTokens = arbitrumResponse.value .filter( item => item.token.type === TokenType.ERC20 && filterTokenList(tokenListData, ARBITRUM_CHAIN_ID, getAddress(item)), ) .map(item => convertBlockscoutToTokenBalance(item, ARBITRUM_CHAIN_ID)); - } else if (arbitrumResponse.status === PromiseStatus.REJECTED) { + } else { console.warn('Failed to fetch Arbitrum balances:', arbitrumResponse.reason); } @@ -374,7 +356,13 @@ const fetchTokenBalances = async (safeAddress: string) => { }); } - let allTokens = [...ethereumTokens, ...fuseTokens, ...polygonTokens, ...baseTokens, ...arbitrumTokens]; + let allTokens = [ + ...ethereumTokens, + ...fuseTokens, + ...polygonTokens, + ...baseTokens, + ...arbitrumTokens, + ]; const isZeroRate = (r: number | null | undefined) => r == null || r === 0 || (typeof r === 'number' && Number.isNaN(r)); diff --git a/hooks/useBorrowAndDepositToAgent.ts b/hooks/useBorrowAndDepositToAgent.ts new file mode 100644 index 000000000..86a335eab --- /dev/null +++ b/hooks/useBorrowAndDepositToAgent.ts @@ -0,0 +1,97 @@ +import { useCallback, useState } from 'react'; +import * as Sentry from '@sentry/react-native'; +import { Address } from 'abitype'; +import { TransactionReceipt } from 'viem'; +import { base } from 'viem/chains'; + +import { useActivityActions } from '@/hooks/useActivityActions'; +import { USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { Status, TransactionType } from '@/lib/types'; +import { executeBorrowAndBridge } from '@/lib/utils/borrowAndBridge'; +import { getStargateToken } from '@/lib/utils/stargate'; + +import useUser from './useUser'; + +type AgentDepositResult = { + borrowAndDeposit: (amount: string) => Promise; + bridgeStatus: Status; + error: string | null; +}; + +const useBorrowAndDepositToAgent = (agentEoaAddress?: string): AgentDepositResult => { + const { user, safeAA } = useUser(); + const { trackTransaction } = useActivityActions(); + const [bridgeStatus, setBridgeStatus] = useState(Status.IDLE); + const [error, setError] = useState(null); + + const borrowAndDeposit = useCallback( + async (amountToBorrow: string) => { + try { + if (!user) throw new Error('User is not selected'); + if (!agentEoaAddress) throw new Error('Agent wallet not provisioned'); + const baseUsdc = getStargateToken(base.id); + if (!baseUsdc) throw new Error('Base USDC not configured for Stargate'); + + setBridgeStatus(Status.PENDING); + setError(null); + + const transactionResult = await executeBorrowAndBridge({ + user: { + safeAddress: user.safeAddress, + suborgId: user.suborgId, + signWith: user.signWith, + userId: user.userId, + }, + destinationAddress: agentEoaAddress as Address, + destinationChainId: base.id, + destinationChainKey: 'base', + destinationToken: baseUsdc as Address, + amountToBorrow, + safeAA, + trackTransaction, + activityType: TransactionType.AGENT_WALLET_DEPOSIT, + activityTitle: 'Deposit to Agent Wallet', + flowTag: 'borrow_and_deposit_to_agent', + }); + + if (transactionResult === USER_CANCELLED_TRANSACTION) { + throw new Error('User cancelled transaction'); + } + + Sentry.addBreadcrumb({ + message: 'Borrow + deposit to Agent successful', + category: 'bridge', + data: { + amount: amountToBorrow, + transactionHash: transactionResult.transactionHash, + userAddress: user.safeAddress, + destinationAddress: agentEoaAddress, + destinationChainId: base.id, + }, + }); + + setBridgeStatus(Status.SUCCESS); + return transactionResult; + } catch (err) { + console.error(err); + Sentry.captureException(err, { + tags: { operation: 'borrow_and_deposit_to_agent' }, + extra: { + amount: amountToBorrow, + userAddress: user?.safeAddress, + agentEoaAddress, + }, + user: user ? { id: user.userId, address: user.safeAddress } : undefined, + }); + setBridgeStatus(Status.ERROR); + setError(err instanceof Error ? err.message : 'Unknown error'); + throw err; + } + }, + [user, agentEoaAddress, safeAA, trackTransaction], + ); + + return { borrowAndDeposit, bridgeStatus, error }; +}; + +export default useBorrowAndDepositToAgent; diff --git a/hooks/useBorrowAndDepositToCard.ts b/hooks/useBorrowAndDepositToCard.ts index f1204674a..0fbfffa5b 100644 --- a/hooks/useBorrowAndDepositToCard.ts +++ b/hooks/useBorrowAndDepositToCard.ts @@ -1,59 +1,32 @@ import { useCallback, useState } from 'react'; import * as Sentry from '@sentry/react-native'; import { Address } from 'abitype'; -import { erc20Abi, pad, TransactionReceipt } from 'viem'; -import { readContract } from 'viem/actions'; -import { fuse, mainnet } from 'viem/chains'; -import { encodeFunctionData, parseUnits } from 'viem/utils'; +import { TransactionReceipt } from 'viem'; +import { fuse } from 'viem/chains'; -import { USDC_STARGATE } from '@/constants/addresses'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useActivityActions } from '@/hooks/useActivityActions'; -import { AaveV3Pool_ABI } from '@/lib/abis/AaveV3Pool'; -import BridgePayamster_ABI from '@/lib/abis/BridgePayamster'; -import { CardDepositManager_ABI } from '@/lib/abis/CardDepositManager'; import { track } from '@/lib/analytics'; import { - ADDRESSES, EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, EXPO_PUBLIC_CARD_FUNDING_CHAIN_KEY, } from '@/lib/config'; -import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; -import { StargateQuoteParams, Status, TransactionType } from '@/lib/types'; +import { USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { Status, TransactionType } from '@/lib/types'; import { getCardDepositTokenAddress, getCardFundingAddress } from '@/lib/utils'; -import { getStargateChainId, getStargateQuote } from '@/lib/utils/stargate'; -import { publicClient } from '@/lib/wagmi'; +import { executeBorrowAndBridge } from '@/lib/utils/borrowAndBridge'; import { useCardContracts } from './useCardContracts'; import { useCardDetails } from './useCardDetails'; import { useCardProvider } from './useCardProvider'; import useUser from './useUser'; -// ABI for AccountantWithRateProviders getRate function -const ACCOUNTANT_ABI = [ - { - inputs: [], - name: 'getRate', - outputs: [ - { - internalType: 'uint256', - name: 'rate', - type: 'uint256', - }, - ], - stateMutability: 'view', - type: 'function', - }, -] as const; - type BridgeResult = { borrowAndDeposit: (amount: string) => Promise; bridgeStatus: Status; error: string | null; }; -const soUSDLTV = 70n; // 80% LTV for soUSD (79% to avoid rounding errors) - const useBorrowAndDepositToCard = (): BridgeResult => { const { user, safeAA } = useUser(); const { trackTransaction } = useActivityActions(); @@ -67,27 +40,19 @@ const useBorrowAndDepositToCard = (): BridgeResult => { async (amountToBorrow: string) => { try { if (!user) { - const error = new Error('User is not selected'); + const err = new Error('User is not selected'); track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_ERROR, { amount: amountToBorrow, error: 'User not found', step: 'validation', source: 'useBridgeToCard', }); - Sentry.captureException(error, { - tags: { - operation: 'bridge_to_card', - step: 'validation', - }, - extra: { - amount: amountToBorrow, - hasUser: !!user, - }, + Sentry.captureException(err, { + tags: { operation: 'bridge_to_card', step: 'validation' }, + extra: { amount: amountToBorrow, hasUser: !!user }, }); - throw error; + throw err; } - - // Get card's Arbitrum funding address (Rain: from contracts, Bridge: from card details) if (!cardDetails) { throw new Error('Card details not found'); } @@ -97,26 +62,19 @@ const useBorrowAndDepositToCard = (): BridgeResult => { provider, contracts ?? undefined, ); - if (!arbitrumFundingAddress) { - const error = new Error('Arbitrum funding address not found for card'); + const err = new Error('Arbitrum funding address not found for card'); track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_ERROR, { amount: amountToBorrow, error: 'Arbitrum funding address not found', step: 'validation', source: 'useBridgeToCard', }); - Sentry.captureException(error, { - tags: { - operation: 'bridge_to_card', - step: 'validation', - }, - extra: { - amount: amountToBorrow, - hasCardDetails: !!cardDetails, - }, + Sentry.captureException(err, { + tags: { operation: 'bridge_to_card', step: 'validation' }, + extra: { amount: amountToBorrow, hasCardDetails: !!cardDetails }, }); - throw error; + throw err; } track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_INITIATED, { @@ -129,193 +87,36 @@ const useBorrowAndDepositToCard = (): BridgeResult => { setBridgeStatus(Status.PENDING); setError(null); - const rate = await readContract(publicClient(mainnet.id), { - address: ADDRESSES.ethereum.accountant, - abi: ACCOUNTANT_ABI, - functionName: 'getRate', - }); - - const destinationAddress = arbitrumFundingAddress; - const borrowAmountWei = parseUnits(amountToBorrow, 6); - const supplyAmountWei = (borrowAmountWei * 100n * 1000000n) / (soUSDLTV * rate); - - const supplyApproveCalldata = encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [ADDRESSES.fuse.aaveV3Pool, supplyAmountWei], - }); - - const supplyCalldata = encodeFunctionData({ - abi: AaveV3Pool_ABI, - functionName: 'supply', - args: [ADDRESSES.fuse.vault, supplyAmountWei, user.safeAddress as Address, 0], - }); - - const borrowCalldata = encodeFunctionData({ - abi: AaveV3Pool_ABI, - functionName: 'borrow', - args: [USDC_STARGATE, borrowAmountWei, 2, 0, user.safeAddress as Address], - }); - - Sentry.addBreadcrumb({ - message: 'Starting bridge to Card transaction', - category: 'bridge', - data: { - amount: amountToBorrow, - amountWei: borrowAmountWei.toString(), - userAddress: user.safeAddress, - destinationAddress, - chainId: fuse.id, + const transactionResult = await executeBorrowAndBridge({ + user: { + safeAddress: user.safeAddress, + suborgId: user.suborgId, + signWith: user.signWith, + userId: user.userId, }, + destinationAddress: arbitrumFundingAddress as Address, + destinationChainId: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, + destinationChainKey: EXPO_PUBLIC_CARD_FUNDING_CHAIN_KEY, + destinationToken: getCardDepositTokenAddress( + EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, + ) as Address, + amountToBorrow, + safeAA, + trackTransaction, + activityType: TransactionType.BORROW_AND_DEPOSIT_TO_CARD, + activityTitle: 'Borrow and deposit to Card', + flowTag: 'bridge_to_card', }); - // Get Stargate quote for taxi route - // Calculate minimum destination amount (95% of source amount for 5% slippage tolerance) - const dstAmountMin = (borrowAmountWei * 95n) / 100n; - - const dstToken = getCardDepositTokenAddress(EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID); - const quoteParams: StargateQuoteParams = { - srcToken: USDC_STARGATE, - srcChainKey: 'fuse', - dstToken, - dstChainKey: EXPO_PUBLIC_CARD_FUNDING_CHAIN_KEY, - srcAddress: ADDRESSES.fuse.bridgePaymasterAddress, - dstAddress: destinationAddress, - srcAmount: borrowAmountWei.toString(), - dstAmountMin: dstAmountMin.toString(), - }; - const quote = await getStargateQuote(quoteParams); - const taxiQuote = quote.quotes.find(q => q.route.includes('taxi')); - - if (!taxiQuote) { - throw new Error('Taxi route not available from Stargate'); - } - - if (taxiQuote.error) { - throw new Error(`Stargate quote error: ${taxiQuote.error}`); - } - - // Get the transaction from the first step (should be the bridge step) - const bridgeStep = taxiQuote.steps.find(step => step.type === 'bridge'); - - if (!bridgeStep) { - throw new Error('No bridge step found in Stargate quote'); - } - - const { transaction } = bridgeStep; - const nativeFeeAmount = BigInt(transaction.value); - - const sendParam = { - dstEid: getStargateChainId(EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID) as number, - to: pad(destinationAddress as `0x${string}`, { - size: 32, - }), - amountLD: borrowAmountWei, - minAmountLD: dstAmountMin, - extraOptions: '0x', - composeMsg: '0x', - oftCmd: '0x', - }; - - const calldata = encodeFunctionData({ - abi: CardDepositManager_ABI, - functionName: 'depositUsingStargate', - args: [ - transaction.to as Address, - user.safeAddress as Address, - sendParam, - nativeFeeAmount, - ADDRESSES.fuse.bridgePaymasterAddress, - ], - }); - - const transactions = [ - { - to: ADDRESSES.fuse.vault, - data: supplyApproveCalldata, - value: 0n, - }, - { - to: ADDRESSES.fuse.aaveV3Pool, - data: supplyCalldata, - value: 0n, - }, - { - to: ADDRESSES.fuse.aaveV3Pool, - data: borrowCalldata, - value: 0n, - }, - // 1) Approve USDC.e from Safe to DepositManager - { - to: USDC_STARGATE, - data: encodeFunctionData({ - abi: erc20Abi, - functionName: 'approve', - args: [ADDRESSES.fuse.cardDepositManager, borrowAmountWei], - }), - value: 0n, - }, - // 2) Perform the Stargate taxi call via BridgePaymaster and DepositManager, forwarding the fee it now holds - { - to: ADDRESSES.fuse.bridgePaymasterAddress, - data: encodeFunctionData({ - abi: BridgePayamster_ABI, - functionName: 'callWithValue', - args: [ - ADDRESSES.fuse.cardDepositManager, - '0x37fe667d', // depositUsingStargate function selector - calldata, - nativeFeeAmount, // the native to forward - ], - }), - value: 0n, - }, - ]; - - const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); - - const result = await trackTransaction( - { - type: TransactionType.BORROW_AND_DEPOSIT_TO_CARD, - title: `Borrow and deposit to Card`, - shortTitle: `Borrow and deposit to Card`, - amount: amountToBorrow, - symbol: 'USDC.e', // Source symbol - bridging USDC.e - chainId: fuse.id, - fromAddress: user.safeAddress, - toAddress: arbitrumFundingAddress, - metadata: { - description: `Borrow and deposit ${amountToBorrow} USDC from Fuse to Card on Arbitrum`, - fee: transaction.value, - sourceSymbol: 'USDC.e', // Track source symbol for display - tokenAddress: USDC_STARGATE, - }, - }, - onUserOpHash => - executeTransactions( - smartAccountClient, - transactions, - 'Borrow and deposit to Card failed', - fuse, - onUserOpHash, - ), - ); - - const transaction_result = - result && typeof result === 'object' && 'transaction' in result - ? result.transaction - : result; - - if (transaction_result === USER_CANCELLED_TRANSACTION) { - const error = new Error('User cancelled transaction'); + if (transactionResult === USER_CANCELLED_TRANSACTION) { + const err = new Error('User cancelled transaction'); track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_CANCELLED, { amount: amountToBorrow, - fee: transaction.value, from_chain: fuse.id, to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, source: 'useBridgeToCard', }); - Sentry.captureException(error, { + Sentry.captureException(err, { tags: { operation: 'bridge_to_card', step: 'execution', @@ -324,22 +125,17 @@ const useBorrowAndDepositToCard = (): BridgeResult => { extra: { amount: amountToBorrow, userAddress: user.safeAddress, - destinationAddress, + destinationAddress: arbitrumFundingAddress, chainId: fuse.id, - fee: transaction.value, - }, - user: { - id: user?.userId, - address: user?.safeAddress, }, + user: { id: user.userId, address: user.safeAddress }, }); - throw error; + throw err; } track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_COMPLETED, { amount: amountToBorrow, - transaction_hash: transaction_result.transactionHash, - fee: transaction.value, + transaction_hash: transactionResult.transactionHash, from_chain: fuse.id, to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, source: 'useBridgeToCard', @@ -350,49 +146,40 @@ const useBorrowAndDepositToCard = (): BridgeResult => { category: 'bridge', data: { amount: amountToBorrow, - transactionHash: transaction_result.transactionHash, + transactionHash: transactionResult.transactionHash, userAddress: user.safeAddress, - destinationAddress, + destinationAddress: arbitrumFundingAddress, chainId: fuse.id, }, }); setBridgeStatus(Status.SUCCESS); - return transaction_result; - } catch (error) { - console.error(error); - + return transactionResult; + } catch (err) { + console.error(err); track(TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_ERROR, { amount: amountToBorrow, from_chain: fuse.id, to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, - error: error instanceof Error ? error.message : 'Unknown error', - user_cancelled: String(error).includes('cancelled'), + error: err instanceof Error ? err.message : 'Unknown error', + user_cancelled: String(err).includes('cancelled'), step: 'execution', source: 'useBridgeToCard', }); - - Sentry.captureException(error, { - tags: { - operation: 'bridge_to_card', - step: 'execution', - }, + Sentry.captureException(err, { + tags: { operation: 'bridge_to_card', step: 'execution' }, extra: { amount: amountToBorrow, userAddress: user?.safeAddress, chainId: fuse.id, - errorMessage: error instanceof Error ? error.message : 'Unknown error', + errorMessage: err instanceof Error ? err.message : 'Unknown error', bridgeStatus, }, - user: { - id: user?.suborgId, - address: user?.safeAddress, - }, + user: { id: user?.suborgId, address: user?.safeAddress }, }); - setBridgeStatus(Status.ERROR); - setError(error instanceof Error ? error.message : 'Unknown error'); - throw error; + setError(err instanceof Error ? err.message : 'Unknown error'); + throw err; } }, [user, cardDetails, provider, contracts, safeAA, trackTransaction, bridgeStatus], diff --git a/hooks/useCardContracts.ts b/hooks/useCardContracts.ts index 131c36d12..d375120d0 100644 --- a/hooks/useCardContracts.ts +++ b/hooks/useCardContracts.ts @@ -15,6 +15,6 @@ export function useCardContracts() { queryKey: [CARD_CONTRACTS_KEY], queryFn: () => withRefreshToken(() => getCardContracts()), enabled: provider === CardProvider.RAIN, - retry: false, + retry: 2, }); } diff --git a/hooks/useCardDeposit.ts b/hooks/useCardDeposit.ts index 1f793055e..7203c21c5 100644 --- a/hooks/useCardDeposit.ts +++ b/hooks/useCardDeposit.ts @@ -76,6 +76,7 @@ const useCardDeposit = (): CardDepositResult => { type: 'error', text1: 'Deposits not available', text2: 'This card does not support deposits', + props: { badgeText: '' }, }); return; } diff --git a/hooks/useCardProvider.ts b/hooks/useCardProvider.ts index 1a149bc3d..99d11ae90 100644 --- a/hooks/useCardProvider.ts +++ b/hooks/useCardProvider.ts @@ -1,20 +1,17 @@ import { useQuery } from '@tanstack/react-query'; -import { getCardBalance } from '@/lib/api'; import { EXPO_PUBLIC_CARD_ISSUER } from '@/lib/config'; import { CardProvider } from '@/lib/types'; -import { hasCard, withRefreshToken } from '@/lib/utils'; +import { hasCard } from '@/lib/utils'; import { cardDetailsQueryOptions } from './cardDetailsQueryOptions'; import { useCardStatus } from './useCardStatus'; -const CARD_PROVIDER_PROBE_KEY = 'cardProviderProbe'; - /** - * Resolves card issuer (bridge vs rain). Uses, in order: - * 1. EXPO_PUBLIC_CARD_ISSUER if set - * 2. provider from GET /cards/details or GET /cards/status when backend sends it - * 3. Probe: GET /cards/balance → 200 = rain, 400 = bridge (cached) + * Resolves card issuer. Bridge is deprecated — Rain is the only supported provider. + * Uses, in order: + * 1. EXPO_PUBLIC_CARD_ISSUER if set (test/override) + * 2. Rain when the user has an active Rain card (Bridge-only users are treated as no card) */ export function useCardProvider(): { provider: CardProvider | null; @@ -22,41 +19,14 @@ export function useCardProvider(): { } { const { data: cardDetails } = useQuery(cardDetailsQueryOptions()); const { data: cardStatus } = useCardStatus(); - const hasCardData = - hasCard(cardStatus) || (!!cardDetails?.id && cardDetails?.provider !== CardProvider.BRIDGE); - - const providerFromResponse = cardDetails?.provider ?? cardStatus?.provider ?? undefined; - - const probeQuery = useQuery({ - queryKey: [CARD_PROVIDER_PROBE_KEY], - queryFn: async (): Promise => { - try { - await withRefreshToken(() => getCardBalance()); - return CardProvider.RAIN; - } catch (e: unknown) { - if (e instanceof Response && e.status === 400) return CardProvider.BRIDGE; - throw e; - } - }, - enabled: hasCardData && !providerFromResponse && !EXPO_PUBLIC_CARD_ISSUER, - retry: false, - staleTime: 5 * 60 * 1000, - }); if (EXPO_PUBLIC_CARD_ISSUER) { return { provider: EXPO_PUBLIC_CARD_ISSUER, isLoading: false }; } - if (providerFromResponse) { - return { provider: providerFromResponse, isLoading: false }; - } - if (!hasCardData) { - return { provider: null, isLoading: false }; - } - if (probeQuery.isLoading || probeQuery.isFetching) { - return { provider: null, isLoading: true }; - } - if (probeQuery.data) { - return { provider: probeQuery.data, isLoading: false }; - } - return { provider: null, isLoading: false }; + + const hasRainCard = + hasCard(cardStatus) || + (!!cardDetails?.id && cardDetails?.provider !== CardProvider.BRIDGE); + + return { provider: hasRainCard ? CardProvider.RAIN : null, isLoading: false }; } diff --git a/hooks/useCardSteps/kycDisplayHelpers.ts b/hooks/useCardSteps/kycDisplayHelpers.ts index 6eacb0ca4..ac873ac1f 100644 --- a/hooks/useCardSteps/kycDisplayHelpers.ts +++ b/hooks/useCardSteps/kycDisplayHelpers.ts @@ -5,6 +5,7 @@ import { BridgeRejectionReason, CardProvider, KycStatus, + KycWarning, RainApplicationStatus, } from '@/lib/types'; @@ -79,25 +80,50 @@ const DIDIT_WARNING_DESCRIPTIONS: Record = { INVALID_DATE: 'A date on the document is invalid', }; -function formatDiditWarning(tag: string): string { - return ( - DIDIT_WARNING_DESCRIPTIONS[tag] ?? - tag - .replace(/_/g, ' ') - .toLowerCase() - .replace(/^\w/, (c) => c.toUpperCase()) - ); +/** Convert a SCREAMING_SNAKE_CASE tag into a Title-Cased phrase. */ +function formatRiskTag(tag: string): string { + return tag + .replace(/_/g, ' ') + .toLowerCase() + .replace(/^\w/, c => c.toUpperCase()); +} + +/** + * Pick the best display text for a single warning: + * 1. Our DIDIT_WARNING_DESCRIPTIONS override (when we want friendlier wording than Didit's) + * 2. Didit's `short_description` (always set for documented warnings) + * 3. Didit's `long_description` (rare fallback if a partial payload arrives) + * 4. The risk tag formatted into Title Case + */ +function formatDiditWarning(warning: KycWarning): string { + const risk = warning.risk ?? ''; + if (risk && DIDIT_WARNING_DESCRIPTIONS[risk]) { + return DIDIT_WARNING_DESCRIPTIONS[risk]; + } + if (warning.short_description) return warning.short_description; + if (warning.long_description) return warning.long_description; + return risk ? formatRiskTag(risk) : ''; } -function formatKycWarnings(warnings: string[]): string { - if (warnings.length === 0) return ''; - return warnings.map(formatDiditWarning).join('\n- '); +function formatKycWarnings(warnings: KycWarning[]): string { + if (!warnings || warnings.length === 0) return ''; + return warnings + .map(formatDiditWarning) + .filter(line => line.length > 0) + .join('\n- '); } /** - * User-friendly KYC description per Rain application state + * User-friendly KYC description per Rain application state. + * For NEEDS_INFORMATION, surface the specific rejection reasons (Rain only sends + * temporary, user-actionable labels for this state). Other states stay generic — + * final rejections (DENIED/LOCKED/CANCELED) intentionally do not expose the + * underlying compliance labels (e.g. SANCTIONS, PEP). */ -export function getKYCDescription(rainApplicationStatus?: RainApplicationStatus | null): string { +export function getKYCDescription( + rainApplicationStatus?: RainApplicationStatus | null, + kycWarnings?: KycWarning[] | null, +): string { if (!rainApplicationStatus) return DEFAULT_KYC_DESCRIPTION; switch (rainApplicationStatus) { case RainApplicationStatus.APPROVED: @@ -114,8 +140,13 @@ export function getKYCDescription(rainApplicationStatus?: RainApplicationStatus return 'This application was canceled. Contact support if you need to start over.'; case RainApplicationStatus.NEEDS_VERIFICATION: return "Verify your identity to continue. You'll be redirected to complete verification."; - case RainApplicationStatus.NEEDS_INFORMATION: + case RainApplicationStatus.NEEDS_INFORMATION: { + const formatted = formatKycWarnings(kycWarnings ?? []); + if (formatted.length > 0) { + return `We need a bit more information to process your application:\n- ${formatted}`; + } return 'We need a bit more information to process your application.'; + } case RainApplicationStatus.NOT_STARTED: default: return DEFAULT_KYC_DESCRIPTION; @@ -178,7 +209,7 @@ export function getStepDescription( cardIssuer?: CardProvider | null; rainApplicationStatus?: RainApplicationStatus | null; kycStatus?: KycStatus | null; - kycWarnings?: string[] | null; + kycWarnings?: KycWarning[] | null; }, ): string { // Only use Rain description for recognized Rain application statuses @@ -186,12 +217,12 @@ export function getStepDescription( options?.rainApplicationStatus && Object.values(RainApplicationStatus).includes(options.rainApplicationStatus); + const warnings = options?.kycWarnings ?? []; + if (options?.cardIssuer === CardProvider.RAIN && isRecognizedRainStatus) { - return getKYCDescription(options.rainApplicationStatus); + return getKYCDescription(options.rainApplicationStatus, warnings); } - const warnings = options?.kycWarnings ?? []; - // Didit KYC rejected or expired before reaching Rain — show rejection reasons if (options?.kycStatus === KycStatus.REJECTED) { if (warnings.length > 0) { diff --git a/hooks/useCardSteps/stepHelpers.ts b/hooks/useCardSteps/stepHelpers.ts index bd4593038..4993a5f3b 100644 --- a/hooks/useCardSteps/stepHelpers.ts +++ b/hooks/useCardSteps/stepHelpers.ts @@ -1,25 +1,18 @@ import { useCallback, useEffect, useState } from 'react'; -import Toast from 'react-native-toast-message'; import { Router } from 'expo-router'; -import { useQueryClient } from '@tanstack/react-query'; import { EndorsementStatus } from '@/components/BankTransfer/enums'; import { path } from '@/constants/path'; -import { TRACKING_EVENTS } from '@/constants/tracking-events'; -import { CARD_STATUS_QUERY_KEY } from '@/hooks/useCardStatus'; -import { track } from '@/lib/analytics'; -import { createCard } from '@/lib/api'; import { BridgeCustomerEndorsement, BridgeRejectionReason, CardProvider, CardStatus, KycStatus, + KycWarning, RainApplicationStatus, } from '@/lib/types'; -import { withRefreshToken } from '@/lib/utils'; -import { extractCardActivationErrorMessage } from './cardActivationHelpers'; import { getStepButtonText, getStepDescription, isStepButtonDisabled } from './kycDisplayHelpers'; import { Step } from './types'; @@ -33,13 +26,13 @@ export function buildCardSteps( activationBlocked: boolean | undefined, activationBlockedReason: string | undefined, handleProceedToKyc: () => void, - handleActivateCard: () => void, + pushCardReady: () => void, pushCardDetails: () => void, options?: { cardIssuer?: CardProvider | null; rainApplicationStatus?: RainApplicationStatus | null; kycStatus?: KycStatus | null; - kycWarnings?: string[] | null; + kycWarnings?: KycWarning[] | null; handleRainKYCPress?: () => void; }, ): Step[] { @@ -66,7 +59,7 @@ export function buildCardSteps( const orderCardDesc = activationBlocked ? activationBlockedReason || 'There was an issue activating your card. Please contact support.' - : 'All is set! now click on the "Create card" button to issue your new card'; + : 'All is set! Click on "Activate card" to review the agreements and issue your new card.'; const kycStepOnPress = options?.cardIssuer === CardProvider.RAIN && options?.handleRainKYCPress @@ -90,8 +83,8 @@ export function buildCardSteps( description: orderCardDesc, completed: cardActivated, status: cardActivated ? 'completed' : 'pending', - buttonText: activationBlocked || !isKycComplete ? undefined : 'Order card', - onPress: activationBlocked || !isKycComplete ? undefined : handleActivateCard, + buttonText: activationBlocked || !isKycComplete ? undefined : 'Activate card', + onPress: activationBlocked || !isKycComplete ? undefined : pushCardReady, }, { id: 3, @@ -116,46 +109,13 @@ export function findFirstIncompleteStep(steps: Step[]): Step | undefined { } /** - * Hook to manage card activation state and actions + * Hook to manage card activation state and actions. + * Card creation itself happens on /card/ready after the user accepts the + * consents; this hook only tracks completion state and exposes navigation. */ export function useCardActivation(router: Router) { - const queryClient = useQueryClient(); const [cardActivated, setCardActivated] = useState(false); - const [activatingCard, setActivatingCard] = useState(false); - const handleActivateCard = useCallback(async () => { - track(TRACKING_EVENTS.CARD_ACTIVATION_STARTED); - try { - setActivatingCard(true); - - // Create the card - const card = await withRefreshToken(() => createCard()); - - if (!card) throw new Error('Failed to create card'); - - if (card.status !== CardStatus.PENDING) { - setCardActivated(true); - track(TRACKING_EVENTS.CARD_ACTIVATION_SUCCEEDED, { cardId: card.id }); - router.replace(path.CARD_DETAILS); - } else { - // If card is pending, we don't mark as activated and don't redirect. - // We just invalidate the card status to show the "pending" UI on the same page. - queryClient.invalidateQueries({ queryKey: [CARD_STATUS_QUERY_KEY] }); - } - } catch (error) { - console.error('Error activating card:', error); - const errorMessage = await extractCardActivationErrorMessage(error); - - track(TRACKING_EVENTS.CARD_ACTIVATION_FAILED, { message: errorMessage }); - Toast.show({ - type: 'error', - text1: 'Error activating card', - text2: errorMessage, - props: { badgeText: '' }, - }); - } finally { - setActivatingCard(false); - } - }, [router, queryClient]); + const [activatingCard] = useState(false); const syncCardActivationState = useCallback((cardStatus: CardStatus | undefined) => { // Mark card as activated if user has a card in any state @@ -172,12 +132,16 @@ export function useCardActivation(router: Router) { router.push(path.CARD_DETAILS); }, [router]); + const pushCardReady = useCallback(() => { + router.push(path.CARD_READY); + }, [router]); + return { cardActivated, activatingCard, - handleActivateCard, syncCardActivationState, pushCardDetails, + pushCardReady, }; } diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index 56af82cc6..0fd2b2473 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -11,12 +11,7 @@ import { getCustomerFromBridge, getKycLinkFromBridge } from '@/lib/api'; import { EXPO_PUBLIC_CARD_ISSUER } from '@/lib/config'; import { openIntercom } from '@/lib/intercom'; import { redirectToRainVerification } from '@/lib/rainVerification'; -import { - CardProvider, - CardStatusResponse, - KycStatus, - RainApplicationStatus, -} from '@/lib/types'; +import { CardProvider, CardStatusResponse, KycStatus, RainApplicationStatus } from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { useCountryStore } from '@/store/useCountryStore'; import { useKycStore } from '@/store/useKycStore'; @@ -102,13 +97,8 @@ export function useCardSteps( ); // Card activation state and handlers - const { - cardActivated, - activatingCard, - handleActivateCard, - syncCardActivationState, - pushCardDetails, - } = useCardActivation(router); + const { cardActivated, activatingCard, syncCardActivationState, pushCardDetails, pushCardReady } = + useCardActivation(router); // Sync card activation state with server useEffect(() => { @@ -256,7 +246,7 @@ export function useCardSteps( cardStatusResponse?.activationBlocked, cardStatusResponse?.activationBlockedReason, handleProceedToKyc, - handleActivateCard, + pushCardReady, pushCardDetails, { cardIssuer, @@ -276,7 +266,7 @@ export function useCardSteps( cardStatusResponse?.kycStatus, cardStatusResponse?.kycWarnings, handleProceedToKyc, - handleActivateCard, + pushCardReady, pushCardDetails, cardIssuer, handleRainKYCPress, diff --git a/hooks/useDepositFromSolidUsdc.ts b/hooks/useDepositFromSolidUsdc.ts index ca4fb9a74..615b34901 100644 --- a/hooks/useDepositFromSolidUsdc.ts +++ b/hooks/useDepositFromSolidUsdc.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import * as Sentry from '@sentry/react-native'; import { type Address, encodeFunctionData, erc20Abi, parseUnits } from 'viem'; -import { mainnet } from 'viem/chains'; +import { base, mainnet } from 'viem/chains'; import { useBlockNumber, useReadContract } from 'wagmi'; import { ERRORS } from '@/constants/errors'; @@ -12,7 +12,14 @@ import { bridgeDeposit, createDeposit } from '@/lib/api'; import { getAttributionChannel } from '@/lib/attribution'; import { EXPO_PUBLIC_BRIDGE_AUTO_DEPOSIT_ADDRESS } from '@/lib/config'; import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; -import { Status, StatusInfo, TransactionStatus, TransactionType, VaultType } from '@/lib/types'; +import { + DepositCategory, + Status, + StatusInfo, + TransactionStatus, + TransactionType, + VaultType, +} from '@/lib/types'; import { withRefreshToken } from '@/lib/utils'; import { useAttributionStore } from '@/store/useAttributionStore'; import { useDepositStore } from '@/store/useDepositStore'; @@ -32,6 +39,7 @@ const useDepositFromSolidUsdc = ( tokenAddress: Address, token: string, minimumAmount: string = '10', + category: DepositCategory = DepositCategory.SAVINGS, ): DepositResult => { const { user, safeAA } = useUser(); const [depositStatus, setDepositStatus] = useState({ status: Status.IDLE }); @@ -42,6 +50,9 @@ const useDepositFromSolidUsdc = ( const updateUser = useUserStore(state => state.updateUser); const safeAddress = user?.safeAddress as Address | undefined; + const isCard = category === DepositCategory.CARD; + const targetChainId = isCard ? base.id : mainnet.id; + const isTargetChain = srcChainId === targetChainId; const isEthereum = srcChainId === mainnet.id; const { data: blockNumber } = useBlockNumber({ @@ -65,19 +76,24 @@ const useDepositFromSolidUsdc = ( const createEvent = async (amount: string, spender: Address, tokenSymbol: string) => { const clientTxId = await createActivity({ - title: `Deposit ${tokenSymbol}`, + title: isCard ? `Deposit ${tokenSymbol} to Card` : `Deposit ${tokenSymbol}`, amount, symbol: tokenSymbol, chainId: srcChainId, fromAddress: safeAddress, toAddress: spender, - type: TransactionType.DEPOSIT, + type: isCard ? TransactionType.CARD_DEPOSIT : TransactionType.DEPOSIT, }); return clientTxId; }; const deposit = async (amount: string) => { - if (!token || !srcChainId) return undefined; + if (!token) return undefined; + if (!srcChainId) { + throw new Error( + 'Source chain is not selected. Please reopen the deposit flow and pick a chain.', + ); + } const attributionData = useAttributionStore.getState().getAttributionForEvent(); const attributionChannel = getAttributionChannel(attributionData); @@ -89,7 +105,14 @@ const useDepositFromSolidUsdc = ( safe_address: user?.safeAddress, amount, deposit_type: 'solid_wallet', - deposit_method: isEthereum ? 'usdc_solid_ethereum' : 'usdc_solid_bridge', + deposit_method: isTargetChain + ? isCard + ? 'usdc_solid_base_card' + : 'usdc_solid_ethereum' + : isCard + ? 'usdc_solid_bridge_card' + : 'usdc_solid_bridge', + deposit_destination: isCard ? 'card' : 'savings', chain_id: srcChainId, is_sponsor: Number(amount) >= Number(minimumAmount), ...attributionData, @@ -111,7 +134,7 @@ const useDepositFromSolidUsdc = ( const isSponsor = Number(amount) >= Number(minimumAmount); - if (!isSponsor) { + if (!isCard && !isSponsor) { throw new Error(`Minimum deposit amount is $${minimumAmount}`); } @@ -128,8 +151,13 @@ const useDepositFromSolidUsdc = ( const amountWei = parseUnits(amount, 6); - // Approve the bridge/deposit address to pull tokens from Safe - const chain = isEthereum ? mainnet : { id: srcChainId } as any; + // Approve the bridge/deposit address to pull tokens from Safe on the src chain. + const chain = + srcChainId === mainnet.id + ? mainnet + : srcChainId === base.id + ? base + : ({ id: srcChainId } as any); const smartAccountClient = await safeAA(chain, user!.suborgId, user!.signWith); const approveTransaction = { @@ -171,14 +199,16 @@ const useDepositFromSolidUsdc = ( }); } - // Call backend to pull tokens from Safe and deposit to vault - const depositPromise = isEthereum + // Call backend to pull tokens from the Solid Safe AA and deliver to the + // target (savings vault on Ethereum, or Rain card funding address on Base). + const depositPromise = isTargetChain ? withRefreshToken(() => createDeposit({ eoaAddress: safeAddress, amount, trackingId, - vault: VaultType.USDC, + vault: isCard ? undefined : VaultType.USDC, + category: isCard ? DepositCategory.CARD : DepositCategory.SAVINGS, }), ) : withRefreshToken(() => @@ -188,6 +218,7 @@ const useDepositFromSolidUsdc = ( srcChainId, amount, trackingId, + category: isCard ? DepositCategory.CARD : DepositCategory.SAVINGS, }), ); @@ -207,12 +238,21 @@ const useDepositFromSolidUsdc = ( data: { amount, safeAddress, srcChainId, isSponsor }, }); + const depositMethod = isTargetChain + ? isCard + ? 'usdc_solid_base_card' + : 'usdc_solid_ethereum' + : isCard + ? 'usdc_solid_bridge_card' + : 'usdc_solid_bridge'; + track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { user_id: user?.userId, safe_address: user?.safeAddress, amount, deposit_type: 'solid_wallet', - deposit_method: isEthereum ? 'usdc_solid_ethereum' : 'usdc_solid_bridge', + deposit_method: depositMethod, + deposit_destination: isCard ? 'card' : 'savings', chain_id: srcChainId, is_sponsor: isSponsor, is_first_deposit: !user?.isDeposited, @@ -223,8 +263,12 @@ const useDepositFromSolidUsdc = ( trackIdentity(user?.userId!, { last_deposit_amount: parseFloat(amount), last_deposit_date: new Date().toISOString(), - last_deposit_method: isEthereum ? 'usdc_solid_ethereum' : 'usdc_solid_bridge', - last_deposit_chain: isEthereum ? 'ethereum' : String(srcChainId), + last_deposit_method: depositMethod, + last_deposit_chain: isEthereum + ? 'ethereum' + : srcChainId === base.id + ? 'base' + : String(srcChainId), ...attributionData, attribution_channel: attributionChannel, }); diff --git a/hooks/useNav.ts b/hooks/useNav.ts index 7c1623fe2..73e7b9bd9 100644 --- a/hooks/useNav.ts +++ b/hooks/useNav.ts @@ -28,12 +28,17 @@ const card: MenuItem = { href: path.CARD, }; +const agent: MenuItem = { + label: 'Agent', + href: path.AGENT, +}; + const useNav = () => { const points: MenuItem = { label: isProduction ? 'Points' : 'Rewards', href: isProduction ? path.POINTS : path.REWARDS, }; - const menuItems: MenuItem[] = [home, savings, card, points, activity]; + const menuItems: MenuItem[] = [home, savings, card, points, agent, activity]; return { menuItems, }; diff --git a/hooks/useRepayAndWithdrawCollateral.ts b/hooks/useRepayAndWithdrawCollateral.ts index deb05ea19..7cb944c34 100644 --- a/hooks/useRepayAndWithdrawCollateral.ts +++ b/hooks/useRepayAndWithdrawCollateral.ts @@ -14,7 +14,7 @@ import { publicClient } from '@/lib/wagmi'; import * as Sentry from '@sentry/react-native'; import { Address } from 'abitype'; import { useCallback, useState } from 'react'; -import { erc20Abi, pad, TransactionReceipt } from 'viem'; +import { erc20Abi, maxUint256, pad, TransactionReceipt } from 'viem'; import { readContract } from 'viem/actions'; import { fuse, mainnet } from 'viem/chains'; import { encodeFunctionData, parseUnits } from 'viem/utils'; @@ -48,6 +48,10 @@ type RepayAndWithdrawCollateralResult = { const RATE_SCALE = 1_000_000n; const LIQ_THRESHOLD_BPS = 8_000n; // 80% const TARGET_HEALTH_FACTOR_BPS = 10_200n; // 1.02x +// Buffer added to the approval when the user is fully repaying their debt. +// Aave's debt accrues interest every block, so the live debt at execution +// time is slightly higher than the snapshot the UI shows. +const REPAY_INTEREST_BUFFER_BPS = 50n; // 0.5% const useRepayAndWithdrawCollateral = (): RepayAndWithdrawCollateralResult => { const { user, safeAA } = useUser(); @@ -94,10 +98,24 @@ const useRepayAndWithdrawCollateral = (): RepayAndWithdrawCollateralResult => { const totalBorrowedWei = parseUnits(totalBorrowed.toFixed(6), 6); const totalSuppliedSoUSDWei = parseUnits(totalSupplied.toFixed(6), 6); const totalSuppliedUsdWei = (totalSuppliedSoUSDWei * rate) / RATE_SCALE; + // When the user is repaying the full borrowed snapshot we treat this as a + // "max repay": pass MaxUint256 to Aave's repay() so it consumes exactly + // the live debt (which has accrued past the snapshot), approve a small + // buffer to cover that accrual, and withdraw all collateral. Without this + // the repay leaves a tiny dust debt and the bundled withdraw reverts on + // the health factor check. + const isMaxRepay = repayAmountWei >= totalBorrowedWei && totalBorrowedWei > 0n; + const approveAmountWei = isMaxRepay + ? totalBorrowedWei + (totalBorrowedWei * REPAY_INTEREST_BUFFER_BPS) / 10_000n + : repayAmountWei; + const repayCallAmountWei = isMaxRepay ? maxUint256 : repayAmountWei; const cappedRepayWei = repayAmountWei > totalBorrowedWei ? totalBorrowedWei : repayAmountWei; - const remainingBorrowWei = - totalBorrowedWei > cappedRepayWei ? totalBorrowedWei - cappedRepayWei : 0n; + const remainingBorrowWei = isMaxRepay + ? 0n + : totalBorrowedWei > cappedRepayWei + ? totalBorrowedWei - cappedRepayWei + : 0n; const requiredCollateralValueWei = remainingBorrowWei === 0n ? 0n @@ -115,13 +133,13 @@ const useRepayAndWithdrawCollateral = (): RepayAndWithdrawCollateralResult => { const repayApproveCalldata = encodeFunctionData({ abi: erc20Abi, functionName: 'approve', - args: [ADDRESSES.fuse.aaveV3Pool, repayAmountWei], + args: [ADDRESSES.fuse.aaveV3Pool, approveAmountWei], }); const repayCalldata = encodeFunctionData({ abi: AaveV3Pool_ABI, functionName: 'repay', - args: [USDC_STARGATE, repayAmountWei, 2, user.safeAddress as Address], + args: [USDC_STARGATE, repayCallAmountWei, 2, user.safeAddress as Address], }); const withdrawCalldata = encodeFunctionData({ diff --git a/hooks/useTransferToWallet.ts b/hooks/useTransferToWallet.ts index b99ed0b8e..5c1cda43b 100644 --- a/hooks/useTransferToWallet.ts +++ b/hooks/useTransferToWallet.ts @@ -184,7 +184,21 @@ const useTransferToWallet = ( waitForTransactionReceipt(publicClient(srcChainId), { hash: txHash as `0x${string}`, }) - .then(() => { + .then(receipt => { + // viem resolves with the receipt regardless of execution outcome. + // A reverted on-chain transaction is `status: 'reverted'` — treat + // that as FAILED, not SUCCESS. + if (receipt.status !== 'success') { + updateActivity(capturedTrackingId!, { + status: TransactionStatus.FAILED, + metadata: { + error: 'Transaction reverted on-chain', + failedAt: new Date().toISOString(), + }, + }); + return; + } + updateActivity(capturedTrackingId!, { status: TransactionStatus.SUCCESS }); track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { diff --git a/hooks/useUser.ts b/hooks/useUser.ts index 1687e7697..000426126 100644 --- a/hooks/useUser.ts +++ b/hooks/useUser.ts @@ -463,10 +463,17 @@ const useUser = (): UseUserReturn => { } }, [clearBalance, users, unselectUser, updateUser, clearKycLinkId, router, user, intercom]); - // New: select user by userId (preferred for email-first users) + // Authenticate the user identified by `userId` via passkey. + // + // Callers (the welcome page) must pre-select the user in the store before + // invoking this — TurnkeyProvider reads the selected user's credentialId + // and uses it as the passkey stamper's `allowCredentials`, so the user must + // already be selected by the time the SDK builds its client. + // + // Errors (including auth failures) are re-thrown so the caller can revert + // the pre-selection and surface the failure in the UI. const handleSelectUserById = useCallback( async (userId: string) => { - const previousUserId = user?.userId; clearKycLinkId(); // Find the selected user @@ -492,51 +499,40 @@ const useUser = (): UseUserReturn => { return; } - // Always require passkey authentication on all platforms - try { - if (!httpClient) { - throw new Error('Turnkey client is not initialized. Please wait and try again.'); - } + if (!httpClient) { + throw new Error('Turnkey client is not initialized. Please wait and try again.'); + } - const result = await httpClient.stampGetWhoami( - { organizationId: EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID }, - StamperType.Passkey, - ); + const result = await httpClient.stampGetWhoami( + { organizationId: EXPO_PUBLIC_TURNKEY_ORGANIZATION_ID }, + StamperType.Passkey, + ); - const authedUser = await login(result); + const authedUser = await login(result); - // Update the stored user with fresh tokens and select them - if (selectedUser && authedUser) { - storeUser({ - ...selectedUser, - selected: true, - tokens: authedUser.tokens || undefined, - }); - } else { - selectUserById(authedUser?._id ?? userId); - } + // Update the stored user with fresh tokens and keep them selected + if (selectedUser && authedUser) { + storeUser({ + ...selectedUser, + selected: true, + tokens: authedUser.tokens || undefined, + }); + } else { + selectUserById(authedUser?._id ?? userId); + } - // Reset logout flag so future session expiries show the toast - setIsLoggingOut(false); + // Reset logout flag so future session expiries show the toast + setIsLoggingOut(false); - const { redirectFrom, setRedirectFrom } = useUserStore.getState(); - if (redirectFrom) { - setRedirectFrom(null); - router.replace(redirectFrom as any); - } else { - router.replace(path.HOME); - } - } catch (_) { - // Revert to previous user or clear selection on auth failure - if (previousUserId) { - selectUserById(previousUserId); - } else { - unselectUser(); - } - // Don't navigate on error - stay on welcome screen + const { redirectFrom, setRedirectFrom } = useUserStore.getState(); + if (redirectFrom) { + setRedirectFrom(null); + router.replace(redirectFrom as any); + } else { + router.replace(path.HOME); } }, - [selectUserById, storeUser, clearKycLinkId, router, user, unselectUser, users, httpClient], + [selectUserById, storeUser, clearKycLinkId, router, users, httpClient], ); const handleRemoveUsers = useCallback(() => { diff --git a/lib/alchemy.ts b/lib/alchemy.ts new file mode 100644 index 000000000..a200461a6 --- /dev/null +++ b/lib/alchemy.ts @@ -0,0 +1,281 @@ +import axios from 'axios'; + +import { ALCHEMY_CHAIN_URLS, ALCHEMY_REQUEST_TIMEOUT_MS } from '@/constants/alchemy'; +import { BlockscoutTransaction, BlockscoutTransactions, TokenType } from '@/lib/types'; + +import type { BlockscoutTokenBalance } from '@/hooks/useBalances'; + +/** + * Thin Alchemy JSON-RPC client plus mappers that shape responses into the + * existing `BlockscoutTokenBalance` / `BlockscoutTransactions` types so + * consumers (useBalances, fetchTokenTransfer) don't need to change. + * + * Native balances are NOT fetched here — viem `getBalance` handles that. + */ + +interface JsonRpcResponse { + jsonrpc: string; + id: number | string; + result?: T; + error?: { code: number; message: string }; +} + +interface AlchemyTokenBalancesResult { + address: string; + tokenBalances: { contractAddress: string; tokenBalance: string | null }[]; + pageKey?: string; +} + +interface AlchemyTokenMetadata { + decimals: number | null; + logo: string | null; + name: string | null; + symbol: string | null; +} + +export type AlchemyTransferCategory = 'external' | 'erc20' | 'erc721' | 'erc1155'; + +interface AlchemyAssetTransfer { + blockNum: string; + uniqueId: string; + hash: string; + from: string; + to: string | null; + value: number | null; + asset: string | null; + category: AlchemyTransferCategory; + rawContract: { + value: string | null; + address: string | null; + decimal: string | null; + }; + metadata: { blockTimestamp: string }; +} + +interface AlchemyAssetTransfersResult { + transfers: AlchemyAssetTransfer[]; + pageKey?: string; +} + +const jsonRpc = async (chainId: number, method: string, params: unknown[]): Promise => { + const url = ALCHEMY_CHAIN_URLS[chainId]; + if (!url) throw new Error(`No Alchemy URL configured for chain ${chainId}`); + const response = await axios.post>( + url, + { jsonrpc: '2.0', id: 1, method, params }, + { timeout: ALCHEMY_REQUEST_TIMEOUT_MS }, + ); + if (response.data.error) { + throw new Error( + `Alchemy ${method} error ${response.data.error.code}: ${response.data.error.message}`, + ); + } + if (response.data.result === undefined) { + throw new Error(`Alchemy ${method} returned no result`); + } + return response.data.result; +}; + +// Module-level cache for token metadata. Immutable per contract; no TTL +// needed for a single app session on mobile. +const metadataCache = new Map(); +const metadataCacheKey = (chainId: number, address: string) => + `${chainId}:${address.toLowerCase()}`; + +/** + * Resolve metadata for a batch of contract addresses using a single JSON-RPC + * batch request. Results are populated into the shared metadata cache. + */ +const alchemyGetTokenMetadataBatch = async ( + chainId: number, + addresses: string[], +): Promise => { + const url = ALCHEMY_CHAIN_URLS[chainId]; + if (!url) return; + + const toFetch = addresses.filter(addr => !metadataCache.has(metadataCacheKey(chainId, addr))); + if (toFetch.length === 0) return; + + const body = toFetch.map((addr, idx) => ({ + jsonrpc: '2.0', + id: idx, + method: 'alchemy_getTokenMetadata', + params: [addr], + })); + + try { + const response = await axios.post[]>(url, body, { + timeout: ALCHEMY_REQUEST_TIMEOUT_MS, + }); + const data = Array.isArray(response.data) ? response.data : []; + for (const entry of data) { + const idx = Number(entry.id); + if (Number.isFinite(idx) && toFetch[idx]) { + const meta = entry.result ?? { + decimals: null, + logo: null, + name: null, + symbol: null, + }; + metadataCache.set(metadataCacheKey(chainId, toFetch[idx]), meta); + } + } + } catch { + // Populate cache with empty entries so we don't retry endlessly. + for (const addr of toFetch) { + metadataCache.set(metadataCacheKey(chainId, addr), { + decimals: null, + logo: null, + name: null, + symbol: null, + }); + } + } +}; + +/** + * Fetch ERC-20 balances from Alchemy and map into the existing + * `BlockscoutTokenBalance` shape so `convertBlockscoutToTokenBalance` in + * useBalances can consume it without changes. + * + * Native balance (ETH, MATIC) is not included — handled via viem `getBalance` + * in useBalances as before. + */ +export const fetchAlchemyTokenBalances = async ( + chainId: number, + address: string, +): Promise => { + // Paginate via pageKey so wallets with >100 tokens aren't truncated. + const tokenBalances: { contractAddress: string; tokenBalance: string | null }[] = []; + let pageKey: string | undefined; + // Safety cap at 10 pages (≈1000 tokens) to bound worst case. + for (let i = 0; i < 10; i++) { + const params: unknown[] = [address, 'erc20']; + if (pageKey) params.push({ pageKey }); + const page = await jsonRpc( + chainId, + 'alchemy_getTokenBalances', + params, + ); + tokenBalances.push(...(page.tokenBalances ?? [])); + if (!page.pageKey) break; + pageKey = page.pageKey; + } + + const nonZero = tokenBalances.filter(b => { + if (!b.tokenBalance) return false; + try { + return BigInt(b.tokenBalance) !== 0n; + } catch { + return false; + } + }); + + if (nonZero.length === 0) return []; + + await alchemyGetTokenMetadataBatch( + chainId, + nonZero.map(b => b.contractAddress), + ); + + return nonZero.map(b => { + const meta = metadataCache.get(metadataCacheKey(chainId, b.contractAddress)) ?? { + decimals: null, + logo: null, + name: null, + symbol: null, + }; + const decimalsNum = meta.decimals ?? 18; + const value = BigInt(b.tokenBalance ?? '0x0').toString(); + return { + token: { + address: b.contractAddress, + address_hash: b.contractAddress, + decimals: String(decimalsNum), + name: meta.name ?? '', + symbol: meta.symbol ?? '', + type: TokenType.ERC20, + icon_url: meta.logo ?? undefined, + exchange_rate: undefined, + }, + token_id: null, + token_instance: null, + value, + }; + }); +}; + +/** + * Fetch token transfers for an address from Alchemy and map into the existing + * `BlockscoutTransactions` shape. + */ +export const fetchAlchemyTokenTransfers = async ({ + chainId, + address, + token, + filter = 'to', +}: { + chainId: number; + address: string; + token?: string; + filter?: 'from' | 'to'; +}): Promise => { + const category: AlchemyTransferCategory[] = token ? ['erc20'] : ['erc20', 'external']; + + const baseParams: Record = { + category, + excludeZeroValue: true, + order: 'desc', + withMetadata: true, + maxCount: '0x64', // 100 + }; + if (filter === 'from') baseParams.fromAddress = address; + else baseParams.toAddress = address; + if (token) baseParams.contractAddresses = [token]; + + const result = await jsonRpc(chainId, 'alchemy_getAssetTransfers', [ + baseParams, + ]); + + // Resolve metadata for any ERC-20 contracts in the batch. + const erc20Addrs = Array.from( + new Set( + result.transfers + .filter(t => t.category === 'erc20') + .map(t => t.rawContract.address?.toLowerCase()) + .filter((a): a is string => !!a), + ), + ); + if (erc20Addrs.length) { + await alchemyGetTokenMetadataBatch(chainId, erc20Addrs); + } + + const items: BlockscoutTransaction[] = result.transfers.map(t => { + const contractAddr = t.rawContract.address ?? ''; + const meta = contractAddr + ? metadataCache.get(metadataCacheKey(chainId, contractAddr)) + : undefined; + const decimals = + meta?.decimals ?? (t.rawContract.decimal ? parseInt(t.rawContract.decimal, 16) : 18); + return { + to: { + hash: (t.to ?? '') as `0x${string}`, + name: '', + }, + token: { + address: (contractAddr || '0x0000000000000000000000000000000000000000') as `0x${string}`, + symbol: meta?.symbol ?? t.asset ?? '', + icon_url: meta?.logo ?? '', + }, + total: { + decimals: String(decimals), + value: t.rawContract.value ? BigInt(t.rawContract.value).toString() : '0', + }, + transaction_hash: t.hash, + timestamp: t.metadata.blockTimestamp, + type: t.category === 'external' ? 'coin_transfer' : 'token_transfer', + }; + }); + + return { items }; +}; diff --git a/lib/api.ts b/lib/api.ts index 4877f7aee..2346fb0a0 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -3,8 +3,8 @@ import * as Sentry from '@sentry/react-native'; import axios, { AxiosRequestHeaders } from 'axios'; import { fuse } from 'viem/chains'; -import { explorerUrls } from '@/constants/explorers'; import { MOCK_REWARDS_USER_DATA, MOCK_TIER_BENEFITS } from '@/constants/rewards'; +import { fetchTokenTransferWithFallback } from '@/lib/data-source'; import { BridgeApiTransfer } from '@/lib/types/bank-transfer'; import { useUserStore } from '@/store/useUserStore'; @@ -24,8 +24,9 @@ import { ActivityEvents, AddressBookRequest, AddressBookResponse, + AgentApiKeySummary, + AgentSummary, APYsByAsset, - BlockscoutTransactions, BridgeCustomerEndorsement, BridgeCustomerResponse, BridgeDeposit, @@ -59,6 +60,7 @@ import { ExtensionCardsResponse, FromCurrency, FullRewardsConfig, + GenerateAgentApiKeyResponse, GetLifiQuoteParams, HistoricalAPYPoint, HoldingFundsPointsMultiplierConfig, @@ -74,8 +76,11 @@ import { MppCredentialsResponse, Points, PromotionsBannerResponse, + ProvisioningActivity, + ProvisioningInitResponse, ProvisioningSessionRequest, ProvisioningSessionResponse, + ProvisioningStepInput, RainConsumerType, RainContractResponseDto, RainKycSubmitResponse, @@ -340,28 +345,25 @@ export const fetchTotalAPY = async (): Promise => { export const fetchTokenTransfer = async ({ address, + chainId = fuse.id, token, - type = 'ERC-20', filter = 'to', - explorerUrl = explorerUrls[fuse.id].blockscout, + explorerUrl, }: { address: string; + chainId?: number; token?: string; - type?: string; - filter?: string; + filter?: 'from' | 'to'; + /** Optional override for the Blockscout explorer URL (used on fallback). */ explorerUrl?: string; }) => { - let url = `${explorerUrl}/api/v2/addresses/${address}/token-transfers`; - let params = []; - - if (type) params.push(`type=${type}`); - if (filter) params.push(`filter=${filter}`); - if (token) params.push(`token=${token}`); - - if (params.length) url += `?${params.join('&')}`; - - const response = await axios.get(url); - return response.data; + return fetchTokenTransferWithFallback({ + chainId, + address, + token, + filter, + blockscoutExplorerUrl: explorerUrl, + }); }; export const fetchTokenPriceUsd = async (token: string) => { @@ -499,6 +501,35 @@ export const personaSimulateAction = async ( return response.json(); }; +/** + * Card-activation consents collected on /card/ready. + * Stored in MongoDB (rainKycAgreements) for compliance retention; not forwarded to Rain. + */ +export const submitCardConsents = async (consents: { + agreedToEsign: boolean; + agreedToAccountOpeningPrivacy: boolean; + isTermsOfServiceAccepted: boolean; + agreedToCertify: boolean; + agreedToNoSolicitation: boolean; +}): Promise<{ id: string; createdAt: string }> => { + const jwt = getJWTToken(); + + const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/cards/kyc/consents`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + body: JSON.stringify(consents), + }); + + if (!response.ok) throw response; + + return response.json(); +}; + /** Rain KYC (in-house): single multipart POST with application fields + document files. Backend creates Rain application then uploads docs. */ export const submitRainKyc = async (formData: FormData): Promise => { const jwt = getJWTToken(); @@ -2217,47 +2248,15 @@ export const revealCardDetailsCompleteRain = async (): Promise => { - if (provider === CardProvider.RAIN) { - return revealCardDetailsCompleteRain(); - } - if (provider === CardProvider.BRIDGE) { - return revealCardDetailsCompleteBridge(); - } - - // Fallback when provider is unknown: try Rain if PEM configured, else Bridge - if (EXPO_PUBLIC_RAIN_CARD_PUBLIC_KEY_PEM) { - try { - return await revealCardDetailsCompleteRain(); - } catch (e: unknown) { - if (e instanceof Response && e.status === 400) { - return revealCardDetailsCompleteBridge(); - } - throw e; - } - } - return revealCardDetailsCompleteBridge(); + return revealCardDetailsCompleteRain(); }; -function revealCardDetailsCompleteBridge(): Promise { - return (async () => { - const nonceData = await generateClientNonceData(); - const ephemeralKeyResponse = await requestEphemeralKey(nonceData.nonce); - return revealCardDetails( - ephemeralKeyResponse.ephemeral_key, - nonceData.clientSecret, - nonceData.clientTimestamp, - ); - })(); -} - export const fetchAPYs = async (): Promise => { const response = await axios.get( `${EXPO_PUBLIC_FLASH_ANALYTICS_API_BASE_URL}/analytics/v1/bigquery-metrics/apys`, @@ -2514,6 +2513,107 @@ export const fetchTokenList = async (params: SwapTokenRequest) => { return response.data; }; +// ===================================================================== +// Agent Wallet +// ===================================================================== + +const agentEndpoint = (path: string) => + `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/agents${path}`; + +const agentJsonHeaders = () => { + const jwt = getJWTToken(); + return { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }; +}; + +const postAgentJson = async (path: string, body?: unknown): Promise => { + const response = await fetch(agentEndpoint(path), { + method: 'POST', + headers: agentJsonHeaders(), + credentials: 'include', + body: body === undefined ? undefined : JSON.stringify(body), + }); + if (!response.ok) throw response; + return response.json(); +}; + +export const provisionAgentInit = (): Promise => + postAgentJson('/provision/init'); + +export const provisionAgentWalletAccount = ( + input: ProvisioningStepInput, +): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/wallet-account', input); + +export const provisionAgentUser = ( + input: ProvisioningStepInput, +): Promise<{ activity: ProvisioningActivity }> => postAgentJson('/provision/user', input); + +export const provisionAgentPolicy = ( + input: ProvisioningStepInput, +): Promise<{ agentEoaAddress: string }> => postAgentJson('/provision/policy', input); + +export const fetchAgent = async (): Promise => { + const response = await fetch(agentEndpoint('/me'), { + method: 'GET', + headers: agentJsonHeaders(), + credentials: 'include', + }); + if (!response.ok) throw response; + return response.json(); +}; + +/** + * Returns true iff the user has at least one successful AGENT_WALLET_DEPOSIT + * activity. Cached on the UI side to avoid re-querying on every render. + */ +export const fetchAgentHasDeposited = async (): Promise => { + const jwt = getJWTToken(); + const url = `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/activity?scope=agent&type=agent_wallet_deposit&status=success&limit=1`; + const response = await fetch(url, { + headers: { + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + }); + if (!response.ok) throw response; + const json = (await response.json()) as { totalDocs?: number; docs?: unknown[] }; + return (json.totalDocs ?? json.docs?.length ?? 0) > 0; +}; + +export const fetchAgentApiKeys = async (): Promise => { + const response = await fetch(agentEndpoint('/me/api-keys'), { + method: 'GET', + headers: agentJsonHeaders(), + credentials: 'include', + }); + if (!response.ok) throw response; + return response.json(); +}; + +export const generateAgentApiKey = async (name?: string): Promise => { + const response = await fetch(agentEndpoint('/me/api-keys'), { + method: 'POST', + headers: agentJsonHeaders(), + credentials: 'include', + body: JSON.stringify({ name }), + }); + if (!response.ok) throw response; + return response.json(); +}; + +export const revokeAgentApiKey = async (id: string): Promise => { + const response = await fetch(agentEndpoint(`/me/api-keys/${id}`), { + method: 'DELETE', + headers: agentJsonHeaders(), + credentials: 'include', + }); + if (!response.ok) throw response; +}; + export const fetchAddressBook = async (): Promise => { const jwt = getJWTToken(); const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/address-book`, { diff --git a/lib/assets.ts b/lib/assets.ts index d9efccb55..49a2ee731 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -152,7 +152,7 @@ export const ASSETS = { }, 'images/cards-desktop.png': { module: require('@/assets/images/cards-desktop.png'), - hash: 'ae26473b', + hash: 'b0fc2da8', }, 'images/cards-mobile.png': { module: require('@/assets/images/cards-mobile.png'), @@ -194,6 +194,10 @@ export const ASSETS = { 'images/diamond.png': { module: require('@/assets/images/diamond.png'), hash: '9875c4f5' }, 'images/diamond.tsx': { module: require('@/assets/images/diamond.tsx'), hash: '6654209f' }, 'images/docs.tsx': { module: require('@/assets/images/docs.tsx'), hash: '458b69fc' }, + 'images/dollar-green.png': { + module: require('@/assets/images/dollar-green.png'), + hash: '97437332', + }, 'images/dollar-lavender.png': { module: require('@/assets/images/dollar-lavender.png'), hash: '23534784', @@ -283,6 +287,10 @@ export const ASSETS = { module: require('@/assets/images/gbp-fiat-currency.tsx'), hash: '9315a113', }, + 'images/globe-green.png': { + module: require('@/assets/images/globe-green.png'), + hash: 'ba254b95', + }, 'images/google_pay.png': { module: require('@/assets/images/google_pay.png'), hash: '78c91f8f' }, 'images/gray_onboarding_bg.png': { module: require('@/assets/images/gray_onboarding_bg.png'), @@ -552,6 +560,7 @@ export const ASSETS = { hash: '14067288', }, 'images/star-gold.png': { module: require('@/assets/images/star-gold.png'), hash: 'e96ddea2' }, + 'images/star-green.png': { module: require('@/assets/images/star-green.png'), hash: 'c21e4f3c' }, 'images/star-silver.png': { module: require('@/assets/images/star-silver.png'), hash: '3c3d13e1', diff --git a/lib/data-source.ts b/lib/data-source.ts new file mode 100644 index 000000000..8f3461ccc --- /dev/null +++ b/lib/data-source.ts @@ -0,0 +1,119 @@ +import axios from 'axios'; +import { arbitrum, base, fuse, mainnet, polygon } from 'viem/chains'; + +import { isAlchemyChain } from '@/constants/alchemy'; +import { explorerUrls } from '@/constants/explorers'; +import { fetchAlchemyTokenBalances, fetchAlchemyTokenTransfers } from '@/lib/alchemy'; +import { BlockscoutTransactions } from '@/lib/types'; + +import type { BlockscoutTokenBalance } from '@/hooks/useBalances'; + +/** + * Dispatcher: tries Alchemy first, falls back to Blockscout on failure. + * Fuse (122) is always Blockscout (not supported by Alchemy). + */ + +const BLOCKSCOUT_URLS: Record = { + [mainnet.id]: 'https://eth.blockscout.com', + [base.id]: 'https://base.blockscout.com', + [polygon.id]: 'https://polygon.blockscout.com', + [arbitrum.id]: 'https://arbitrum.blockscout.com', + [fuse.id]: explorerUrls[fuse.id]?.blockscout ?? 'https://explorer.fuse.io', +}; + +const blockscoutUrlForChain = (chainId: number): string | undefined => BLOCKSCOUT_URLS[chainId]; + +const fetchBlockscoutTokenBalances = async ( + chainId: number, + address: string, +): Promise => { + const url = blockscoutUrlForChain(chainId); + if (!url) return []; + const response = await fetch(`${url}/api/v2/addresses/${address}/token-balances`, { + headers: { accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`Blockscout token-balances ${response.status} for chain ${chainId}`); + } + return (await response.json()) as BlockscoutTokenBalance[]; +}; + +const fetchBlockscoutTokenTransfers = async ({ + chainId, + address, + token, + filter = 'to', + explorerUrl, +}: { + chainId: number; + address: string; + token?: string; + filter?: 'from' | 'to'; + explorerUrl?: string; +}): Promise => { + const url = explorerUrl ?? blockscoutUrlForChain(chainId) ?? BLOCKSCOUT_URLS[fuse.id]; + const params: string[] = ['type=ERC-20']; + if (filter) params.push(`filter=${filter}`); + if (token) params.push(`token=${token}`); + const response = await axios.get( + `${url}/api/v2/addresses/${address}/token-transfers?${params.join('&')}`, + ); + return response.data; +}; + +export const fetchTokenBalancesWithFallback = async ( + chainId: number, + address: string, +): Promise => { + if (!isAlchemyChain(chainId)) { + return fetchBlockscoutTokenBalances(chainId, address); + } + try { + return await fetchAlchemyTokenBalances(chainId, address); + } catch (err) { + console.warn( + `[data-source] alchemy balances failed for chain ${chainId}, falling back to blockscout`, + err, + ); + return fetchBlockscoutTokenBalances(chainId, address); + } +}; + +export const fetchTokenTransferWithFallback = async ({ + chainId, + address, + token, + filter = 'to', + blockscoutExplorerUrl, +}: { + chainId: number; + address: string; + token?: string; + filter?: 'from' | 'to'; + blockscoutExplorerUrl?: string; +}): Promise => { + if (!isAlchemyChain(chainId)) { + return fetchBlockscoutTokenTransfers({ + chainId, + address, + token, + filter, + explorerUrl: blockscoutExplorerUrl, + }); + } + try { + return await fetchAlchemyTokenTransfers({ chainId, address, token, filter }); + } catch (err) { + console.warn( + `[data-source] alchemy transfers failed for chain ${chainId}, falling back to blockscout`, + err, + ); + return fetchBlockscoutTokenTransfers({ + chainId, + address, + token, + filter, + explorerUrl: blockscoutExplorerUrl, + }); + } +}; diff --git a/lib/types.ts b/lib/types.ts index 71ca785c7..4702e0e4e 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -435,6 +435,22 @@ export interface CardDetailsResponseDto extends CardResponse { provider?: CardProvider; } +/** + * A single warning entry surfaced for a user's KYC. Mirrors Didit's per-block warning shape: + * `risk` is the tag (DOCUMENT_EXPIRED, DATE_OF_BIRTH_NOT_DETECTED, ...) — same key space as + * DIDIT_WARNING_DESCRIPTIONS overrides; `short_description` / `long_description` are Didit's + * pre-formatted user-facing copy. Backend also synthesises one of these (with + * `risk: 'CARD_ACTIVATION_FAILED'`) when Rain rejects the forwarded application. + */ +export interface KycWarning { + risk: string; + log_type?: string; + short_description?: string; + long_description?: string; + feature?: string; + node_id?: string; +} + export interface CardStatusResponse { status?: CardStatus; activationBlocked?: boolean; @@ -444,8 +460,8 @@ export interface CardStatusResponse { provider?: CardProvider; /** Internal KYC status (covers Didit rejection before Rain is reached) */ kycStatus?: KycStatus; - /** Warning tags or reasons from Didit verification (e.g. DOCUMENT_EXPIRED). */ - kycWarnings?: string[]; + /** Warning entries from Didit verification (e.g. DOCUMENT_EXPIRED) and Rain forward failures. */ + kycWarnings?: KycWarning[]; /** Rain KYC: application status from Rain */ rainApplicationStatus?: RainApplicationStatus; /** Rain: link for needsVerification redirect */ @@ -657,6 +673,7 @@ export enum TransactionType { CANCEL_WITHDRAW = 'cancel_withdraw', BRIDGE_DEPOSIT = 'bridge_deposit', BORROW_AND_DEPOSIT_TO_CARD = 'borrow_and_deposit_to_card', + CARD_DEPOSIT = 'card_deposit', BRIDGE_TRANSFER = 'bridge_transfer', BANK_TRANSFER = 'bank_transfer', CARD_TRANSACTION = 'card_transaction', @@ -672,6 +689,8 @@ export enum TransactionType { FAST_WITHDRAW = 'fast_withdraw', REPAY_AND_WITHDRAW_COLLATERAL = 'repay_and_withdraw_collateral', WITHDRAW_COLLATERAL = 'withdraw_collateral', + AGENT_X402_PAYMENT = 'agent_x402_payment', + AGENT_WALLET_DEPOSIT = 'agent_wallet_deposit', } export enum TransactionDirection { @@ -791,6 +810,7 @@ export type BridgeDeposit = { deadline: number; }; trackingId?: string; + category?: DepositCategory; }; export type BridgeTransactionRequest = { @@ -814,8 +834,14 @@ export type Deposit = { }; trackingId?: string; vault?: VaultType; + category?: DepositCategory; }; +export enum DepositCategory { + SAVINGS = 'SAVINGS', + CARD = 'CARD', +} + export enum DepositTransactionStatus { PENDING = 'pending', FAILED = 'failed', @@ -923,12 +949,15 @@ export interface Cashback { fuseUsdPrice?: string; fiatAmount?: string; fiatCurrency?: string; + payoutAt?: string; createdAt: string; } export interface CashbackInfo { amount: string; isPending: boolean; + isEscrowed: boolean; + payoutAt?: string; } export interface SourceDepositInstructions { @@ -1244,6 +1273,7 @@ export interface CardTransaction { merchant_city?: string; merchant_country?: string; local_transaction_details?: LocalTransactionDetails; + declined_reason?: string; } export interface CardTransactionsResponse { @@ -1519,6 +1549,54 @@ export interface AddressBookResponse { skipped2faAt?: Date; } +export type AgentSummary = { + agentEoaAddress?: string; +}; + +export type AgentApiKeySummary = { + id: string; + prefix: string; + name?: string; + createdAt: string; + lastUsedAt?: string; + revokedAt?: string; +}; + +export type GenerateAgentApiKeyResponse = AgentApiKeySummary & { key: string }; + +/** + * Envelope returned by the Turnkey SDK's `stampX` methods. `body` is the + * exact stringified bytes the SDK signed — we MUST forward it verbatim; + * re-serializing on the server changes key order and breaks the stamp. + */ +export type SignedTurnkeyRequest = { + url: string; + body: string; + stamp: { stampHeaderName: string; stampHeaderValue: string }; +}; + +export type ProvisioningActivity = { + url: string; + body: Record; +}; + +export type ProvisioningInitResponse = { + provisioningId: string; + subOrganizationId: string; + /** + * Set when the agent's wallet path was already derived in Turnkey from a + * prior failed provisioning attempt. The `activity` in this case is the + * createUsers body — the client should skip the wallet-account stamp. + */ + agentEoaAddress?: string; + activity: ProvisioningActivity; +}; + +export type ProvisioningStepInput = { + provisioningId: string; + signed: SignedTurnkeyRequest; +}; + export interface WhatsNewStep { imageUrl: string; title: string; diff --git a/lib/utils/borrowAndBridge.ts b/lib/utils/borrowAndBridge.ts new file mode 100644 index 000000000..8543fb29b --- /dev/null +++ b/lib/utils/borrowAndBridge.ts @@ -0,0 +1,256 @@ +import * as Sentry from '@sentry/react-native'; +import { Address } from 'abitype'; +import { Chain, erc20Abi, pad, TransactionReceipt } from 'viem'; +import { readContract } from 'viem/actions'; +import { fuse, mainnet } from 'viem/chains'; +import { encodeFunctionData, parseUnits } from 'viem/utils'; + +import { USDC_STARGATE } from '@/constants/addresses'; +import { useActivityActions } from '@/hooks/useActivityActions'; +import { AaveV3Pool_ABI } from '@/lib/abis/AaveV3Pool'; +import BridgePayamster_ABI from '@/lib/abis/BridgePayamster'; +import { CardDepositManager_ABI } from '@/lib/abis/CardDepositManager'; +import { ADDRESSES } from '@/lib/config'; +import { executeTransactions, USER_CANCELLED_TRANSACTION } from '@/lib/execute'; +import { StargateQuoteParams, TransactionType } from '@/lib/types'; +import { getStargateChainId, getStargateQuote } from '@/lib/utils/stargate'; +import { publicClient } from '@/lib/wagmi'; + +import type { SmartAccountClient } from 'permissionless'; + +// EIP-3009 / Aave LTV — keep one source of truth shared by every flow that +// borrows USDC against soUSD on Fuse and bridges via Stargate to a chosen +// destination address. +const SO_USD_LTV = 70n; + +// AccountantWithRateProviders.getRate() — shared between card + agent flows. +const ACCOUNTANT_ABI = [ + { + inputs: [], + name: 'getRate', + outputs: [{ internalType: 'uint256', name: 'rate', type: 'uint256' }], + stateMutability: 'view', + type: 'function', + }, +] as const; + +export interface BorrowAndBridgeUser { + safeAddress: string; + suborgId: string; + signWith: string; + userId: string; +} + +export interface BorrowAndBridgeParams { + /** Connected user (must have safeAddress + AA signer context). */ + user: BorrowAndBridgeUser; + /** Receiver of bridged USDC on the destination chain. */ + destinationAddress: Address; + /** EVM chain id of the destination (e.g. base.id, arbitrum.id). */ + destinationChainId: number; + /** Stargate's chain key for the destination ('base', 'arbitrum', ...). */ + destinationChainKey: string; + /** USDC contract on the destination chain. */ + destinationToken: Address; + /** Borrow amount as a human-readable USDC string (e.g. '10.5'). */ + amountToBorrow: string; + /** AA signer factory used by the card flow (`useUser().safeAA`). */ + safeAA: (chain: Chain, suborgId: string, signWith: string) => Promise; + /** Activity tracking — wires receipt + status into the in-app feed. */ + trackTransaction: ReturnType['trackTransaction']; + /** Activity payload metadata. */ + activityType: TransactionType; + activityTitle: string; + /** Optional Sentry/analytics breadcrumb tag (purely cosmetic). */ + flowTag?: string; +} + +/** + * Core "borrow USDC.e against soUSD on Fuse → Stargate-bridge to a + * destination" flow used by both the card-funding and agent-wallet + * deposit paths. The CardDepositManager is destination-agnostic (the + * receiver allowlist is gated by `isWhitelistEnabled` which is off in + * prod), so the same on-chain plumbing handles both. + */ +export async function executeBorrowAndBridge( + params: BorrowAndBridgeParams, +): Promise { + const { + user, + destinationAddress, + destinationChainId, + destinationChainKey, + destinationToken, + amountToBorrow, + safeAA, + trackTransaction, + activityType, + activityTitle, + flowTag = 'borrow_and_bridge', + } = params; + + const rate = await readContract(publicClient(mainnet.id), { + address: ADDRESSES.ethereum.accountant, + abi: ACCOUNTANT_ABI, + functionName: 'getRate', + }); + + const borrowAmountWei = parseUnits(amountToBorrow, 6); + const supplyAmountWei = (borrowAmountWei * 100n * 1000000n) / (SO_USD_LTV * rate); + + const supplyApproveCalldata = encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [ADDRESSES.fuse.aaveV3Pool, supplyAmountWei], + }); + + const supplyCalldata = encodeFunctionData({ + abi: AaveV3Pool_ABI, + functionName: 'supply', + args: [ADDRESSES.fuse.vault, supplyAmountWei, user.safeAddress as Address, 0], + }); + + const borrowCalldata = encodeFunctionData({ + abi: AaveV3Pool_ABI, + functionName: 'borrow', + args: [USDC_STARGATE, borrowAmountWei, 2, 0, user.safeAddress as Address], + }); + + Sentry.addBreadcrumb({ + message: `Starting ${flowTag} transaction`, + category: 'bridge', + data: { + amount: amountToBorrow, + amountWei: borrowAmountWei.toString(), + userAddress: user.safeAddress, + destinationAddress, + destinationChainId, + chainId: fuse.id, + }, + }); + + // 5% slippage envelope on the destination amount. + const dstAmountMin = (borrowAmountWei * 95n) / 100n; + + const quoteParams: StargateQuoteParams = { + srcToken: USDC_STARGATE, + srcChainKey: 'fuse', + dstToken: destinationToken, + dstChainKey: destinationChainKey, + srcAddress: ADDRESSES.fuse.bridgePaymasterAddress, + dstAddress: destinationAddress, + srcAmount: borrowAmountWei.toString(), + dstAmountMin: dstAmountMin.toString(), + }; + const quote = await getStargateQuote(quoteParams); + const taxiQuote = quote.quotes.find(q => q.route.includes('taxi')); + if (!taxiQuote) throw new Error('Taxi route not available from Stargate'); + if (taxiQuote.error) throw new Error(`Stargate quote error: ${taxiQuote.error}`); + + const bridgeStep = taxiQuote.steps.find(step => step.type === 'bridge'); + if (!bridgeStep) throw new Error('No bridge step found in Stargate quote'); + + const { transaction } = bridgeStep; + const nativeFeeAmount = BigInt(transaction.value); + + const sendParam = { + dstEid: getStargateChainId(destinationChainId) as number, + to: pad(destinationAddress, { size: 32 }), + amountLD: borrowAmountWei, + minAmountLD: dstAmountMin, + extraOptions: '0x' as `0x${string}`, + composeMsg: '0x' as `0x${string}`, + oftCmd: '0x' as `0x${string}`, + }; + + const calldata = encodeFunctionData({ + abi: CardDepositManager_ABI, + functionName: 'depositUsingStargate', + args: [ + transaction.to as Address, + user.safeAddress as Address, + sendParam, + nativeFeeAmount, + ADDRESSES.fuse.bridgePaymasterAddress, + ], + }); + + const transactions = [ + { + to: ADDRESSES.fuse.vault, + data: supplyApproveCalldata, + value: 0n, + }, + { + to: ADDRESSES.fuse.aaveV3Pool, + data: supplyCalldata, + value: 0n, + }, + { + to: ADDRESSES.fuse.aaveV3Pool, + data: borrowCalldata, + value: 0n, + }, + // Approve USDC.e from Safe to CardDepositManager (manager is destination-agnostic). + { + to: USDC_STARGATE, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'approve', + args: [ADDRESSES.fuse.cardDepositManager, borrowAmountWei], + }), + value: 0n, + }, + // Forward the LZ native fee from BridgePaymaster (which is sponsored + // for the depositUsingStargate selector) and let the manager call + // Stargate's send(). + { + to: ADDRESSES.fuse.bridgePaymasterAddress, + data: encodeFunctionData({ + abi: BridgePayamster_ABI, + functionName: 'callWithValue', + args: [ + ADDRESSES.fuse.cardDepositManager, + '0x37fe667d', // depositUsingStargate selector + calldata, + nativeFeeAmount, + ], + }), + value: 0n, + }, + ]; + + const smartAccountClient = await safeAA(fuse, user.suborgId, user.signWith); + + const result = await trackTransaction( + { + type: activityType, + title: activityTitle, + shortTitle: activityTitle, + amount: amountToBorrow, + symbol: 'USDC.e', + chainId: fuse.id, + fromAddress: user.safeAddress, + toAddress: destinationAddress, + metadata: { + description: `${activityTitle} ${amountToBorrow} USDC from Fuse to ${destinationAddress} on chain ${destinationChainId}`, + fee: transaction.value, + sourceSymbol: 'USDC.e', + tokenAddress: USDC_STARGATE, + }, + }, + onUserOpHash => + executeTransactions( + smartAccountClient, + transactions, + `${activityTitle} failed`, + fuse, + onUserOpHash, + ), + ); + + const transactionResult = + result && typeof result === 'object' && 'transaction' in result ? result.transaction : result; + + return transactionResult as TransactionReceipt | typeof USER_CANCELLED_TRANSACTION; +} diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts index ae889c20b..c4f55e7f9 100644 --- a/lib/utils/cardHelpers.ts +++ b/lib/utils/cardHelpers.ts @@ -117,12 +117,15 @@ export const getCashbackAmount = ( } const isPending = PENDING_CASHBACK_STATUSES.includes(cashback.status); + const isEscrowed = cashback.status === CashbackStatus.Escrowed; // For pending cashbacks without fuseAmount yet, show pending indicator without amount if (!cashback.fuseAmount) { return { amount: 'Pending', isPending: true, + isEscrowed, + payoutAt: cashback.payoutAt, }; } @@ -137,5 +140,7 @@ export const getCashbackAmount = ( return { amount: `+$${amount.toFixed(2)}`, isPending, + isEscrowed, + payoutAt: cashback.payoutAt, }; }; diff --git a/lib/utils/utils.ts b/lib/utils/utils.ts index 69388df19..8ba4b9529 100644 --- a/lib/utils/utils.ts +++ b/lib/utils/utils.ts @@ -333,12 +333,15 @@ export const parseStampHeaderValueCredentialId = (stampHeaderValue: string) => { export const getArbitrumFundingAddress = (cardDetails: CardResponse) => { const ARBITRUM_CHAIN = 'arbitrum'; - if (cardDetails?.funding_instructions?.chain === ARBITRUM_CHAIN) { - return cardDetails?.funding_instructions?.address; + if ( + cardDetails?.funding_instructions?.chain === ARBITRUM_CHAIN && + cardDetails?.funding_instructions?.address + ) { + return cardDetails.funding_instructions.address; } return cardDetails?.additional_funding_instructions?.find( - instruction => instruction.chain === ARBITRUM_CHAIN, + instruction => instruction.chain === ARBITRUM_CHAIN && instruction.address, )?.address; }; @@ -370,9 +373,12 @@ export function getCardFundingAddress( provider: CardProvider | null | undefined, contracts: RainContractResponseDto[] | null | undefined, ): string | undefined { - if (provider === CardProvider.RAIN && contracts?.length) { - const rainContract = contracts.find(c => c.chainId === EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID); - if (rainContract?.depositAddress) return rainContract.depositAddress; + if (provider === CardProvider.RAIN) { + if (!contracts?.length) return undefined; + const rainContract = contracts.find( + c => Number(c.chainId) === EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, + ); + return rainContract?.depositAddress || undefined; } return cardDetails ? getArbitrumFundingAddress(cardDetails) : undefined; } diff --git a/package.json b/package.json index aebe5d499..ea7c4b307 100644 --- a/package.json +++ b/package.json @@ -226,6 +226,5 @@ } } }, - "private": true, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "private": true } diff --git a/store/useUserStore.ts b/store/useUserStore.ts index 2e5512f2c..178d3178d 100644 --- a/store/useUserStore.ts +++ b/store/useUserStore.ts @@ -13,6 +13,12 @@ interface UserState { signupUser: SignupUser; safeAddressSynced: Record; redirectFrom: string | null; + /** + * userId awaiting passkey authentication after the welcome-page user + * selection. Scoped to a single session — survives the TurnkeyProvider + * re-mount triggered by credentialId changes but is not persisted. + */ + pendingAuthUserId: string | null; _hasHydrated: boolean; storeUser: (user: User) => void; updateUser: (user: User) => void; @@ -24,13 +30,14 @@ interface UserState { setSignupUser: (user: SignupUser) => void; markSafeAddressSynced: (userId: string) => void; setRedirectFrom: (path: string | null) => void; + setPendingAuthUserId: (userId: string | null) => void; setHasHydrated: (state: boolean) => void; } // Selectors - pure functions for deriving state // These can be used with useUserStore(selector) for optimal re-render behavior -/** Get the currently selected user, or the only user if there's just one */ +/** Get the currently selected user */ export const selectSelectedUser = ({ users }: UserState): User | undefined => users.find(u => u.selected); @@ -48,6 +55,7 @@ export const useUserStore = create()( signupUser: { username: '' }, safeAddressSynced: {}, redirectFrom: null, + pendingAuthUserId: null, _hasHydrated: false, setHasHydrated: (state: boolean) => set({ _hasHydrated: state }), @@ -124,6 +132,8 @@ export const useUserStore = create()( ), setRedirectFrom: (path: string | null) => set({ redirectFrom: path }), + + setPendingAuthUserId: (userId: string | null) => set({ pendingAuthUserId: userId }), }), { name: USER.storageKey, @@ -132,7 +142,7 @@ export const useUserStore = create()( state?.setHasHydrated(true); }, partialize: state => { - const { redirectFrom, ...rest } = state; + const { redirectFrom, pendingAuthUserId, ...rest } = state; return rest; }, },