diff --git a/app.config.ts b/app.config.ts index 98bcc7667..110feedbf 100644 --- a/app.config.ts +++ b/app.config.ts @@ -43,7 +43,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({ ...config, name: 'Solid', slug: 'flash-frontend', - version: '1.0.6', + version: '1.0.8', orientation: 'portrait', icon: './assets/images/adaptive-icon.png', scheme: 'solid', diff --git a/app/(protected)/(tabs)/activity/[clientTxId].tsx b/app/(protected)/(tabs)/activity/[clientTxId].tsx index 9ae6c3480..5f8d85336 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,11 +13,12 @@ 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'; import { path } from '@/constants/path'; -import { TRANSACTION_DETAILS } from '@/constants/transaction'; +import { getTransactionCategory, TRANSACTION_DETAILS } from '@/constants/transaction'; import { useActivity } from '@/hooks/useActivity'; import useCancelOnchainWithdraw from '@/hooks/useCancelOnchainWithdraw'; import { useCardProvider } from '@/hooks/useCardProvider'; @@ -42,6 +43,8 @@ import { getColorForTransaction, getInitials, } from '@/lib/utils/cardHelpers'; +import { resolveCardDepositTransferTx } from '@/lib/utils/deduplicateTransactions'; +import { getChain } from '@/lib/wagmi'; type RowProps = { label: React.ReactNode; @@ -92,6 +95,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 +124,11 @@ const Back = memo(function Back({ title, className }: BackProps) { }, [params.from, params.tab, router]); return ( - - - - + + + + {title} - ); }); @@ -162,15 +179,18 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ activity, cardProvider, }: CardTransactionDetailProps) { - const merchantName = transaction.merchant_name || transaction.description || 'Unknown'; - const merchantLocation = [transaction.merchant_city, transaction.merchant_country] - .filter(Boolean) - .join(' ') || undefined; + const merchantName = + transaction.merchant_name?.trim() || transaction.description?.trim() || 'Unknown'; + const merchantLocation = + [transaction.merchant_city, transaction.merchant_country].filter(Boolean).join(' ') || + undefined; const isPurchase = transaction.category === CardTransactionCategory.PURCHASE; const { data: cashbacks } = useCashbacks(); 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 +213,33 @@ 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 +252,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 +287,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 +308,7 @@ const CardTransactionDetail = memo(function CardTransactionDetail({ {merchantLocation && ( - {merchantLocation} + {merchantLocation} )} @@ -442,14 +497,9 @@ export default function ActivityDetail() { if (isDeposit && finalActivity?.status === TransactionStatus.SUCCESS) { return 'Complete'; } - return transactionDetails?.category ?? 'Unknown'; - }, [ - finalActivity?.type, - finalActivity?.status, - finalActivity?.metadata?.destination, - isDeposit, - transactionDetails?.category, - ]); + if (!finalActivity) return 'Unknown'; + return getTransactionCategory(finalActivity.type, finalActivity.title) ?? 'Unknown'; + }, [finalActivity, isDeposit]); const tokenIcon = useMemo( () => (finalActivity ? getTokenIcon({ tokenSymbol: finalActivity.symbol, size: 75 }) : null), @@ -461,14 +511,32 @@ export default function ActivityDetail() { await cancelOnchainWithdraw(finalActivity.requestId); }, [isCancelWithdraw, finalActivity?.requestId, cancelOnchainWithdraw]); + // Resolve the tx to show in the Explorer row. For card deposits, prefer the + // sibling on-chain USDC transfer (indexed by the blockscout sync as a Send) + // over the activity's own hash, which for connect-wallet deposits is the + // approve userOp rather than the actual transfer. Derive the url from + // hash + chain when the matched tx has none (the card_deposit row stores a + // hash but no url, so otherwise the Explorer row stayed hidden). + const explorerTx = useMemo((): { hash?: string; url?: string } => { + if (!finalActivity) return {}; + const transfer = resolveCardDepositTransferTx(finalActivity, cachedActivities); + const hash = transfer?.hash ?? finalActivity.hash; + let url = transfer?.url ?? finalActivity.url; + if (!url && hash && finalActivity.chainId) { + const explorerBase = getChain(finalActivity.chainId)?.blockExplorers?.default?.url; + if (explorerBase) url = `${explorerBase}/tx/${hash}`; + } + return { hash, url }; + }, [finalActivity, cachedActivities]); + const handleExplorerPress = useCallback(() => { - if (finalActivity?.url) Linking.openURL(finalActivity.url); - }, [finalActivity?.url]); + if (explorerTx.url) Linking.openURL(explorerTx.url); + }, [explorerTx.url]); const rows = useMemo(() => { if (!finalActivity) return []; - const { fromAddress, toAddress, status, metadata, url, hash } = finalActivity; + const { fromAddress, toAddress, status, metadata } = finalActivity; return [ fromAddress && { @@ -516,15 +584,15 @@ export default function ActivityDetail() { ), }, - url && - hash && { + explorerTx.url && + explorerTx.hash && { key: 'explorer', label: , value: ( - {eclipseAddress(hash)} + {eclipseAddress(explorerTx.hash)} @@ -547,6 +615,8 @@ export default function ActivityDetail() { isDetected, isProcessing, currentTime, + explorerTx.url, + explorerTx.hash, handleExplorerPress, ]); 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..fa9f6eac3 100644 --- a/app/(protected)/(tabs)/card/details.tsx +++ b/app/(protected)/(tabs)/card/details.tsx @@ -1,38 +1,19 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - ActivityIndicator, - Alert, - Animated, - Linking, - Pressable, - StyleSheet, - View, -} from 'react-native'; +import { ActivityIndicator, Alert, Animated, Pressable, StyleSheet, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; 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, -} from 'lucide-react-native'; +import { useLocalSearchParams, useRouter } from 'expo-router'; +import { ChevronDown, ChevronRight, Copy, KeyRound, Plus, Settings } from 'lucide-react-native'; import AddToWalletModal from '@/components/Card/AddToWalletModal'; import { BorrowPositionCard } from '@/components/Card/BorrowPositionCard'; +import CardWelcomePopup from '@/components/Card/CardWelcomePopup'; 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'; @@ -43,21 +24,25 @@ import { DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Text } from '@/components/ui/text'; -import { CARD_DEPOSIT_MODAL } from '@/constants/modals'; -import { useCardDepositBonusConfig } from '@/hooks/useCardDepositBonusConfig'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardDetailsReveal } from '@/hooks/useCardDetailsReveal'; 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 { isProduction } from '@/lib/config'; import { CardHolderName, CardProvider, CardStatus, FreezeInitiator, KycStatus } from '@/lib/types'; -import { cn, withRefreshToken } from '@/lib/utils/utils'; -import { CardDepositSource, useCardDepositStore } from '@/store/useCardDepositStore'; +import { cn } from '@/lib/utils/utils'; +import { useCardWelcomePopupStore } from '@/store/useCardWelcomePopupStore'; export default function CardDetails() { const { data: cardDetails, isLoading, refetch } = useCardDetails(); @@ -72,17 +57,22 @@ 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 { state: debugState } = useLocalSearchParams<{ state?: string }>(); + const isDebugWelcome = !isProduction && debugState === 'welcome'; - const hasPhysicalCard = physicalCardStatusData?.hasPhysicalCard ?? false; + const storeShouldShowWelcomePopup = useCardWelcomePopupStore( + state => state.shouldShowWelcomePopup, + ); + const setShouldShowWelcomePopup = useCardWelcomePopupStore( + state => state.setShouldShowWelcomePopup, + ); + const shouldShowWelcomePopup = isDebugWelcome || storeShouldShowWelcomePopup; + const handleCloseWelcomePopup = useCallback( + () => setShouldShowWelcomePopup(false), + [setShouldShowWelcomePopup], + ); const availableBalance = cardDetails?.balances.available; const availableAmount = Number(availableBalance?.amount || '0').toString(); @@ -158,12 +148,6 @@ export default function CardDetails() { onFreezeToggle={handleFreezeToggle} isWithdrawFromCardAllowed={isWithdrawFromCardAllowed} isRain={provider === CardProvider.RAIN} - hasPhysicalCard={hasPhysicalCard} - onPhysicalCardPress={() => - hasPhysicalCard - ? setIsCancelPhysicalCardModalOpen(true) - : setIsOrderPhysicalCardModalOpen(true) - } /> ) : ( @@ -206,14 +190,9 @@ export default function CardDetails() { - {/* Row 3: Borrow Position Card + Deposit Bonus Banner */} - - - - - - - + {/* Row 3: Borrow Position Card */} + + @@ -222,16 +201,8 @@ export default function CardDetails() { onOpenChange={setIsAddToWalletModalOpen} trigger={null} /> - - + + ); } @@ -262,15 +233,8 @@ export default function CardDetails() { onFreezeToggle={handleFreezeToggle} isWithdrawFromCardAllowed={isWithdrawFromCardAllowed} isRain={provider === CardProvider.RAIN} - hasPhysicalCard={hasPhysicalCard} - onPhysicalCardPress={() => - hasPhysicalCard - ? setIsCancelPhysicalCardModalOpen(true) - : setIsOrderPhysicalCardModalOpen(true) - } /> - setIsAddToWalletModalOpen(true)} /> @@ -283,16 +247,8 @@ export default function CardDetails() { onOpenChange={setIsAddToWalletModalOpen} trigger={null} /> - - + + ); } @@ -311,8 +267,6 @@ interface DesktopHeaderProps { onFreezeToggle: () => Promise; isWithdrawFromCardAllowed: boolean; isRain: boolean; - hasPhysicalCard: boolean; - onPhysicalCardPress: () => void; } function DesktopHeader({ @@ -325,30 +279,18 @@ function DesktopHeader({ onFreezeToggle, isWithdrawFromCardAllowed, isRain, - hasPhysicalCard, - onPhysicalCardPress, }: DesktopHeaderProps) { const [isManageOpen, setIsManageOpen] = useState(false); - const manageRef = useRef(null); + const insets = useSafeAreaInsets(); + const contentInsets = { + top: insets.top, + bottom: insets.bottom, + left: 12, + right: 12, + }; const showManageDropdown = isRain || !isCardFrozen || canUnfreeze; - // Close dropdown when clicking outside - useEffect(() => { - if (!isManageOpen) return; - const handleClick = (e: MouseEvent) => { - if (manageRef.current) { - // Check if click is outside the dropdown container - const node = manageRef.current as unknown as HTMLElement; - if (!node.contains(e.target as Node)) { - setIsManageOpen(false); - } - } - }; - document.addEventListener('mousedown', handleClick); - return () => document.removeEventListener('mousedown', handleClick); - }, [isManageOpen]); - return ( Card @@ -375,76 +317,55 @@ function DesktopHeader({ {showManageDropdown && ( - - - {isManageOpen && ( - - {isRain && ( - setIsManageOpen(false)} - > - - PIN - - } - /> - )} - {(!isCardFrozen || canUnfreeze) && ( - { - setIsManageOpen(false); - onFreezeToggle(); - }} - disabled={isFreezing} - > - {isFreezing ? ( - - ) : ( - - )} - - {isCardFrozen ? 'Unfreeze' : 'Freeze'} - - - )} - - )} - - )} - {isRain && ( - + + + + {isRain && ( + + + PIN + + } + /> + )} + {(!isCardFrozen || canUnfreeze) && ( + + {isFreezing ? ( + + ) : ( + + )} + + {isCardFrozen ? 'Unfreeze' : 'Freeze'} + + + )} + + )} {isWithdrawFromCardAllowed && ( Promise; isWithdrawFromCardAllowed: boolean; isRain: boolean; - hasPhysicalCard: boolean; - onPhysicalCardPress: () => void; } function CardActions({ @@ -869,8 +788,6 @@ function CardActions({ onFreezeToggle, isWithdrawFromCardAllowed, isRain, - hasPhysicalCard, - onPhysicalCardPress, }: CardActionsProps) { const [isManageSheetOpen, setIsManageSheetOpen] = useState(false); const showManageButton = isRain || !isCardFrozen || canUnfreeze; @@ -962,20 +879,6 @@ function CardActions({ )} - {isRain && ( - - - - - - {hasPhysicalCard ? 'Cancel' : 'Physical'} - - - )} {isWithdrawFromCardAllowed && ( { - e?.stopPropagation(); - void Linking.openURL(learnMoreUrl); - }, []); - - const handleBannerPress = useCallback(() => { - setSource(CardDepositSource.BORROW); - setModal(CARD_DEPOSIT_MODAL.OPEN_INTERNAL_FORM); - }, [setModal, setSource]); - - // Don't render while loading or if disabled - if (isLoading || !isEnabled) { - return null; - } - - const bonusPercentage = Math.round(percentage * 100); - const capFormatted = cap >= 1000 ? `$${cap / 1000}K` : `$${cap}`; - - if (isScreenMedium) { - return ( - - - - {/* Left content */} - - {/* Badge */} - - - Get {bonusPercentage}% bonus for deposit - - - - {/* Description and Learn more */} - - - For users that deposit to savings{'\n'} - and then borrow-deposit to the card. - - - Up to {capFormatted} - - Learn more - - - - - - - {/* Right side - Percentage with circles background */} - - - - - +{bonusPercentage}% - - - - - - ); - } - - // Mobile layout - return ( - - - - {/* Left content */} - - {/* Badge */} - - - Get {bonusPercentage}% bonus for deposit - - - - {/* Description and Learn more */} - - - For users that deposit to savings and then borrow-deposit to the card. - - - - Up to {capFormatted} - - - Learn more - - - - - - - - ); -} - interface CashbackDisplayProps { cashback?: { monthlyFuseAmount: number; @@ -1138,7 +906,7 @@ interface CashbackDisplayProps { } function CashbackDisplay({ cashback }: CashbackDisplayProps) { - const totalUsdValue = cashback?.totalUsdValue ? parseFloat(cashback.totalUsdValue.toFixed(2)) : 0; + const totalUsdValue = cashback?.totalUsdValue ? cashback.totalUsdValue.toFixed(2) : '0.00'; const cashbackPercentage = cashback?.percentage || 0; @@ -1242,14 +1010,6 @@ const styles = StyleSheet.create({ transactionAvatar: { width: 43, height: 43 }, // Gradient overlays (used with LinearGradient) - gradientOverlay: { - position: 'absolute', - left: 0, - right: 0, - top: 0, - bottom: 0, - zIndex: -1, - }, gradientOverlayWithOpacity: { position: 'absolute', left: 0, @@ -1284,8 +1044,4 @@ const styles = StyleSheet.create({ // Text styles lineHeight20: { lineHeight: 20 }, - - // Deposit bonus banner - percentageCircles: { width: 140, height: 140 }, - percentageCirclesMobile: { width: 100, height: 100 }, }); diff --git a/app/(protected)/(tabs)/card/details/transactions.tsx b/app/(protected)/(tabs)/card/details/transactions.tsx index a3ddf1b67..64d9d91a0 100644 --- a/app/(protected)/(tabs)/card/details/transactions.tsx +++ b/app/(protected)/(tabs)/card/details/transactions.tsx @@ -3,13 +3,14 @@ import { ActivityIndicator, Platform, Pressable, RefreshControl, View } from 're import { useRouter } from 'expo-router'; import { FlashList } from '@shopify/flash-list'; import { useQueryClient } from '@tanstack/react-query'; -import { ArrowLeft, RotateCw } from 'lucide-react-native'; +import { RotateCw } from 'lucide-react-native'; import Loading from '@/components/Loading'; import PageLayout from '@/components/PageLayout'; import RenderTokenIcon from '@/components/RenderTokenIcon'; import TransactionDrawer from '@/components/Transaction/TransactionDrawer'; import TransactionDropdown from '@/components/Transaction/TransactionDropdown'; +import { BackButton } from '@/components/ui/back-button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; import { useCardProvider } from '@/hooks/useCardProvider'; @@ -156,14 +157,7 @@ export default function CardTransactions() { - - 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 setShouldShowWelcomePopup = useCardWelcomePopupStore( + state => state.setShouldShowWelcomePopup, + ); + 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'); queryClient.invalidateQueries({ queryKey: [CARD_STATUS_QUERY_KEY] }); if (card.status !== CardStatus.PENDING) { + setShouldShowWelcomePopup(true); router.replace(path.CARD_DETAILS); } else { Toast.show({ @@ -49,15 +132,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/(protected)/rescue-token.tsx b/app/(protected)/rescue-token.tsx new file mode 100644 index 000000000..ac4986284 --- /dev/null +++ b/app/(protected)/rescue-token.tsx @@ -0,0 +1,289 @@ +import { useMemo } from 'react'; +import { ActivityIndicator, View } from 'react-native'; +import Toast from 'react-native-toast-message'; +import { useRouter } from 'expo-router'; +import { useQuery } from '@tanstack/react-query'; +import { Image } from 'expo-image'; +import { Address, encodeFunctionData, erc20Abi, formatEther, formatUnits } from 'viem'; +import { mainnet } from 'viem/chains'; +import { useBalance, useReadContract } from 'wagmi'; + +import Wallet from '@/assets/images/wallet'; +import PageLayout from '@/components/PageLayout'; +import TooltipPopover from '@/components/Tooltip'; +import { BackButton } from '@/components/ui/back-button'; +import { Button } from '@/components/ui/button'; +import Skeleton from '@/components/ui/skeleton'; +import { Text } from '@/components/ui/text'; +import { path } from '@/constants/path'; +import useRescueToken from '@/hooks/useRescueToken'; +import useUser from '@/hooks/useUser'; +import { getAsset } from '@/lib/assets'; +import { ADDRESSES } from '@/lib/config'; +import { Status } from '@/lib/types'; +import { cn, eclipseAddress, formatNumber } from '@/lib/utils'; +import { publicClient } from '@/lib/wagmi'; + +const USDC_DECIMALS = 6; +// Fallback gas units for a standard USDC transfer when on-chain estimation is unavailable +// (e.g. user has 0 USDC and estimateContractGas reverts). +const FALLBACK_USDC_TRANSFER_GAS = 65_000n; + +const GAS_INFO_TEXT = + 'ETH is required to pay network fees to transfer your stuck tokens out. ' + + "If you don't have enough ETH, reach out to the Solid team and we can top up " + + 'your wallet with the gas needed to recover your tokens.'; + +export default function RescueToken() { + const router = useRouter(); + const { user } = useUser(); + const { rescue, status } = useRescueToken(); + + const eoaAddress = user?.walletAddress as Address | undefined; + const safeAddress = user?.safeAddress as Address | undefined; + + const { data: usdcBalance, isLoading: isUsdcLoading } = useReadContract({ + abi: erc20Abi, + address: ADDRESSES.ethereum.usdc, + functionName: 'balanceOf', + args: eoaAddress ? [eoaAddress] : undefined, + chainId: mainnet.id, + query: { enabled: !!eoaAddress }, + }); + + const { data: ethBalance, isLoading: isEthLoading } = useBalance({ + address: eoaAddress, + chainId: mainnet.id, + query: { enabled: !!eoaAddress }, + }); + + const { data: gasCostWei, isLoading: isGasLoading } = useQuery({ + queryKey: [ + 'rescue-token-gas', + mainnet.id, + eoaAddress, + safeAddress, + usdcBalance?.toString(), + ], + queryFn: async () => { + if (!eoaAddress || !safeAddress) return 0n; + const client = publicClient(mainnet.id); + const fees = await client.estimateFeesPerGas(); + const gasPrice = fees.maxFeePerGas || fees.gasPrice || (await client.getGasPrice()); + + let gasUnits = FALLBACK_USDC_TRANSFER_GAS; + if (usdcBalance && usdcBalance > 0n) { + try { + gasUnits = await client.estimateGas({ + account: eoaAddress, + to: ADDRESSES.ethereum.usdc, + data: encodeFunctionData({ + abi: erc20Abi, + functionName: 'transfer', + args: [safeAddress, usdcBalance], + }), + }); + } catch { + gasUnits = FALLBACK_USDC_TRANSFER_GAS; + } + } + + return gasUnits * gasPrice; + }, + enabled: !!eoaAddress && !!safeAddress, + staleTime: 30 * 1000, + gcTime: 60 * 1000, + }); + + const usdcAmount = useMemo( + () => (usdcBalance ? Number(formatUnits(usdcBalance, USDC_DECIMALS)) : 0), + [usdcBalance], + ); + const ethAmount = useMemo( + () => (ethBalance ? Number(formatEther(ethBalance.value)) : 0), + [ethBalance], + ); + const gasEthAmount = useMemo( + () => (gasCostWei ? Number(formatEther(gasCostWei)) : 0), + [gasCostWei], + ); + + const hasUsdc = (usdcBalance ?? 0n) > 0n; + const hasEnoughEth = + !!gasCostWei && !!ethBalance && ethBalance.value >= gasCostWei; + const isRescuing = status === Status.PENDING; + const isDisabled = + isRescuing || + isUsdcLoading || + isEthLoading || + isGasLoading || + !hasUsdc || + !hasEnoughEth; + + const getButtonText = () => { + if (isRescuing) return 'Rescuing...'; + if (!hasUsdc) return 'No USDC to rescue'; + if (!hasEnoughEth) return 'Insufficient ETH for gas'; + return 'Rescue tokens'; + }; + + const handleRescue = async () => { + if (!usdcBalance || usdcBalance <= 0n) return; + try { + const { transactionHash } = await rescue(usdcBalance); + Toast.show({ + type: 'success', + text1: 'Tokens rescued', + text2: `${formatNumber(usdcAmount)} USDC sent to your Solid wallet`, + props: { + link: `https://etherscan.io/tx/${transactionHash}`, + linkText: eclipseAddress(transactionHash), + image: { type: 'image', source: getAsset('images/usdc-4x.png') }, + }, + }); + router.replace(path.HOME); + } catch (err) { + Toast.show({ + type: 'error', + text1: 'Rescue failed', + text2: err instanceof Error ? err.message : 'Please try again', + props: { badgeText: '' }, + }); + } + }; + + return ( + + + + router.replace(path.HOME)} /> + + Rescue tokens + + + + + + + + Recover stuck USDC + + + USDC was sent to your signer wallet on Ethereum by mistake. Sign with your + passkey to move it into your Solid wallet. + + + + + USDC + + {formatNumber(usdcAmount)} USDC + + + } + /> + + + + + + Required ETH gas + + + + } + isLoading={isGasLoading} + value={ + + + ETH + + {formatNumber(gasEthAmount, 6, 6)} ETH + + + + {isEthLoading ? '—' : `Balance: ${formatNumber(ethAmount, 6, 6)} ETH`} + + + } + /> + + + + + + + + ); +} + +function BalanceRow({ + label, + value, + isLoading, +}: { + label: React.ReactNode; + value: React.ReactNode; + isLoading?: boolean; +}) { + return ( + + {typeof label === 'string' ? ( + {label} + ) : ( + label + )} + {isLoading ? : value} + + ); +} + +function DestinationRow({ safeAddress }: { safeAddress?: Address }) { + return ( + + To + + + + Solid wallet + + {safeAddress ? eclipseAddress(safeAddress, 6, 6) : ''} + + + + + ); +} diff --git a/app/_layout.tsx b/app/_layout.tsx index b6d03db08..6cec2c2cf 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,7 +1,7 @@ import '@/global.css'; import { useCallback, useEffect, useState } from 'react'; -import { Appearance, Platform } from 'react-native'; +import { Appearance, AppState, Platform } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { SafeAreaProvider } from 'react-native-safe-area-context'; import Toast from 'react-native-toast-message'; @@ -23,9 +23,15 @@ import { useFonts, } from '@expo-google-fonts/mona-sans'; import { BottomSheetModalProvider } from '@gorhom/bottom-sheet'; +import NetInfo from '@react-native-community/netinfo'; import { PortalHost } from '@rn-primitives/portal'; import * as Sentry from '@sentry/react-native'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + focusManager, + onlineManager, + QueryClient, + QueryClientProvider, +} from '@tanstack/react-query'; import { injectSpeedInsights } from '@vercel/speed-insights'; import { WagmiProvider } from 'wagmi'; @@ -40,9 +46,11 @@ import { getInfoClient } from '@/graphql/clients'; import { useAttributionInitialization } from '@/hooks/useAttributionInitialization'; import { usePushNotifications } from '@/hooks/usePushNotifications'; import { useTrackingTransparency } from '@/hooks/useTrackingTransparency'; +import { useTrackUserPlatform } from '@/hooks/useTrackUserPlatform'; import { useWhatsNew } from '@/hooks/useWhatsNew'; import { initAnalytics, track, trackScreen } from '@/lib/analytics'; import { EXPO_PUBLIC_ENVIRONMENT, isProduction } from '@/lib/config'; +import { configureObserve, markAppInteractive, withObserve } from '@/lib/observe'; import { config } from '@/lib/wagmi'; import { useUserStore } from '@/store/useUserStore'; @@ -116,6 +124,10 @@ Sentry.init({ // tracePropagationTargets: [/^https:\/\/app\.solid\.xyz/], }); +// EAS Observe: native-side startup/performance metric collection begins at +// launch, so configure dispatching before the app renders. +configureObserve(); + export function ErrorBoundary(props: ErrorBoundaryProps) { return ; } @@ -153,6 +165,27 @@ function WhatsNewWrapper() { return ; } +// On native, React Query's focus and online managers have no default wiring: +// `refetchOnWindowFocus` listens to `visibilitychange` (web-only) and +// `refetchOnReconnect` has no transport to detect connectivity. Without this +// bridge, a query with `refetchOnWindowFocus: true` (e.g. tokenBalances) +// never fires when the app returns from background, and queries don't recover +// after a network drop. Wire AppState → focusManager and NetInfo → onlineManager. +if (Platform.OS !== 'web') { + focusManager.setEventListener(handleFocus => { + const subscription = AppState.addEventListener('change', status => { + handleFocus(status === 'active'); + }); + return () => subscription.remove(); + }); + + onlineManager.setEventListener(setOnline => { + return NetInfo.addEventListener(state => { + setOnline(!!state.isConnected); + }); + }); +} + export const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -164,9 +197,10 @@ export const queryClient = new QueryClient({ }, }); -export default Sentry.wrap(function RootLayout() { +function RootLayout() { const [appIsReady, setAppIsReady] = useState(false); const [splashScreenHidden, setSplashScreenHidden] = useState(false); + const [analyticsReady, setAnalyticsReady] = useState(false); const hasSelectedUser = useUserStore(state => state.users.some(u => u.selected)); @@ -176,6 +210,9 @@ export default Sentry.wrap(function RootLayout() { // Push notification lifecycle: token refresh + notification tap handling usePushNotifications(); + // Record platform (ios/android/web) on the user once per session + useTrackUserPlatform(); + // App Tracking Transparency (iOS only) const { isReady: attReady, @@ -189,7 +226,9 @@ export default Sentry.wrap(function RootLayout() { if (!splashScreenHidden) return; if (Platform.OS === 'ios' && !attReady) return; - initAnalytics(isTrackingAllowed).catch(e => console.warn('Analytics init error:', e)); + initAnalytics(isTrackingAllowed) + .catch(e => console.warn('Analytics init error:', e)) + .finally(() => setAnalyticsReady(true)); }, [splashScreenHidden, attReady, isTrackingAllowed]); useEffect(() => { @@ -274,13 +313,27 @@ export default Sentry.wrap(function RootLayout() { } }, [appIsReady, splashScreenHidden]); + // EAS Observe: record time-to-interactive once the splash screen is gone + // and the first real frame is visible. + useEffect(() => { + if (splashScreenHidden) { + markAppInteractive(); + } + }, [splashScreenHidden]); + // Track screen views on all platforms (web, iOS, Android) // trackScreen() handles platform-specific routing internally: // - Amplitude: tracks on all platforms // - Firebase: tracks on web only useEffect(() => { + // Wait until analytics is initialized before tracking screen views. On + // web the SDK has no proxy/serverUrl configured until init() runs, so an + // early Page Viewed would be queued and flushed to the wrong endpoint (or + // lost). Gating on analyticsReady also re-fires this effect once init + // completes, capturing the landing screen with full attribution context. + if (!analyticsReady) return; trackScreen(pathname, params); - }, [pathname, params]); + }, [pathname, params, analyticsReady]); useEffect(() => { if (fontError) { @@ -293,14 +346,14 @@ export default Sentry.wrap(function RootLayout() { } return ( - + - + {Platform.OS === 'web' && ( @@ -392,4 +445,8 @@ export default Sentry.wrap(function RootLayout() { ); -}); +} + +// withObserve wraps the layout with AppMetricsRoot so EAS Observe records +// time-to-first-render without a manual markFirstRender() call. +export default Sentry.wrap(withObserve(RootLayout)); 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/onboarding.tsx b/app/onboarding.tsx index 43da8c59e..45935cf71 100644 --- a/app/onboarding.tsx +++ b/app/onboarding.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { ActivityIndicator, useWindowDimensions, View } from 'react-native'; +import { ActivityIndicator, Pressable, useWindowDimensions, View } from 'react-native'; import Animated, { useAnimatedReaction, useAnimatedScrollHandler, @@ -11,6 +11,7 @@ import Toast from 'react-native-toast-message'; import { scheduleOnRN } from 'react-native-worklets'; import { Image } from 'expo-image'; import { useRouter } from 'expo-router'; +import { ChevronRight } from 'lucide-react-native'; import { useShallow } from 'zustand/react/shallow'; import LoginKeyIcon from '@/assets/images/login_key_icon'; @@ -20,6 +21,7 @@ import { OnboardingPage, OnboardingPagination, } from '@/components/Onboarding'; +import PasskeyFaqModal from '@/components/PasskeyFaqModal'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; import { path } from '@/constants/path'; @@ -45,12 +47,16 @@ export default function Onboarding() { const isLoginPending = loginInfo.status === Status.PENDING; const { width: screenWidth, height: screenHeight } = useWindowDimensions(); const [currentIndex, setCurrentIndex] = useState(0); + const [showRecoveryLink, setShowRecoveryLink] = useState(false); + const [showPasskeyFaq, setShowPasskeyFaq] = useState(false); const scrollX = useSharedValue(0); // Responsive layout for small screens (iPhone SE) const isSmallScreen = screenHeight < 700; - // Fixed button area height - content area uses flex: 1 to fill remaining space - const buttonAreaHeight = isSmallScreen ? 200 : 240; + // Fixed button area height - content area uses flex: 1 to fill remaining space. + // Grows to fit the help prompt (Passkey FAQs / account recovery) shown after a failed login, + // which can wrap onto two lines on smaller screens. + const buttonAreaHeight = (isSmallScreen ? 200 : 240) + (showRecoveryLink ? 64 : 0); // Track screen width as shared value for use in worklets const widthSV = useSharedValue(screenWidth); @@ -84,6 +90,8 @@ export default function Onboarding() { const handleLoginPress = useCallback(async () => { // Mark onboarding as seen setHasSeenOnboarding(true); + // Hide any previous recovery prompt while retrying + setShowRecoveryLink(false); try { await handleLogin(); @@ -92,12 +100,13 @@ export default function Onboarding() { // User not found — redirect to signup router.replace(path.SIGNUP_EMAIL); } else { - // Other errors — show toast and stay on onboarding + // Other errors — show toast, stay on onboarding, and offer account recovery Toast.show({ type: 'error', text1: 'Login failed', text2: error?.message || 'Something went wrong. Please try again.', }); + setShowRecoveryLink(true); } } }, [handleLogin, router, setHasSeenOnboarding]); @@ -107,6 +116,10 @@ export default function Onboarding() { router.replace(path.SIGNUP_EMAIL); }, [router, setHasSeenOnboarding]); + const handleRecoverAccount = useCallback(() => { + router.push(path.RECOVERY); + }, [router]); + const handleHelpCenter = useCallback(() => { // TODO: Add help center link // Linking.openURL(HELP_CENTER_URL); @@ -114,6 +127,26 @@ export default function Onboarding() { const filteredOnboardingData = ONBOARDING_DATA.filter(slide => slide?.platform !== false); + // Surfaced below the Login button after a failed login attempt so users who + // can't authenticate (e.g. lost passkey) can read the Passkey FAQs or start + // account recovery. + const recoveryLink = showRecoveryLink ? ( + + Have trouble logging in? See our + setShowPasskeyFaq(true)} className="web:hover:opacity-70"> + Passkey FAQs + + or + + Recover your account + + + + ) : null; + // Mobile Layout if (!isDesktop) { return ( @@ -183,6 +216,9 @@ export default function Onboarding() { )} + {/* Account recovery prompt — only after a failed login */} + {recoveryLink} + {/* Dev-only Dummy Login */} {/* {__DEV__ && ( + {/* Account recovery prompt — only after a failed login */} + {recoveryLink} + {/* Dev-only Dummy Login */} {__DEV__ && ( + + ); +}; + +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/AreaChart.tsx b/components/AreaChart.tsx index 3a167cede..1594d7e66 100644 --- a/components/AreaChart.tsx +++ b/components/AreaChart.tsx @@ -57,7 +57,6 @@ const ChartContent = ({ const [tooltipData, setTooltipData] = useState<{ price: string; date: string; - priceChange: number | null; } | null>(null); const { currentIndex, isActive } = LineChart.useChart(); @@ -84,16 +83,9 @@ const ChartContent = ({ return `$${formatNumber(value)}`; }; - const prevData = index > 0 ? data[index - 1] : null; - const change = - prevData && currentData - ? calculatePercentageChange(prevData.value, currentData.value) - : null; - setTooltipData({ price: formatToolTip ? formatToolTip(currentData.value) : format(currentData.value), date: formatChartTooltipDate(currentData.time), - priceChange: change, }); setTooltipVisible(true); } @@ -267,20 +259,7 @@ const ChartContent = ({ {tooltipData.price} - - {tooltipData.priceChange !== null && ( - - {formatNumber(tooltipData.priceChange, 2)}% - - )} - {tooltipData.date} - + {tooltipData.date} )} diff --git a/components/AreaChart.web.tsx b/components/AreaChart.web.tsx index c7748a86a..689e9112d 100644 --- a/components/AreaChart.web.tsx +++ b/components/AreaChart.web.tsx @@ -83,9 +83,7 @@ const Chart = ({ tickLine={false} /> - } - /> + } /> - - - + 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..6bc0539d1 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; } @@ -817,9 +926,9 @@ export default function CardDepositInternalForm() { // Create activity event (stays PENDING until Bridge processes it) const clientTxId = await createActivity({ - type: TransactionType.CARD_TRANSACTION, - title: `Card Deposit`, - shortTitle: `Card Deposit`, + type: TransactionType.BORROW_AND_DEPOSIT_TO_CARD, + title: `Borrow and deposit to Card`, + shortTitle: `Borrow and deposit to Card`, amount: data.amount, symbol: sourceSymbol, chainId: fuse.id, @@ -827,7 +936,7 @@ export default function CardDepositInternalForm() { toAddress: borrowFundingAddress, status: TransactionStatus.PENDING, metadata: { - description: `Deposit ${data.amount} ${sourceSymbol} to card`, + description: `Borrow and deposit ${data.amount} ${sourceSymbol} to card`, processingStatus: 'bridging', tokenAddress: sourceTokenAddress, }, @@ -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; } @@ -935,9 +1053,9 @@ export default function CardDepositInternalForm() { // Create activity event (stays PENDING until Bridge processes it) const clientTxId = await createActivity({ - type: TransactionType.CARD_TRANSACTION, - title: `Card Deposit`, - shortTitle: `Card Deposit`, + type: TransactionType.BRIDGE_DEPOSIT, + title: `Deposit ${sourceSymbol} to Card`, + shortTitle: `Deposit ${sourceSymbol}`, amount: data.amount, symbol: sourceSymbol, chainId: fuse.id, @@ -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/Card/CardWelcomePopup.tsx b/components/Card/CardWelcomePopup.tsx new file mode 100644 index 000000000..9659f9afe --- /dev/null +++ b/components/Card/CardWelcomePopup.tsx @@ -0,0 +1,54 @@ +import { View } from 'react-native'; +import { Image } from 'expo-image'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogCloseButton, DialogContent } from '@/components/ui/dialog'; +import { Text } from '@/components/ui/text'; +import { getAsset } from '@/lib/assets'; + +interface CardWelcomePopupProps { + isOpen: boolean; + onClose: () => void; +} + +const CardWelcomePopup = ({ isOpen, onClose }: CardWelcomePopupProps) => { + return ( + !open && onClose()}> + + + + + + + + + + + Welcome to the solid card + + + Your account is verified and your virtual Solid card is officially live. You can now + view your card details, add it to your digital wallet, and start spending online + instantly. + + + + + + + + + + ); +}; + +export default CardWelcomePopup; diff --git a/components/Card/OrderPhysicalCardModal.tsx b/components/Card/OrderPhysicalCardModal.tsx index 8ff38e271..16da7aa5b 100644 --- a/components/Card/OrderPhysicalCardModal.tsx +++ b/components/Card/OrderPhysicalCardModal.tsx @@ -25,17 +25,23 @@ interface OrderPhysicalCardModalProps { const MODAL_STATE: ModalState = { name: 'order-physical-card', number: 1 }; const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; +// Rain embosses firstName + lastName as the displayName on physical cards +// and only allows alphanumeric characters, spaces, periods, and hyphens. +const PHYSICAL_CARD_NAME_REGEX = /^[a-zA-Z0-9 .\-]+$/; +const PHYSICAL_CARD_NAME_MESSAGE = + 'Only letters, numbers, spaces, periods, and hyphens are allowed'; + const shippingSchema = z.object({ firstName: z .string() .min(1, { message: 'First name is required' }) .max(50) - .regex(/^[a-zA-Z -]+$/, { message: 'Only Latin characters, spaces, and hyphens' }), + .regex(PHYSICAL_CARD_NAME_REGEX, { message: PHYSICAL_CARD_NAME_MESSAGE }), lastName: z .string() .min(1, { message: 'Last name is required' }) .max(50) - .regex(/^[a-zA-Z -]+$/, { message: 'Only Latin characters, spaces, and hyphens' }), + .regex(PHYSICAL_CARD_NAME_REGEX, { message: PHYSICAL_CARD_NAME_MESSAGE }), line1: z.string().min(1, { message: 'Address is required' }).max(100), line2: z.string().max(100).optional().or(z.literal('')), city: z.string().min(1, { message: 'City is required' }).max(50), diff --git a/components/CardWaitlist/CardFeesModal.tsx b/components/CardWaitlist/CardFeesModal.tsx new file mode 100644 index 000000000..f4e16f805 --- /dev/null +++ b/components/CardWaitlist/CardFeesModal.tsx @@ -0,0 +1,139 @@ +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..f2d851c39 100644 --- a/components/CardWaitlist/CardWaitlistContainer.tsx +++ b/components/CardWaitlist/CardWaitlistContainer.tsx @@ -1,4 +1,4 @@ -import { ImageBackground, Platform } from 'react-native'; +import { ImageBackground, Platform, View } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { useDimension } from '@/hooks/useDimension'; @@ -12,13 +12,25 @@ const CardWaitlistContainer = ({ children }: CardWaitlistContainerProps) => { const { isScreenMedium } = useDimension(); return ( - + {isScreenMedium ? ( { ) : ( <>{children} )} - + ); }; diff --git a/components/CardWaitlist/CardWaitlistHeaderTitle.tsx b/components/CardWaitlist/CardWaitlistHeaderTitle.tsx index 95563a800..4c18623db 100644 --- a/components/CardWaitlist/CardWaitlistHeaderTitle.tsx +++ b/components/CardWaitlist/CardWaitlistHeaderTitle.tsx @@ -1,32 +1,11 @@ import { View } from 'react-native'; import { Text } from '@/components/ui/text'; -import { useDimension } from '@/hooks/useDimension'; const CardWaitlistHeaderTitle = () => { - 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..453a121e3 --- /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..1ace8b6d9 --- /dev/null +++ b/components/CardWaitlist/SolidCardSummary.tsx @@ -0,0 +1,64 @@ +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; + classNames?: { + container?: string + text?: string + }; +}; + +const FeatureItem = ({ icon, label, classNames }: 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} classNames={{container:"items-start"}} />} + + {!compact && } label={topUpLabel} classNames={{container:"items-start"}} />} + + ); +}; + +export default SolidCardSummary; diff --git a/components/ChartTooltip.tsx b/components/ChartTooltip.tsx index 011d6b606..1b13a7336 100644 --- a/components/ChartTooltip.tsx +++ b/components/ChartTooltip.tsx @@ -4,7 +4,7 @@ import { useShallow } from 'zustand/react/shallow'; import { Text } from '@/components/ui/text'; import { ChartPayload } from '@/lib/types'; -import { cn, formatNumber } from '@/lib/utils'; +import { formatNumber } from '@/lib/utils'; import { formatChartTooltipDate } from '@/lib/utils/chartDate'; import { useCoinStore } from '@/store/useCoinStore'; @@ -17,7 +17,6 @@ interface ChartTooltipProps { payload?: TooltipPayload[]; data?: ChartPayload[]; formatToolTip?: (value: number | null) => string; - isPriceChange?: boolean; } export function calculatePercentageChange(oldValue: number, newValue: number) { @@ -28,22 +27,14 @@ export function calculatePercentageChange(oldValue: number, newValue: number) { return ((newValue - oldValue) / oldValue) * 100; } -const ChartTooltip = ({ - active, - payload, - data, - formatToolTip, - isPriceChange, -}: ChartTooltipProps) => { - const { selectedPrice, selectedPriceChange, setSelectedPriceChange, setSelectedPrice } = - useCoinStore( - useShallow(state => ({ - selectedPrice: state.selectedPrice, - selectedPriceChange: state.selectedPriceChange, - setSelectedPriceChange: state.setSelectedPriceChange, - setSelectedPrice: state.setSelectedPrice, - })), - ); +const ChartTooltip = ({ active, payload, data, formatToolTip }: ChartTooltipProps) => { + const { selectedPrice, setSelectedPriceChange, setSelectedPrice } = useCoinStore( + useShallow(state => ({ + selectedPrice: state.selectedPrice, + setSelectedPriceChange: state.setSelectedPriceChange, + setSelectedPrice: state.setSelectedPrice, + })), + ); const [currentTimestamp, setCurrentTimestamp] = useState(0); const prevPayloadRef = useRef(null); @@ -100,21 +91,9 @@ const ChartTooltip = ({ {formatToolTip ? formatToolTip(selectedPrice) : format(selectedPrice)} - - {isPriceChange && selectedPriceChange && ( - - {formatNumber(selectedPriceChange, 2)}% - - )} - - {formatChartTooltipDate(currentTimestamp)} - - + + {formatChartTooltipDate(currentTimestamp)} + ); diff --git a/components/Coin/BalanceBreakdown.tsx b/components/Coin/BalanceBreakdown.tsx index 702f6fe44..4f79087be 100644 --- a/components/Coin/BalanceBreakdown.tsx +++ b/components/Coin/BalanceBreakdown.tsx @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { View } from 'react-native'; import { Image } from 'expo-image'; import { formatUnits } from 'viem'; -import { base, fuse, mainnet } from 'viem/chains'; +import { base, bsc, fuse, mainnet } from 'viem/chains'; import SavingsIcon from '@/assets/images/savings'; import WalletIcon from '@/assets/images/wallet'; @@ -36,12 +36,14 @@ const CHAIN_ICONS: Record = { [mainnet.id]: getAsset('images/eth.png'), [fuse.id]: getAsset('images/fuse-4x.png'), [base.id]: getAsset('images/base.png'), + [bsc.id]: getAsset('images/bsc.png'), }; const CHAIN_NAMES: Record = { [mainnet.id]: 'Ethereum', [fuse.id]: 'Fuse', [base.id]: 'Base', + [bsc.id]: 'BNB Chain', }; const BalanceBreakdown = ({ token, className }: BalanceBreakdownProps) => { diff --git a/components/DepositOption/AddFundsToWalletForm.tsx b/components/DepositOption/AddFundsToWalletForm.tsx index c14001560..6883057fb 100644 --- a/components/DepositOption/AddFundsToWalletForm.tsx +++ b/components/DepositOption/AddFundsToWalletForm.tsx @@ -42,6 +42,7 @@ function AddFundsToWalletForm() { image: tokenData?.icon || getAsset('images/usdc.png'), fullName: tokenData?.fullName, isNative: tokenData?.isNative ?? false, + decimals: tokenData?.decimals, }; }, [srcChainId, principalToken]); @@ -51,8 +52,10 @@ function AddFundsToWalletForm() { selectedTokenInfo.isNative, ); + // Derive decimals from the bridge config so 18-decimal stablecoins (e.g. + // Binance-Peg USDC/USDT on BNB Chain) display and validate correctly. const isStablecoin = principalToken === 'USDC' || principalToken === 'USDT'; - const decimals = isStablecoin ? 6 : 18; + const decimals = selectedTokenInfo.decimals ?? (isStablecoin ? 6 : 18); const isLoading = transferStatus.status === Status.PENDING; const formattedBalance = balance ? formatUnits(balance, decimals) : '0'; 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/PasskeyFaqModal.tsx b/components/PasskeyFaqModal.tsx new file mode 100644 index 000000000..e09aa39b1 --- /dev/null +++ b/components/PasskeyFaqModal.tsx @@ -0,0 +1,49 @@ +import { View } from 'react-native'; +import LottieView from 'lottie-react-native'; + +import { FAQ } from '@/components/FAQ'; +import ResponsiveModal, { ModalState } from '@/components/ResponsiveModal'; +import { Text } from '@/components/ui/text'; +import passkeyFaqs from '@/constants/passkey-faqs'; + +const MODAL_STATE: ModalState = { name: 'passkey-faq', number: 1 }; +const CLOSE_STATE: ModalState = { name: 'close', number: 0 }; + +interface PasskeyFaqModalProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; +} + +const PasskeyFaqModal = ({ isOpen, onOpenChange }: PasskeyFaqModalProps) => { + return ( + + + + + + + Passkey FAQ + + + + + ); +}; + +export default PasskeyFaqModal; diff --git a/components/RainKyc/rainKycSchema.ts b/components/RainKyc/rainKycSchema.ts index 7c2c45f42..ed70bdc42 100644 --- a/components/RainKyc/rainKycSchema.ts +++ b/components/RainKyc/rainKycSchema.ts @@ -2,25 +2,10 @@ import { z } from 'zod'; import type { RainDocumentType } from '@/lib/types'; -/** - * Rain requires the card display name to contain only Latin characters. - * Allows Basic Latin + Latin Extended (accented) letters, spaces, hyphens, apostrophes, and periods. - */ -const LATIN_NAME_REGEX = /^[a-zA-ZÀ-ÖØ-öø-ÿĀ-ſ\s\-'.]+$/; -const LATIN_NAME_MESSAGE = 'Only Latin characters are allowed (no Cyrillic, Arabic, CJK, etc.)'; - export const rainKycFormSchema = z .object({ - firstName: z - .string() - .min(1, 'First name is required') - .max(50) - .regex(LATIN_NAME_REGEX, LATIN_NAME_MESSAGE), - lastName: z - .string() - .min(1, 'Last name is required') - .max(50) - .regex(LATIN_NAME_REGEX, LATIN_NAME_MESSAGE), + firstName: z.string().min(1, 'First name is required').max(50), + lastName: z.string().min(1, 'Last name is required').max(50), birthDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Use YYYY-MM-DD'), nationalId: z.string().min(1, 'National ID / SSN is required'), countryOfIssue: z.string().length(2, 'Use 2-letter country code'), 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 && ( { ]; }; - const earningMethods = getEarningMethods(); + // Swap is not available on iOS, so exclude it from the earning methods there. + const earningMethods = getEarningMethods().filter( + method => Platform.OS !== 'ios' || method.title !== 'Swap', + ); return ( diff --git a/components/Rewards/TierFeesTable.tsx b/components/Rewards/TierFeesTable.tsx index 1caf75278..e201f3d05 100644 --- a/components/Rewards/TierFeesTable.tsx +++ b/components/Rewards/TierFeesTable.tsx @@ -1,3 +1,5 @@ +import { Platform } from 'react-native'; + import { RewardsTier, TierBenefits } from '@/lib/types'; import RewardTable, { RewardTableRow } from './RewardTable'; @@ -31,10 +33,15 @@ const TierFeesTable = ({ tierBenefits }: TierFeesTableProps) => { label: 'Bank deposit', values: sortedTiers.map(tier => tier.bankDeposit), }, - { - label: 'Swaps', - values: sortedTiers.map(tier => tier.swapFees), - }, + // Swap is not available on iOS, so omit the swap fees row there. + ...(Platform.OS === 'ios' + ? [] + : [ + { + label: 'Swaps', + values: sortedTiers.map(tier => tier.swapFees), + }, + ]), { label: 'Support', values: sortedTiers.map(tier => tier.support), diff --git a/components/Savings/SavingsAnalytics.tsx b/components/Savings/SavingsAnalytics.tsx index 32fdff7d6..11b1188bb 100644 --- a/components/Savings/SavingsAnalytics.tsx +++ b/components/Savings/SavingsAnalytics.tsx @@ -40,7 +40,8 @@ const SavingsAnalytics = () => { historicalVault, ); const currentVaultName = VAULTS[selectedVault]?.name?.toLowerCase(); - const { data: vaultBreakdown } = useVaultBreakdown(currentVaultName); + const { data: vaultBreakdown, isLoading: isVaultBreakdownLoading } = + useVaultBreakdown(currentVaultName); useEffect(() => { isMountedRef.current = true; @@ -136,7 +137,9 @@ const SavingsAnalytics = () => { {selectedTab === Tab.VAULT_BREAKDOWN && ( <> - {vaultBreakdown && vaultBreakdown.length > 0 ? ( + {isVaultBreakdownLoading ? ( + + ) : vaultBreakdown && vaultBreakdown.length > 0 ? ( = ({ onNext }) => { setModal: state.setModal, })), ); - const { user } = useUser(); - const tokenType = selectedToken?.type || TokenType.ERC20; - const isNative = tokenType === TokenType.NATIVE; + const { ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens, isLoading } = + useWalletTokens(); - const { data: balanceNative, isLoading: isBalanceNativeLoading } = useBalance({ - address: user?.safeAddress as `0x${string}` | undefined, - chainId: selectedToken?.chainId, - query: { - enabled: !!user?.safeAddress && !!selectedToken && isNative, - }, - }); - - const { data: balanceERC20, isLoading: isBalanceERC20Loading } = useBalance({ - address: user?.safeAddress as `0x${string}` | undefined, - token: - selectedToken && !isNative && selectedToken.contractAddress !== zeroAddress - ? (selectedToken.contractAddress as `0x${string}`) - : undefined, - chainId: selectedToken?.chainId, - query: { - enabled: !!user?.safeAddress && !!selectedToken && !isNative, - }, - }); + // Use the live token from useWalletTokens (5s polling + SSE-invalidated) so + // the balance stays current after a previous send. The `selectedToken` + // snapshot in the store is captured at selection time and would otherwise + // show a stale balance — and wagmi's useBalance has a 5-minute default + // staleTime that isn't invalidated by the safe-account send flow. + const liveToken = useMemo(() => { + if (!selectedToken) return null; + const allTokens = [ + ...ethereumTokens, + ...fuseTokens, + ...polygonTokens, + ...baseTokens, + ...arbitrumTokens, + ]; + const fresh = allTokens.find( + t => + t.contractAddress === selectedToken.contractAddress && t.chainId === selectedToken.chainId, + ); + return fresh ?? selectedToken; + }, [selectedToken, ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens]); - const balance = isNative ? balanceNative?.value : balanceERC20?.value; - const isLoading = isNative ? isBalanceNativeLoading : isBalanceERC20Loading; + const balanceWei = useMemo(() => { + if (!liveToken) return 0n; + try { + return BigInt(liveToken.balance || '0'); + } catch { + return 0n; + } + }, [liveToken]); const balanceAmount = useMemo(() => { - if (!selectedToken) return 0; - if (balance) { - return Number(formatUnits(balance, selectedToken.contractDecimals)); - } - return Number( - formatUnits(BigInt(selectedToken.balance || '0'), selectedToken.contractDecimals), - ); - }, [selectedToken, balance]); + if (!liveToken) return 0; + return Number(formatUnits(balanceWei, liveToken.contractDecimals)); + }, [liveToken, balanceWei]); const sendSchema = useMemo(() => { return z.object({ amount: z .string() .refine(val => val !== '' && !isNaN(Number(val)), { error: 'Please enter a valid amount' }) - .refine(val => Number(val) > 0, { error: 'Amount must be greater than 0' }) - .refine(val => Number(val) <= balanceAmount, { - error: `Available balance is ${formatNumber(balanceAmount)} ${selectedToken?.contractTickerSymbol || ''}`, - }) - .transform(val => Number(val)), + .refine( + val => { + if (!liveToken) return false; + try { + return parseUnits(val, liveToken.contractDecimals) > 0n; + } catch { + return false; + } + }, + { error: 'Amount must be greater than 0' }, + ) + // Compare in wei so floating-point precision can't enable Send for + // amounts that round above the on-chain balance. + .refine( + val => { + if (!liveToken) return false; + try { + return parseUnits(val, liveToken.contractDecimals) <= balanceWei; + } catch { + return false; + } + }, + { + error: `Available balance is ${formatNumber(balanceAmount)} ${liveToken?.contractTickerSymbol || ''}`, + }, + ), }); - }, [selectedToken, balanceAmount]); + }, [liveToken, balanceAmount, balanceWei]); const { control, handleSubmit, formState: { errors, isValid }, setValue, + trigger, } = useForm({ resolver: zodResolver(sendSchema), mode: Platform.OS === 'web' ? 'onChange' : undefined, @@ -102,9 +123,9 @@ const SendForm: React.FC = ({ onNext }) => { }); const balanceUSD = useMemo(() => { - if (!selectedToken) return 0; - return Number(amount) * (selectedToken?.quoteRate || 0); - }, [selectedToken, amount]); + if (!liveToken) return 0; + return Number(amount) * (liveToken?.quoteRate || 0); + }, [liveToken, amount]); useEffect(() => { if (amount) setValue('amount', amount); @@ -121,18 +142,26 @@ const SendForm: React.FC = ({ onNext }) => { const handleTokenSelectorPress = useCallback(() => { track(TRACKING_EVENTS.SEND_PAGE_TOKEN_SELECTOR_OPENED, { source: 'send_modal', - current_token: selectedToken?.contractTickerSymbol || null, + current_token: liveToken?.contractTickerSymbol || null, }); setModal(SEND_MODAL.OPEN_TOKEN_SELECTOR); - }, [setModal, selectedToken]); + }, [setModal, liveToken]); const handleMaxPress = useCallback(() => { - if (selectedToken && balanceAmount > 0) { - const maxAmount = balanceAmount.toString(); - setAmount(maxAmount); - setValue('amount', maxAmount); - } - }, [setAmount, setValue, selectedToken, balanceAmount]); + if (!liveToken || balanceWei === 0n) return; + // Format from the BigInt directly so the resulting decimal string + // round-trips through parseUnits exactly. Routing through Number() + // (then `.toString()`) drops low-order digits and can make parseUnits + // round above the actual balance, causing the on-chain transfer to revert. + const maxAmount = formatUnits(balanceWei, liveToken.contractDecimals); + setAmount(maxAmount); + setValue('amount', maxAmount); + // RHF's onChange mode validates on the Controller's input change event, + // not on programmatic setValue. Without an explicit trigger, isValid + // stays at its prior value and Review stays disabled when the user + // hits Max without typing first. + trigger('amount'); + }, [setAmount, setValue, trigger, liveToken, balanceWei]); const onSubmit = useCallback( (data: any) => { @@ -150,15 +179,15 @@ const SendForm: React.FC = ({ onNext }) => { Amount - {selectedToken && ( + {liveToken && ( - {isLoading + {isLoading && balanceWei === 0n ? '...' - : `${formatNumber(balanceAmount)} ${selectedToken.contractTickerSymbol}`} + : `${formatNumber(balanceAmount)} ${liveToken.contractTickerSymbol}`} - {balanceAmount > 0 && } + {balanceWei > 0n && } )} diff --git a/components/Send/TokenSelector.tsx b/components/Send/TokenSelector.tsx index 0c0a46eca..a94881534 100644 --- a/components/Send/TokenSelector.tsx +++ b/components/Send/TokenSelector.tsx @@ -11,6 +11,7 @@ import { useWalletTokens } from '@/hooks/useWalletTokens'; import getTokenIcon from '@/lib/getTokenIcon'; import { TokenBalance } from '@/lib/types'; import { cn, formatNumber } from '@/lib/utils'; +import { getChain } from '@/lib/wagmi'; import { useSendStore } from '@/store/useSendStore'; import ToInput from './ToInput'; @@ -24,11 +25,25 @@ const TokenSelector: React.FC = () => { setModal: state.setModal, })), ); - const { ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens } = useWalletTokens(); + const { + ethereumTokens, + fuseTokens, + polygonTokens, + baseTokens, + arbitrumTokens, + bscTokens, + } = useWalletTokens(); // Combine and sort tokens by USD value (descending) const allTokens = useMemo(() => { - const combined = [...ethereumTokens, ...fuseTokens, ...polygonTokens, ...baseTokens, ...arbitrumTokens]; + const combined = [ + ...ethereumTokens, + ...fuseTokens, + ...polygonTokens, + ...baseTokens, + ...arbitrumTokens, + ...bscTokens, + ]; return combined.sort((a, b) => { const balanceA = Number(formatUnits(BigInt(a.balance || '0'), a.contractDecimals)); const balanceUSD_A = balanceA * (a.quoteRate || 0); @@ -38,7 +53,7 @@ const TokenSelector: React.FC = () => { return balanceUSD_B - balanceUSD_A; // Descending order }); - }, [ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens]); + }, [ethereumTokens, fuseTokens, polygonTokens, baseTokens, arbitrumTokens, bscTokens]); const handleTokenSelect = useCallback( (token: TokenBalance) => { @@ -86,7 +101,8 @@ const TokenSelector: React.FC = () => { {token.contractTickerSymbol} - {token.contractTickerSymbol} on {getBridgeChain(token.chainId).name} + {token.contractTickerSymbol} on{' '} + {getBridgeChain(token.chainId)?.name ?? getChain(token.chainId)?.name} diff --git a/components/Swap/SwapModalProvider.tsx b/components/Swap/SwapModalProvider.tsx index 37848aff5..4f69d32a6 100644 --- a/components/Swap/SwapModalProvider.tsx +++ b/components/Swap/SwapModalProvider.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useMemo } from 'react'; -import { View } from 'react-native'; +import { Platform, View } from 'react-native'; import { useRouter } from 'expo-router'; import { useShallow } from 'zustand/react/shallow'; @@ -136,6 +136,11 @@ const SwapModalProvider = () => { handleTransactionStatusPress, ]); + // Swap is not available on iOS — never render the swap modal there. + if (Platform.OS === 'ios') { + return null; + } + return ( ), + info: ({ text1, text2, props }: IBaseToast) => ( + + ), }; export const toastProps: ToastProps = { diff --git a/components/Transaction/index.tsx b/components/Transaction/index.tsx index c7d31c8d4..1ceaf34c5 100644 --- a/components/Transaction/index.tsx +++ b/components/Transaction/index.tsx @@ -8,7 +8,7 @@ import RenderTokenIcon from '@/components/RenderTokenIcon'; import ResponsiveDialog from '@/components/ResponsiveDialog'; import { Button } from '@/components/ui/button'; import { Text } from '@/components/ui/text'; -import { TRANSACTION_DETAILS } from '@/constants/transaction'; +import { getTransactionCategory, TRANSACTION_DETAILS } from '@/constants/transaction'; import { useDimension } from '@/hooks/useDimension'; import { useDirectDepositSession } from '@/hooks/useDirectDepositSession'; import { getAsset } from '@/lib/assets'; @@ -221,7 +221,7 @@ const Transaction = ({ if (isRefunded) return 'Refunded'; if (isCancelled) return 'Cancelled'; if (isSuccess && isDeposit) return 'Complete'; - return transactionDetails?.category ?? 'Unknown'; + return getTransactionCategory(type, title) ?? 'Unknown'; }; const formatTimestamp = () => { diff --git a/components/Unstake/RegularWithdrawForm.tsx b/components/Unstake/RegularWithdrawForm.tsx index cfe8abcf6..6b50897d7 100644 --- a/components/Unstake/RegularWithdrawForm.tsx +++ b/components/Unstake/RegularWithdrawForm.tsx @@ -230,7 +230,7 @@ const RegularWithdrawForm = () => { setModal(UNSTAKE_MODAL.OPEN_TRANSACTION_STATUS); Toast.show({ type: 'success', - text1: 'Swap transaction submitted', + text1: 'Withdrawal transaction submitted', text2: `${data.amount} ${selectedToken?.contractTickerSymbol || 'soUSD'}`, props: { link: `https://etherscan.io/tx/${transaction.transactionHash}`, @@ -243,7 +243,7 @@ const RegularWithdrawForm = () => { } catch (_error) { Toast.show({ type: 'error', - text1: 'Error while swapping', + text1: 'Error while withdrawing', }); } }; diff --git a/components/Wallet/WalletCard.tsx b/components/Wallet/WalletCard.tsx index c3d74bebe..c60a77b9c 100644 --- a/components/Wallet/WalletCard.tsx +++ b/components/Wallet/WalletCard.tsx @@ -1,4 +1,4 @@ -import { Pressable, View } from 'react-native'; +import { Platform, Pressable, View } from 'react-native'; import WalletIcon from '@/assets/images/wallet'; import CountUp from '@/components/CountUp'; @@ -73,7 +73,7 @@ const WalletCard = ({ balance, className, tokens, isLoading, decimalPlaces }: Wa content={ Displaying top three tokens by balance. Wallet can contain any ERC-20 and native - token in Ethereum and Fuse for Swap and Send. + token in Ethereum and Fuse for {Platform.OS === 'ios' ? 'Send' : 'Swap and Send'}. } /> 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..eae058897 --- /dev/null +++ b/constants/alchemy.ts @@ -0,0 +1,30 @@ +import { arbitrum, base, bsc, 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. BSC (56) is Alchemy-only + * (Blockscout has no BSC instance). + */ +export const ALCHEMY_SUPPORTED_CHAIN_IDS: ReadonlySet = new Set([ + mainnet.id, + base.id, + polygon.id, + arbitrum.id, + bsc.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}`, + [bsc.id]: `https://bnb-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/bridge.ts b/constants/bridge.ts index 9f2215bcd..dd7052596 100644 --- a/constants/bridge.ts +++ b/constants/bridge.ts @@ -1,6 +1,6 @@ import { ImageSourcePropType } from 'react-native'; import { NATIVE_TOKEN_ADDRESS } from 'thirdweb'; -import { arbitrum, base, fuse, mainnet, polygon } from 'viem/chains'; +import { arbitrum, base, bsc, fuse, mainnet, polygon } from 'viem/chains'; import { WRAPPED_FUSE } from '@/constants/addresses'; @@ -8,6 +8,7 @@ type BridgeToken = { name?: string; fullName?: string; address: string; + decimals?: number; icon?: ImageSourcePropType; version?: string; isPermit?: boolean; @@ -95,6 +96,14 @@ export const BRIDGE_TOKENS: BridgeTokens = { version: '2', isPermit: false, }, + USDT: { + name: 'USDT', + fullName: 'Tether USD', + address: '0xfde4C96c8593536E31F229EA8f37b2ADa2699bb2', + icon: require('@/assets/images/usdt.png'), + version: '1', + isPermit: false, + }, }, name: 'Base', icon: require('@/assets/images/base.png'), @@ -155,9 +164,32 @@ export const BRIDGE_TOKENS: BridgeTokens = { }, name: 'Fuse', icon: require('@/assets/images/fuse.png'), - sort: 5, + sort: 6, bridgeSpeed: 0, }, + [bsc.id]: { + tokens: { + USDC: { + name: 'USDC', + fullName: 'Binance-Peg USD Coin', + address: '0x8AC76a51cc950d9822D68b83fE1Ad97B32Cd580d', + decimals: 18, + isPermit: false, + }, + USDT: { + name: 'USDT', + fullName: 'Binance-Peg BSC-USD', + address: '0x55d398326f99059fF775485246999027B3197955', + icon: require('@/assets/images/usdt.png'), + decimals: 18, + isPermit: false, + }, + }, + name: 'BNB Chain', + icon: require('@/assets/images/bsc.png'), + sort: 5, + bridgeSpeed: 2, + }, }; export const getUsdcAddress = (chainId: number) => { diff --git a/constants/faqs.tsx b/constants/faqs.tsx index 06063663c..846babc16 100644 --- a/constants/faqs.tsx +++ b/constants/faqs.tsx @@ -19,7 +19,7 @@ const faqs: Faq[] = [ { question: 'What is SoUSD?', answer: - 'SoUSD is a **yield-bearing stablecoin**. Unlike typical 1:1 pegged stablecoins, SoUSD represents your share in a growing vault. Its value **increases over time** as interest accrues — no need to stake or manage positions.\n\nSoUSD is gasless, cross-chain, and composable — ready for use in payments, swaps, lending, and more.', + 'SoUSD is a **yield-bearing stablecoin**. Unlike typical 1:1 pegged stablecoins, SoUSD represents your share in a growing vault. Its value **increases over time** as interest accrues — no need to stake or manage positions.\n\nSoUSD is gasless, cross-chain, and composable — ready for use in payments, lending, and more.', }, { question: "Why didn't I receive 1 SoUSD per 1 USDC?", 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/passkey-faqs.tsx b/constants/passkey-faqs.tsx new file mode 100644 index 000000000..17e953cf1 --- /dev/null +++ b/constants/passkey-faqs.tsx @@ -0,0 +1,26 @@ +import { Faq } from '@/lib/types'; + +const passkeyFaqs: Faq[] = [ + { + question: 'What is a passkey and why is it more secure than a password?', + answer: + "A passkey lets you log into Solid using your device's biometrics (Face ID, fingerprint) or PIN instead of a password. It's stored locally on your device (never with Solid's) and is phishing-resistant by design, making it significantly harder to steal or compromise than a traditional password.", + }, + { + question: "Why can't I see the passkey option when logging in?", + answer: + "Passkeys require a compatible device and browser, make sure you're on a recent version of iOS, Android, Chrome, or Safari.", + }, + { + question: 'Can I use my passkey across multiple devices?', + answer: + "Yes, if you use a password manager that supports passkeys (like iCloud Keychain or Google Password Manager), your passkey syncs across your devices automatically. Otherwise, you'll need to create a separate passkey on each new device.", + }, + { + question: 'What happens if I lose my device?', + answer: + "Your passkey is tied to your device, not your account. If you lose access to your phone, visit the Passkey Recovery link on the login screen, enter your email, and we'll send you a verification code to regain access. Once in, you can set up a new passkey on your new device.", + }, +]; + +export default passkeyFaqs; diff --git a/constants/path.ts b/constants/path.ts index 87d075e2a..b6ea71b27 100644 --- a/constants/path.ts +++ b/constants/path.ts @@ -45,6 +45,8 @@ type Path = { ADD_REFERRER: Href; QUEST_WALLET: Route; QR_SCANNER: Route; + AGENT: Href; + RESCUE_TOKEN: Href; }; export const path: Path = { @@ -94,4 +96,6 @@ 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, + RESCUE_TOKEN: '/rescue-token' as Href, }; diff --git a/constants/tokens.ts b/constants/tokens.ts index b3a7efc07..ab85d56f3 100644 --- a/constants/tokens.ts +++ b/constants/tokens.ts @@ -1,7 +1,7 @@ import { Token } from '@cryptoalgebra/fuse-sdk'; import { ImageSourcePropType } from 'react-native'; -import { base, fuse, mainnet } from 'viem/chains'; +import { arbitrum, base, bsc, fuse, mainnet } from 'viem/chains'; import { BUSD, FUSD_V2, @@ -42,6 +42,8 @@ export const NATIVE_TOKENS: Record = { [mainnet.id]: 'ETH', [fuse.id]: 'fuse-network-token', [base.id]: 'ETH', + [arbitrum.id]: 'ETH', + [bsc.id]: 'BNB', }; /** CoinGecko API coin ids */ @@ -49,6 +51,8 @@ export const NATIVE_COINGECKO_TOKENS: Record = { [mainnet.id]: 'ethereum', [fuse.id]: 'fuse-network-token', [base.id]: 'ethereum', + [arbitrum.id]: 'ethereum', + [bsc.id]: 'binancecoin', }; export const TOKEN_IMAGES: Record = { diff --git a/constants/transaction.ts b/constants/transaction.ts index ffa13af4c..1247ac18b 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,59 @@ 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, + }, + [TransactionType.RESCUE_TOKEN]: { + sign: TransactionDirection.IN, + category: TransactionCategory.WALLET_TRANSFER, + }, }; + +/** + * Resolve the user-facing transaction category. + * + * BRIDGE_DEPOSIT is dual-use: the same type backs both real cross-chain + * bridges (→ "External wallet transfer") and "Deposit … to Card" deposits + * where soUSD/USDC is bridged from Fuse to the card funding address. The + * static map can't tell them apart, so a savings→card deposit was showing as + * "External wallet transfer". Relabel the card variant (title contains + * "Card", matching the backend's title convention for card deposits) as + * "Card deposit". + */ +export const getTransactionCategory = ( + type: TransactionType, + title?: string, +): TransactionCategory | undefined => { + if (type === TransactionType.BRIDGE_DEPOSIT && title?.toLowerCase().includes('card')) { + return TransactionCategory.CARD_DEPOSIT; + } + return TRANSACTION_DETAILS[type]?.category; +}; + +/** + * Card-deposit activity types that bridge from Fuse to the destination (card + * funding / Rain collateral) via Stargate/LayerZero. Their SOURCE-chain receipt + * confirms within seconds, but the funds take minutes to arrive — so a + * source-chain receipt success must NOT mark them complete. The backend + * finalizes them to SUCCESS from the Rain collateral webhook (destination + * confirmation). Used to exclude them from client-side receipt polling, which + * otherwise flipped every card deposit to SUCCESS instantly. + */ +export const SOURCE_RECEIPT_NON_FINAL_TYPES: ReadonlySet = new Set([ + TransactionType.BRIDGE_DEPOSIT, + TransactionType.BORROW_AND_DEPOSIT_TO_CARD, + TransactionType.CARD_DEPOSIT, +]); + +/** + * Whether an activity reaching a successful source-chain receipt can be treated + * as complete. False for cross-chain card deposits (see above). + */ +export const isSourceReceiptFinalizable = (type: TransactionType): boolean => + !SOURCE_RECEIPT_NON_FINAL_TYPES.has(type); diff --git a/hooks/useActivityActions.ts b/hooks/useActivityActions.ts index 53bf09d75..cf63ae229 100644 --- a/hooks/useActivityActions.ts +++ b/hooks/useActivityActions.ts @@ -155,7 +155,8 @@ export function useActivityActions() { withRefreshToken(() => updateActivityEvent(clientTxId, { status: backendStatus, - txHash: updates.hash, + hash: updates.hash, + url: updates.url, userOpHash: updates.userOpHash, metadata: updates.metadata, }), 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 86bdc0acb..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, }); diff --git a/hooks/useBalances.ts b/hooks/useBalances.ts index 8baf9a69f..c8ebd5d86 100644 --- a/hooks/useBalances.ts +++ b/hooks/useBalances.ts @@ -1,11 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import { formatUnits, parseUnits, zeroAddress } from 'viem'; import { getBalance, readContract } from 'viem/actions'; -import { base, fuse, mainnet } from 'viem/chains'; +import { arbitrum, base, bsc, 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; @@ -61,6 +60,7 @@ interface BalanceData { polygonTokens: TokenBalance[]; baseTokens: TokenBalance[]; arbitrumTokens: TokenBalance[]; + bscTokens: TokenBalance[]; tokens: TokenBalance[]; unifiedTokens: UnifiedTokenBalance[]; isLoading: boolean; @@ -76,6 +76,7 @@ const FUSE_CHAIN_ID = 122; const POLYGON_CHAIN_ID = 137; const BASE_CHAIN_ID = 8453; const ARBITRUM_CHAIN_ID = 42161; +const BSC_CHAIN_ID = 56; // ABI for AccountantWithRateProviders getRate function const ACCOUNTANT_ABI = [ @@ -107,31 +108,31 @@ const fetchTokenBalances = async (safeAddress: string) => { fuseResponse, polygonResponse, arbitrumResponse, + bscResponse, soUSDRate, soFUSERate, ethBalance, fuseBalance, baseBalance, + arbitrumBalance, + bscBalance, ethPrice, fusePrice, basePrice, + arbitrumPrice, + bscPrice, 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. BSC (56) + // is Alchemy-only — Blockscout has no BSC instance and returns [] on + // Alchemy failure. + 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), + fetchTokenBalancesWithFallback(BSC_CHAIN_ID, safeAddress), readContract(publicClient(mainnet.id), { address: ADDRESSES.ethereum.accountant, abi: ACCOUNTANT_ABI, @@ -151,9 +152,17 @@ const fetchTokenBalances = async (safeAddress: string) => { getBalance(publicClient(base.id), { address: safeAddress as `0x${string}`, }), + getBalance(publicClient(arbitrum.id), { + address: safeAddress as `0x${string}`, + }), + getBalance(publicClient(bsc.id), { + address: safeAddress as `0x${string}`, + }), fetchTokenPriceUsd(NATIVE_TOKENS[mainnet.id]), fetchTokenPriceUsd(NATIVE_TOKENS[fuse.id]), fetchTokenPriceUsd(NATIVE_TOKENS[base.id]), + fetchTokenPriceUsd(NATIVE_TOKENS[arbitrum.id]), + fetchTokenPriceUsd(NATIVE_TOKENS[bsc.id]), fetchTokenList({ isActive: true, }), @@ -164,6 +173,7 @@ const fetchTokenBalances = async (safeAddress: string) => { let polygonTokens: TokenBalance[] = []; let baseTokens: TokenBalance[] = []; let arbitrumTokens: TokenBalance[] = []; + let bscTokens: TokenBalance[] = []; let soUSDRateNum = 0; let soFUSEQuoteRateUSD = 0; @@ -235,80 +245,84 @@ 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); } + // Process BSC tokens + if (bscResponse.status === PromiseStatus.FULFILLED) { + bscTokens = bscResponse.value + .filter( + item => + item.token.type === TokenType.ERC20 && + filterTokenList(tokenListData, BSC_CHAIN_ID, getAddress(item)), + ) + .map(item => convertBlockscoutToTokenBalance(item, BSC_CHAIN_ID)); + } else { + console.warn('Failed to fetch BSC balances:', bscResponse.reason); + } + // Process native token balances if (ethBalance.status === PromiseStatus.FULFILLED && Number(ethBalance.value)) { const ethPriceValue = ethPrice.status === PromiseStatus.FULFILLED ? Number(ethPrice.value) : 0; @@ -374,7 +388,56 @@ const fetchTokenBalances = async (safeAddress: string) => { }); } - let allTokens = [...ethereumTokens, ...fuseTokens, ...polygonTokens, ...baseTokens, ...arbitrumTokens]; + if (arbitrumBalance.status === PromiseStatus.FULFILLED && Number(arbitrumBalance.value)) { + const arbitrumPriceValue = + arbitrumPrice.status === PromiseStatus.FULFILLED ? Number(arbitrumPrice.value) : 0; + const arbitrumEthTokenFromList = tokenListData.find( + token => token.chainId === ARBITRUM_CHAIN_ID && token.symbol === 'ETH', + ); + arbitrumTokens.push({ + contractTickerSymbol: 'ETH', + contractName: 'Ether', + contractAddress: zeroAddress, + balance: arbitrumBalance.value.toString(), + quoteRate: arbitrumPriceValue, + contractDecimals: 18, + type: TokenType.NATIVE, + verified: true, + chainId: ARBITRUM_CHAIN_ID, + commonId: arbitrumEthTokenFromList?.commonId, + tokenId: arbitrumEthTokenFromList?.tokenId, + }); + } + + if (bscBalance.status === PromiseStatus.FULFILLED && Number(bscBalance.value)) { + const bscPriceValue = + bscPrice.status === PromiseStatus.FULFILLED ? Number(bscPrice.value) : 0; + const bscBnbTokenFromList = tokenListData.find( + token => token.chainId === BSC_CHAIN_ID && token.symbol === 'BNB', + ); + bscTokens.push({ + contractTickerSymbol: 'BNB', + contractName: 'BNB', + contractAddress: zeroAddress, + balance: bscBalance.value.toString(), + quoteRate: bscPriceValue, + contractDecimals: 18, + type: TokenType.NATIVE, + verified: true, + chainId: BSC_CHAIN_ID, + commonId: bscBnbTokenFromList?.commonId, + tokenId: bscBnbTokenFromList?.tokenId, + }); + } + + let allTokens = [ + ...ethereumTokens, + ...fuseTokens, + ...polygonTokens, + ...baseTokens, + ...arbitrumTokens, + ...bscTokens, + ]; const isZeroRate = (r: number | null | undefined) => r == null || r === 0 || (typeof r === 'number' && Number.isNaN(r)); @@ -531,6 +594,7 @@ const fetchTokenBalances = async (safeAddress: string) => { const polygonTokensFinal = allTokens.filter(t => t.chainId === POLYGON_CHAIN_ID); const baseTokensFinal = allTokens.filter(t => t.chainId === BASE_CHAIN_ID); const arbitrumTokensFinal = allTokens.filter(t => t.chainId === ARBITRUM_CHAIN_ID); + const bscTokensFinal = allTokens.filter(t => t.chainId === BSC_CHAIN_ID); return { ...totals, @@ -539,6 +603,7 @@ const fetchTokenBalances = async (safeAddress: string) => { polygonTokens: polygonTokensFinal, baseTokens: baseTokensFinal, arbitrumTokens: arbitrumTokensFinal, + bscTokens: bscTokensFinal, tokens: allTokens, unifiedTokens, }; @@ -575,6 +640,7 @@ export const useBalances = (): BalanceData => { polygonTokens: [], baseTokens: [], arbitrumTokens: [], + bscTokens: [], tokens: [], unifiedTokens: [], }; 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..6e409f4f2 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,75 +125,71 @@ 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, - from_chain: fuse.id, - to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, - source: 'useBridgeToCard', - }); + // Amplitude is emitted server-side as "Card Borrow Completed" (backend + // CardWelcomeBonusCron, once the BORROW_AND_DEPOSIT_TO_CARD activity + // settles on the card funding chain); suppress the client Amplitude event + // to avoid double-counting. Firebase + GTM still fire. Note: the generic + // bridge/swap-to-card hooks (useBridgeToCard, useSwapAndBridgeToCard) are + // NOT borrows and keep firing Amplitude. + track( + TRACKING_EVENTS.BRIDGE_TO_ARBITRUM_COMPLETED, + { + amount: amountToBorrow, + transaction_hash: transactionResult.transactionHash, + from_chain: fuse.id, + to_chain: EXPO_PUBLIC_CARD_FUNDING_CHAIN_ID, + source: 'useBridgeToCard', + }, + { amplitude: false }, + ); Sentry.addBreadcrumb({ message: 'Bridge to Card transaction successful', 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/useCardDepositPoller.ts b/hooks/useCardDepositPoller.ts index 3dce6dda5..98c3c27c8 100644 --- a/hooks/useCardDepositPoller.ts +++ b/hooks/useCardDepositPoller.ts @@ -125,14 +125,21 @@ export const useCardDepositPoller = () => { // Track deposit completed via fallback mechanism // Note: This should rarely trigger since SSE is the primary update mechanism // If this fires frequently, investigate SSE reliability - track(TRACKING_EVENTS.CARD_DEPOSIT_COMPLETED, { - amount: Number(activity.amount), - token_symbol: activity.symbol, - chain_id: activity.chainId, - tx_hash: activity.hash, - // Flag to identify updates from fallback vs SSE in analytics - source: 'fallback_poller', - }); + // Amplitude is emitted server-side as "Card Deposit Completed" (backend + // connect-wallet deposit workflow, CARD category); suppress client + // Amplitude to avoid double-counting. Firebase + GTM still fire. + track( + TRACKING_EVENTS.CARD_DEPOSIT_COMPLETED, + { + amount: Number(activity.amount), + token_symbol: activity.symbol, + chain_id: activity.chainId, + tx_hash: activity.hash, + // Flag to identify updates from fallback vs SSE in analytics + source: 'fallback_poller', + }, + { amplitude: false }, + ); }), ); 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..da9940ba9 100644 --- a/hooks/useCardSteps/kycDisplayHelpers.ts +++ b/hooks/useCardSteps/kycDisplayHelpers.ts @@ -5,6 +5,7 @@ import { BridgeRejectionReason, CardProvider, KycStatus, + KycWarning, RainApplicationStatus, } from '@/lib/types'; @@ -72,32 +73,60 @@ const DIDIT_WARNING_DESCRIPTIONS: Record = { SCREEN_CAPTURE_DETECTED: 'A photo of a screen was detected — please use the original document', PRINTED_COPY_DETECTED: 'A printed copy was detected — please use the original document', PORTRAIT_MANIPULATION_DETECTED: 'The portrait on the document appears to have been altered', - POSSIBLE_DUPLICATED_USER: 'A duplicate account was detected', + POSSIBLE_DUPLICATED_USER: 'This identity is already linked to another verified account', + DUPLICATED_IP: 'This network has already been used to verify another account', + DUPLICATED_DEVICE: 'This device has already been used to verify another account', + DUPLICATED_DEVICE_FINGERPRINT: 'This device has already been used to verify another account', DOCUMENT_NUMBER_NOT_DETECTED: 'Document number could not be read', NAME_NOT_DETECTED: 'Name could not be read from the document', DATE_OF_BIRTH_NOT_DETECTED: 'Date of birth could not be read from the document', 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()); } -function formatKycWarnings(warnings: string[]): string { - if (warnings.length === 0) return ''; - return warnings.map(formatDiditWarning).join('\n- '); +/** + * 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: 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: @@ -107,15 +136,20 @@ export function getKYCDescription(rainApplicationStatus?: RainApplicationStatus case RainApplicationStatus.MANUAL_REVIEW: return "Your application is being reviewed by our team. We'll update you when a decision is reached."; case RainApplicationStatus.DENIED: - return "We couldn't verify your identity. Please contact support for more information."; + return "We couldn't verify your identity."; case RainApplicationStatus.LOCKED: return 'Your application is on hold. Contact support for assistance.'; case RainApplicationStatus.CANCELED: 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; @@ -136,6 +170,8 @@ export function getKYCButtonText( case RainApplicationStatus.MANUAL_REVIEW: return 'Under Review'; case RainApplicationStatus.DENIED: + // Final decision — verification cannot be overridden or resubmitted, so no action button. + return undefined; case RainApplicationStatus.LOCKED: case RainApplicationStatus.CANCELED: return 'Contact support'; @@ -156,11 +192,14 @@ export function isRainKYCButtonDisabled( rainApplicationStatus?: RainApplicationStatus | null, ): boolean { if (!rainApplicationStatus) return false; - // DENIED/LOCKED/CANCELED show "Contact support" and open Intercom — keep enabled + // No actionable button for APPROVED (step complete), PENDING/MANUAL_REVIEW (under review), + // or DENIED (final decision — cannot override or resubmit). + // LOCKED/CANCELED keep an enabled "Contact support" button that opens Intercom. return ( rainApplicationStatus === RainApplicationStatus.APPROVED || rainApplicationStatus === RainApplicationStatus.PENDING || - rainApplicationStatus === RainApplicationStatus.MANUAL_REVIEW + rainApplicationStatus === RainApplicationStatus.MANUAL_REVIEW || + rainApplicationStatus === RainApplicationStatus.DENIED ); } @@ -178,7 +217,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,30 +225,39 @@ 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) { - return `We couldn't verify your identity:\n- ${formatKycWarnings(warnings)}`; + const formatted = formatKycWarnings(warnings); + if (formatted) { + return `We couldn't verify your identity:\n- ${formatted}`; } - return 'Your identity verification was declined. Please try again with a valid ID.'; + return 'Your identity verification was declined.'; } // Didit resubmission or incomplete (including didit_forward_failed) — show reasons if available if (options?.kycStatus === KycStatus.INCOMPLETE && !isRecognizedRainStatus) { - if (warnings.length > 0) { - return `Additional verification required:\n- ${formatKycWarnings(warnings)}`; + const formatted = formatKycWarnings(warnings); + if (formatted) { + return `Additional verification required:\n- ${formatted}`; } return 'Additional verification steps are required. Please continue to complete the process.'; } - // Didit under review — user should wait + // Didit under review — user should wait. Duplicate IP/device filters land here + // (Didit sets these to "review" so the suspicious applicant doesn't reach Rain). + // When warnings are attached, surface them so the user knows what is being checked + // instead of a generic "few minutes" copy for what is actually a manual review. if (options?.kycStatus === KycStatus.UNDER_REVIEW && !isRecognizedRainStatus) { + const formatted = formatKycWarnings(warnings); + if (formatted) { + return `Your application is under additional review:\n- ${formatted}\n\nWe'll update you when the review is complete.`; + } return 'Your information is being reviewed. This usually takes a few minutes.'; } @@ -298,9 +346,9 @@ export function getStepButtonText( return getKYCButtonText(options.rainApplicationStatus); } - // Didit KYC rejected — allow retry + // Didit KYC rejected — final decision; cannot be overridden or resubmitted, so no action button. if (options?.kycStatus === KycStatus.REJECTED) { - return 'Retry KYC'; + return undefined; } // Didit incomplete — user needs to continue @@ -364,6 +412,11 @@ export function isStepButtonDisabled( return true; } + // Didit rejected — final decision, no action available + if (options?.kycStatus === KycStatus.REJECTED && !isRecognizedRainStatus) { + return true; + } + if (!cardsEndorsement) { return false; } 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..c21900dbb 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(() => { @@ -206,11 +196,9 @@ export function useCardSteps( const status = cardStatusResponse?.rainApplicationStatus; const link = cardStatusResponse?.applicationExternalVerificationLink; - if ( - status === RainApplicationStatus.DENIED || - status === RainApplicationStatus.LOCKED || - status === RainApplicationStatus.CANCELED - ) { + // DENIED is a final decision with no action — it renders no button, so it is not + // handled here. LOCKED/CANCELED still offer a "Contact support" button. + if (status === RainApplicationStatus.LOCKED || status === RainApplicationStatus.CANCELED) { openIntercom(); return; } @@ -256,7 +244,7 @@ export function useCardSteps( cardStatusResponse?.activationBlocked, cardStatusResponse?.activationBlockedReason, handleProceedToKyc, - handleActivateCard, + pushCardReady, pushCardDetails, { cardIssuer, @@ -276,7 +264,7 @@ export function useCardSteps( cardStatusResponse?.kycStatus, cardStatusResponse?.kycWarnings, handleProceedToKyc, - handleActivateCard, + pushCardReady, pushCardDetails, cardIssuer, handleRainKYCPress, diff --git a/hooks/useDepositFromEOA.ts b/hooks/useDepositFromEOA.ts index e6813f66e..87db4382c 100644 --- a/hooks/useDepositFromEOA.ts +++ b/hooks/useDepositFromEOA.ts @@ -425,7 +425,13 @@ const useDepositFromEOA = ( }, }); - const amountWei = parseUnits(amount, 6); + // Source token decimals (defaults to 6). Some chains' USDC is not 6 + // decimals (e.g. Binance-Peg USDC on BNB Chain is 18), so derive it from + // the bridge config rather than hardcoding. + const srcDecimals = + Object.values(BRIDGE_TOKENS[srcChainId]?.tokens ?? {}).find(t => t.name === token) + ?.decimals ?? 6; + const amountWei = parseUnits(amount, srcDecimals); let txHash: `0x${string}` | undefined; let transaction: { transactionHash: `0x${string}` } | undefined = { @@ -785,21 +791,28 @@ const useDepositFromEOA = ( }); // Track deposit success with attribution for ROI measurement - track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { - user_id: user?.userId, - safe_address: user?.safeAddress, - eoa_address: eoaAddress, - amount, - transaction_hash: txHash, - deposit_type: 'connected_wallet', - deposit_method: isEthereum ? 'ethereum_direct' : 'cross_chain_bridge', - chain_id: srcChainId, - chain_name: isEthereum ? 'ethereum' : BRIDGE_TOKENS[srcChainId]?.name, - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + // Amplitude is emitted server-side as "Savings Deposit Completed" (backend + // connect-wallet deposit workflow); suppress the client Amplitude event to + // avoid double-counting. Firebase + GTM still fire for web attribution. + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + eoa_address: eoaAddress, + amount, + transaction_hash: txHash, + deposit_type: 'connected_wallet', + deposit_method: isEthereum ? 'ethereum_direct' : 'cross_chain_bridge', + chain_id: srcChainId, + chain_name: isEthereum ? 'ethereum' : BRIDGE_TOKENS[srcChainId]?.name, + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); trackIdentity(user?.userId, { last_deposit_amount: parseFloat(amount), diff --git a/hooks/useDepositFromEOAEth.ts b/hooks/useDepositFromEOAEth.ts index def8caeeb..d57d9e142 100644 --- a/hooks/useDepositFromEOAEth.ts +++ b/hooks/useDepositFromEOAEth.ts @@ -274,21 +274,28 @@ const useDepositFromEOAEth = ( }, }); - track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { - user_id: user?.userId, - safe_address: user?.safeAddress, - eoa_address: eoaAddress, - amount, - transaction_hash: txHash, - deposit_type: 'connected_wallet', - deposit_method: 'eth_direct', - chain_id: srcChainId, - chain_name: 'ethereum', - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + // Amplitude emitted server-side as "Savings Deposit Completed"; + // suppress client Amplitude to avoid double-counting (Firebase + GTM + // still fire). + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + eoa_address: eoaAddress, + amount, + transaction_hash: txHash, + deposit_type: 'connected_wallet', + deposit_method: 'eth_direct', + chain_id: srcChainId, + chain_name: 'ethereum', + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); trackIdentity(user?.userId, { last_deposit_amount: parseFloat(amount), @@ -416,21 +423,28 @@ const useDepositFromEOAEth = ( }, }); - track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { - user_id: user?.userId, - safe_address: user?.safeAddress, - eoa_address: eoaAddress, - amount, - transaction_hash: txHash, - deposit_type: 'connected_wallet', - deposit_method: 'eth_direct', - chain_id: srcChainId, - chain_name: 'ethereum', - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + // Amplitude emitted server-side as "Savings Deposit Completed"; + // suppress client Amplitude to avoid double-counting (Firebase + GTM + // still fire). + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + eoa_address: eoaAddress, + amount, + transaction_hash: txHash, + deposit_type: 'connected_wallet', + deposit_method: 'eth_direct', + chain_id: srcChainId, + chain_name: 'ethereum', + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); trackIdentity(user?.userId, { last_deposit_amount: parseFloat(amount), diff --git a/hooks/useDepositFromEOAFuse.ts b/hooks/useDepositFromEOAFuse.ts index 381c16a0c..36a0fd40f 100644 --- a/hooks/useDepositFromEOAFuse.ts +++ b/hooks/useDepositFromEOAFuse.ts @@ -275,21 +275,28 @@ const useDepositFromEOAFuse = ( }); // Track deposit success with attribution for ROI measurement - track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { - user_id: user?.userId, - safe_address: user?.safeAddress, - eoa_address: eoaAddress, - amount, - transaction_hash: txHash, - deposit_type: 'connected_wallet', - deposit_method: 'fuse_direct', - chain_id: srcChainId, - chain_name: 'fuse', - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + // Amplitude emitted server-side as "Savings Deposit Completed"; + // suppress client Amplitude to avoid double-counting (Firebase + GTM + // still fire). + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + eoa_address: eoaAddress, + amount, + transaction_hash: txHash, + deposit_type: 'connected_wallet', + deposit_method: 'fuse_direct', + chain_id: srcChainId, + chain_name: 'fuse', + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); trackIdentity(user?.userId, { last_deposit_amount: parseFloat(amount), @@ -395,21 +402,27 @@ const useDepositFromEOAFuse = ( }, }); - track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { - user_id: user?.userId, - safe_address: user?.safeAddress, - eoa_address: eoaAddress, - amount, - transaction_hash: txHash, - deposit_type: 'connected_wallet', - deposit_method: 'fuse_direct', - chain_id: srcChainId, - chain_name: 'fuse', - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + // Amplitude emitted server-side as "Savings Deposit Completed"; suppress + // client Amplitude to avoid double-counting (Firebase + GTM still fire). + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + eoa_address: eoaAddress, + amount, + transaction_hash: txHash, + deposit_type: 'connected_wallet', + deposit_method: 'fuse_direct', + chain_id: srcChainId, + chain_name: 'fuse', + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); trackIdentity(user?.userId, { last_deposit_amount: parseFloat(amount), diff --git a/hooks/useDepositFromSolidEth.ts b/hooks/useDepositFromSolidEth.ts index 3fab893c6..d48abdf9e 100644 --- a/hooks/useDepositFromSolidEth.ts +++ b/hooks/useDepositFromSolidEth.ts @@ -242,18 +242,25 @@ const useDepositFromSolidEth = ( data: { amount, safeAddress, srcChainId, isSponsor }, }); - track(TRACKING_EVENTS.DEPOSIT_COMPLETED, { - user_id: user?.userId, - safe_address: user?.safeAddress, - amount, - deposit_type: 'solid_wallet', - deposit_method: 'eth_solid', - chain_id: srcChainId, - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + // Amplitude emitted server-side as "Savings Deposit Completed"; + // suppress client Amplitude to avoid double-counting (Firebase + GTM + // still fire). + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + amount, + deposit_type: 'solid_wallet', + deposit_method: 'eth_solid', + chain_id: srcChainId, + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); trackIdentity(user?.userId!, { last_deposit_amount: parseFloat(amount), diff --git a/hooks/useDepositFromSolidUsdc.ts b/hooks/useDepositFromSolidUsdc.ts index ca4fb9a74..cf4e67298 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,24 +238,45 @@ const useDepositFromSolidUsdc = ( data: { amount, safeAddress, srcChainId, isSponsor }, }); - 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', - chain_id: srcChainId, - is_sponsor: isSponsor, - is_first_deposit: !user?.isDeposited, - ...attributionData, - attribution_channel: attributionChannel, - }); + const depositMethod = isTargetChain + ? isCard + ? 'usdc_solid_base_card' + : 'usdc_solid_ethereum' + : isCard + ? 'usdc_solid_bridge_card' + : 'usdc_solid_bridge'; + + // Amplitude emitted server-side by the backend connect-wallet deposit + // workflow ("Savings Deposit Completed" for savings, "Card Deposit + // Completed" for card); suppress client Amplitude to avoid double- + // counting. Firebase + GTM still fire for web attribution. + track( + TRACKING_EVENTS.DEPOSIT_COMPLETED, + { + user_id: user?.userId, + safe_address: user?.safeAddress, + amount, + deposit_type: 'solid_wallet', + deposit_method: depositMethod, + deposit_destination: isCard ? 'card' : 'savings', + chain_id: srcChainId, + is_sponsor: isSponsor, + is_first_deposit: !user?.isDeposited, + ...attributionData, + attribution_channel: attributionChannel, + }, + { amplitude: false }, + ); 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/usePushNotifications.ts b/hooks/usePushNotifications.ts index dd2eea30a..3f830989a 100644 --- a/hooks/usePushNotifications.ts +++ b/hooks/usePushNotifications.ts @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { Platform } from 'react-native'; import * as Notifications from 'expo-notifications'; -import { useRouter } from 'expo-router'; +import { Href, useRouter } from 'expo-router'; import messaging from '@react-native-firebase/messaging'; import { path } from '@/constants/path'; @@ -9,6 +9,19 @@ import { registerPushToken } from '@/lib/api'; import { registerForPushNotificationsAsync } from '@/lib/registerForPushNotifications'; import { useUserStore } from '@/store/useUserStore'; +/** + * Map a push notification's `type` (set by the backend) to an in-app route. + * Card payment notifications open the card screen; anything else goes home. + */ +function getNotificationRoute(type?: string): Href { + switch (type) { + case 'card-transaction': + return path.CARD; + default: + return path.HOME; + } +} + /** * Manages push notification lifecycle: token refresh and notification tap handling. * Must be mounted inside the root layout so listeners are active for the entire session. @@ -39,12 +52,13 @@ export function usePushNotifications() { } }); - // Handle notification taps (user taps a notification from the system tray) + // Handle notification taps (user taps a notification from the system tray). + // Deep-link based on the `type` the backend set in the notification data; + // fall back to home for anything unrecognised. const notificationResponseSubscription = Notifications.addNotificationResponseReceivedListener( - _response => { - // Future: read _response.notification.request.content.data.route for deep linking - // e.g., router.replace(_response.notification.request.content.data.route as any); - router.replace(path.HOME); + response => { + const data = response.notification.request.content.data as { type?: string } | undefined; + router.replace(getNotificationRoute(data?.type)); }, ); 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/useRescueToken.ts b/hooks/useRescueToken.ts new file mode 100644 index 000000000..2fc693bcc --- /dev/null +++ b/hooks/useRescueToken.ts @@ -0,0 +1,177 @@ +import { useCallback, useState } from 'react'; +import * as Sentry from '@sentry/react-native'; +import { StamperType, useTurnkey } from '@turnkey/react-native-wallet-kit'; +import { createAccount } from '@turnkey/viem'; +import { + Address, + createWalletClient, + erc20Abi, + formatUnits, + hashTypedData, + http, +} from 'viem'; +import { mainnet } from 'viem/chains'; + +import { ADDRESSES } from '@/lib/config'; +import { Status, TransactionStatus, TransactionType } from '@/lib/types'; +import { publicClient, rpcUrls } from '@/lib/wagmi'; + +import { useActivityActions } from './useActivityActions'; +import useUser from './useUser'; + +const USDC_DECIMALS = 6; +const RESCUE_CHAIN_ID = mainnet.id; +const RESCUE_EXPLORER = 'https://etherscan.io'; + +type RescueResult = { + transactionHash: `0x${string}`; + clientTxId: string; +}; + +type UseRescueTokenReturn = { + rescue: (amountWei: bigint) => Promise; + status: Status; + error: string | null; + reset: () => void; +}; + +const useRescueToken = (): UseRescueTokenReturn => { + const { user } = useUser(); + const { createHttpClient } = useTurnkey(); + const { createActivity, updateActivity } = useActivityActions(); + const [status, setStatus] = useState(Status.IDLE); + const [error, setError] = useState(null); + + const reset = useCallback(() => { + setStatus(Status.IDLE); + setError(null); + }, []); + + const rescue = useCallback( + async (amountWei: bigint): Promise => { + if (!user?.walletAddress) throw new Error('Wallet address not found'); + if (!user?.safeAddress) throw new Error('Safe address not found'); + if (!user?.suborgId) throw new Error('Sub-organization not found'); + if (amountWei <= 0n) throw new Error('Amount must be greater than 0'); + + const amount = formatUnits(amountWei, USDC_DECIMALS); + + const clientTxId = await createActivity({ + type: TransactionType.RESCUE_TOKEN, + title: `Rescue ${amount} USDC`, + shortTitle: 'Rescue', + amount, + symbol: 'USDC', + chainId: RESCUE_CHAIN_ID, + fromAddress: user.walletAddress, + toAddress: user.safeAddress, + status: TransactionStatus.PENDING, + metadata: { + description: `Rescue ${amount} USDC from signer wallet to Solid wallet`, + tokenAddress: ADDRESSES.ethereum.usdc, + tokenDecimals: USDC_DECIMALS.toString(), + }, + }); + + try { + setStatus(Status.PENDING); + setError(null); + + const passkeyClient = createHttpClient({ + defaultStamperType: StamperType.Passkey, + }); + + const turnkeyAccount = await createAccount({ + client: passkeyClient, + organizationId: user.suborgId, + signWith: user.walletAddress, + }); + + // Same workaround as useUser.safeAA: route signTypedData through raw sign + if (turnkeyAccount.sign) { + const originalSign = turnkeyAccount.sign.bind(turnkeyAccount); + turnkeyAccount.signTypedData = async (typedData: any) => { + const hash = hashTypedData(typedData); + return originalSign({ hash }); + }; + } + + const walletClient = createWalletClient({ + account: turnkeyAccount, + chain: mainnet, + transport: http(rpcUrls[RESCUE_CHAIN_ID]), + }); + + const txHash = await walletClient.writeContract({ + address: ADDRESSES.ethereum.usdc, + abi: erc20Abi, + functionName: 'transfer', + args: [user.safeAddress as Address, amountWei], + }); + + await updateActivity(clientTxId, { + status: TransactionStatus.PROCESSING, + hash: txHash, + url: `${RESCUE_EXPLORER}/tx/${txHash}`, + metadata: { submittedAt: new Date().toISOString() }, + }); + + const receipt = await publicClient(RESCUE_CHAIN_ID).waitForTransactionReceipt({ + hash: txHash, + }); + + if (receipt.status !== 'success') { + await updateActivity(clientTxId, { + status: TransactionStatus.FAILED, + hash: txHash, + url: `${RESCUE_EXPLORER}/tx/${txHash}`, + metadata: { + error: 'Transaction reverted on-chain', + failedAt: new Date().toISOString(), + }, + }); + throw new Error('Rescue transaction reverted on-chain'); + } + + await updateActivity(clientTxId, { + status: TransactionStatus.SUCCESS, + hash: txHash, + url: `${RESCUE_EXPLORER}/tx/${txHash}`, + metadata: { confirmedAt: new Date().toISOString() }, + }); + + setStatus(Status.SUCCESS); + return { transactionHash: txHash, clientTxId }; + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to rescue tokens'; + + await updateActivity(clientTxId, { + status: TransactionStatus.FAILED, + metadata: { + error: message, + failedAt: new Date().toISOString(), + }, + }); + + Sentry.captureException(err, { + tags: { type: 'rescue_token_error' }, + extra: { + amountWei: amountWei.toString(), + walletAddress: user.walletAddress, + safeAddress: user.safeAddress, + clientTxId, + }, + user: { id: user.userId, address: user.safeAddress }, + }); + setStatus(Status.ERROR); + setError(message); + throw err; + } + }, + [createActivity, updateActivity, createHttpClient, user], + ); + + return { rescue, status, error, reset }; +}; + +export default useRescueToken; diff --git a/hooks/useRewards.ts b/hooks/useRewards.ts index 726f0c92c..1783f57d1 100644 --- a/hooks/useRewards.ts +++ b/hooks/useRewards.ts @@ -1,12 +1,7 @@ import { useQuery } from '@tanstack/react-query'; import { minutesToMilliseconds, secondsToMilliseconds } from 'date-fns'; -import { - fetchRewardsConfig, - fetchRewardsUserData, - fetchTierBenefits, - getJWTToken, -} from '@/lib/api'; +import { fetchRewardsConfig, fetchRewardsUserData, fetchTierBenefits } from '@/lib/api'; import { withRefreshToken } from '@/lib/utils'; const REWARDS = 'rewards'; @@ -17,7 +12,6 @@ export const useRewardsUserData = () => { queryFn: async () => { return await withRefreshToken(() => fetchRewardsUserData()); }, - enabled: !!getJWTToken(), staleTime: secondsToMilliseconds(30), gcTime: secondsToMilliseconds(300), }); @@ -25,7 +19,6 @@ export const useRewardsUserData = () => { export const useTierBenefits = () => { return useQuery({ - enabled: !!getJWTToken(), queryKey: [REWARDS, 'tierBenefits'], queryFn: fetchTierBenefits, staleTime: secondsToMilliseconds(60), @@ -34,7 +27,6 @@ export const useTierBenefits = () => { export const useRewardsConfig = () => { return useQuery({ - enabled: !!getJWTToken(), queryKey: [REWARDS, 'config'], queryFn: fetchRewardsConfig, staleTime: minutesToMilliseconds(5), diff --git a/hooks/useTrackUserPlatform.ts b/hooks/useTrackUserPlatform.ts new file mode 100644 index 000000000..cfb5fcecd --- /dev/null +++ b/hooks/useTrackUserPlatform.ts @@ -0,0 +1,32 @@ +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; + +import { trackUserPlatform } from '@/lib/api'; +import { useUserStore } from '@/store/useUserStore'; + +const SUPPORTED_PLATFORMS = new Set(['ios', 'android', 'web']); + +/** + * Records the current platform (ios/android/web) on the authenticated user's + * `platforms` array so backend quest checks (e.g. Layer3 "download the native + * app") can verify which surfaces a user has actually opened the app from. + * Fires once per app launch per authenticated session. + */ +export function useTrackUserPlatform() { + const isAuthenticated = useUserStore(state => + state.users.some(u => u.selected && !!u.tokens?.accessToken), + ); + const hasTrackedRef = useRef(false); + + useEffect(() => { + if (!isAuthenticated) return; + if (hasTrackedRef.current) return; + if (!SUPPORTED_PLATFORMS.has(Platform.OS)) return; + + hasTrackedRef.current = true; + trackUserPlatform(Platform.OS).catch(err => { + hasTrackedRef.current = false; + console.warn('Failed to track user platform:', err); + }); + }, [isAuthenticated]); +} diff --git a/hooks/useTransactionReceiptPolling.ts b/hooks/useTransactionReceiptPolling.ts index 0a4e8a94a..865ffb155 100644 --- a/hooks/useTransactionReceiptPolling.ts +++ b/hooks/useTransactionReceiptPolling.ts @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef } from 'react'; import { useQueries, useQuery } from '@tanstack/react-query'; import { getTransactionReceipt } from 'viem/actions'; +import { isSourceReceiptFinalizable } from '@/constants/transaction'; import { useActivityActions } from '@/hooks/useActivityActions'; import { ActivityEvent, TransactionStatus } from '@/lib/types'; import { publicClient } from '@/lib/wagmi'; @@ -17,7 +18,11 @@ export const useTransactionReceiptPolling = (activity: ActivityEvent | null | un !!activity && activity.status === TransactionStatus.PROCESSING && !!activity.hash && - !!activity.chainId; + !!activity.chainId && + // Cross-chain card deposits stay PROCESSING after the source tx mines — + // the bridge to the card funding address takes minutes and is confirmed by + // the Rain collateral webhook, not the source-chain receipt. + isSourceReceiptFinalizable(activity.type); return useQuery({ queryKey: ['tx-receipt-poll', activity?.clientTxId, activity?.hash], @@ -70,6 +75,9 @@ export const useProcessingActivitiesPolling = (activities: ActivityEvent[]) => { a.status === TransactionStatus.PROCESSING && a.hash && a.chainId && + // Don't finalize cross-chain card deposits on their source-chain + // receipt; they complete via the Rain collateral webhook. + isSourceReceiptFinalizable(a.type) && !confirmedRef.current.has(a.clientTxId), ), [activities], diff --git a/hooks/useTransferToWallet.ts b/hooks/useTransferToWallet.ts index b99ed0b8e..c29ca618c 100644 --- a/hooks/useTransferToWallet.ts +++ b/hooks/useTransferToWallet.ts @@ -117,9 +117,14 @@ const useTransferToWallet = ( data: { amount, eoaAddress, safeAddress, srcChainId, token }, }); - // Determine decimals based on token + // Determine decimals from the bridge config; some chains use 18-decimal + // stablecoins (e.g. Binance-Peg USDC/USDT on BNB Chain). Fall back to the + // stablecoin/native default when the token has no explicit decimals. const isStablecoin = token === 'USDC' || token === 'USDT'; - const decimals = isStablecoin ? 6 : 18; + const tokenConfig = Object.values(BRIDGE_TOKENS[srcChainId]?.tokens ?? {}).find( + t => t.name === token, + ); + const decimals = tokenConfig?.decimals ?? (isStablecoin ? 6 : 18); const amountWei = parseUnits(amount, decimals); // Switch to the correct chain @@ -184,7 +189,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/hooks/useWalletTokens.ts b/hooks/useWalletTokens.ts index 5c73fc72b..f18d603f3 100644 --- a/hooks/useWalletTokens.ts +++ b/hooks/useWalletTokens.ts @@ -19,6 +19,7 @@ export const useWalletTokens = () => { polygonTokens, baseTokens, arbitrumTokens, + bscTokens, tokens, unifiedTokens, isLoading, @@ -34,8 +35,16 @@ export const useWalletTokens = () => { fuseTokens.length > 0 || polygonTokens.length > 0 || baseTokens.length > 0 || - arbitrumTokens.length > 0, - [ethereumTokens.length, fuseTokens.length, polygonTokens.length, baseTokens.length, arbitrumTokens.length], + arbitrumTokens.length > 0 || + bscTokens.length > 0, + [ + ethereumTokens.length, + fuseTokens.length, + polygonTokens.length, + baseTokens.length, + arbitrumTokens.length, + bscTokens.length, + ], ); const uniqueTokens = useMemo( @@ -69,6 +78,7 @@ export const useWalletTokens = () => { polygonTokens, baseTokens, arbitrumTokens, + bscTokens, tokens, unifiedTokens, uniqueTokens, diff --git a/lib/__tests__/observe.test.ts b/lib/__tests__/observe.test.ts new file mode 100644 index 000000000..4b47e71ce --- /dev/null +++ b/lib/__tests__/observe.test.ts @@ -0,0 +1,19 @@ +import { configureObserve, markAppInteractive, withObserve } from '@/lib/observe'; + +// lib/observe must never throw, even when the expo-observe native module is +// missing from the binary (e.g. an OTA update reaching an older build). +describe('lib/observe', () => { + it('configureObserve does not throw without the native module', () => { + expect(() => configureObserve()).not.toThrow(); + }); + + it('markAppInteractive does not throw without the native module', () => { + expect(() => markAppInteractive()).not.toThrow(); + }); + + it('withObserve returns a renderable component', () => { + const Component = () => null; + const Wrapped = withObserve(Component); + expect(typeof Wrapped).toBe('function'); + }); +}); diff --git a/lib/alchemy.ts b/lib/alchemy.ts new file mode 100644 index 000000000..077d97684 --- /dev/null +++ b/lib/alchemy.ts @@ -0,0 +1,286 @@ +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'; + +// Dedicated axios instance: the global axios in lib/api.ts has a request +// interceptor that injects the Solid backend Bearer JWT on iOS/Android, which +// Alchemy rejects with 401. Using axios.create() bypasses that interceptor. +const alchemyAxios = axios.create(); + +/** + * 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 alchemyAxios.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 alchemyAxios.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/analytics.ts b/lib/analytics.ts index 92c6717c0..960ba91bf 100644 --- a/lib/analytics.ts +++ b/lib/analytics.ts @@ -205,8 +205,45 @@ const trackFirebaseEvent = async (event: string, params: Record) => } }; +export type TrackOptions = { + /** + * Whether to send this event to Amplitude. Defaults to true. Set to `false` + * for events that are now emitted server-side (backend Amplitude) to avoid + * double-counting. Firebase and GTM still fire, so web conversion / ad + * attribution is preserved. + */ + amplitude?: boolean; +}; + +// Enrich event params with attribution + device/session context so every +// event (including screen views) carries the same top-level properties +// (utm_source, utm_campaign, attribution_channel, ...) instead of burying +// them in a nested object. Shared by track() and trackAmplitudeScreen(). +const enrichEventParams = (params: Record) => { + const attributionData = useAttributionStore.getState().getAttributionForEvent(); + + return { + ...params, + // Attribution data (UTM params, referral codes, etc.) + ...attributionData, + // Attribution channel for easier filtering + attribution_channel: getAttributionChannel(attributionData), + // Device/session tracking for anonymous-to-identified user bridging + amplitude_device_id: getAmplitudeDeviceId(), + amplitude_session_id: getAmplitudeSessionId(), + // Platform context + platform: Platform.OS, + // Timestamp + timestamp: Date.now(), + }; +}; + // Main track function with automatic attribution enrichment -export const track = (event: string, params: Record = {}) => { +export const track = ( + event: string, + params: Record = {}, + options: TrackOptions = {}, +) => { // Don't track events locally if (__DEV__) { return; @@ -219,34 +256,16 @@ export const track = (event: string, params: Record = {}) => { return; } - // Get attribution data from store - const attributionStore = useAttributionStore.getState(); - const attributionData = attributionStore.getAttributionForEvent(); - const deviceId = getAmplitudeDeviceId(); - const sessionId = getAmplitudeSessionId(); - - // Enrich params with attribution and device context - const enrichedParams = { - ...params, - // Attribution data (UTM params, referral codes, etc.) - ...attributionData, - // Attribution channel for easier filtering - attribution_channel: getAttributionChannel(attributionData), - // Device/session tracking for anonymous-to-identified user bridging - amplitude_device_id: deviceId, - amplitude_session_id: sessionId, - // Platform context - platform: Platform.OS, - // Timestamp - timestamp: Date.now(), - }; + const { amplitude = true } = options; - // Sanitize all params once - remove undefined/null values and ensure serializable - const sanitizedParams = sanitize(enrichedParams); + // Enrich with attribution + device context, then sanitize once + // (remove undefined/null values and ensure serializable). + const sanitizedParams = sanitize(enrichEventParams(params)); - // Track to all providers in parallel + // Track to all providers in parallel. Amplitude can be opted out per-call + // for events now emitted server-side; Firebase + GTM always fire. Promise.allSettled([ - Promise.resolve(trackAmplitudeEvent(event, sanitizedParams)), + amplitude ? Promise.resolve(trackAmplitudeEvent(event, sanitizedParams)) : undefined, trackFirebaseEvent(event, sanitizedParams), Promise.resolve(trackGTMEvent(event, sanitizedParams)), ]); @@ -263,10 +282,14 @@ const trackAmplitudeScreen = (pathname: string, params: Record) => } try { - trackAmplitude(AmplitudeEvent.PAGE_VIEWED, { - pathname, - params, - }); + // Enrich with attribution + device context and flatten route params to + // top-level properties so utm_source / utm_campaign are queryable in + // Amplitude (matching how track() enriches every other event), instead + // of arriving as a nested `params` object. + trackAmplitude( + AmplitudeEvent.PAGE_VIEWED, + sanitize(enrichEventParams({ pathname, ...params })), + ); } catch (error) { console.error('Error tracking Amplitude screen:', error); } diff --git a/lib/api.ts b/lib/api.ts index 4877f7aee..89717a824 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`, { @@ -2675,6 +2775,21 @@ export const removePushToken = async (token: string) => { return response.json(); }; +export const trackUserPlatform = async (platform: typeof Platform.OS) => { + const jwt = getJWTToken(); + const response = await fetch(`${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/users/platforms`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getPlatformHeaders(), + ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), + }, + credentials: 'include', + body: JSON.stringify({ platform }), + }); + if (!response.ok) throw response; +}; + export const fetchSavingsSummary = async ( vault: string = 'USDC', ): Promise => { diff --git a/lib/assets.ts b/lib/assets.ts index d9efccb55..210230768 100644 --- a/lib/assets.ts +++ b/lib/assets.ts @@ -56,6 +56,10 @@ export const ASSETS = { module: require('@/assets/images/adaptive-icon.png'), hash: 'd2020cd7', }, + 'images/agent-wallet.png': { + module: require('@/assets/images/agent-wallet.png'), + hash: '297f7f78', + }, 'images/apple-google-pay.png': { module: require('@/assets/images/apple-google-pay.png'), hash: '5a4d6b29', @@ -85,6 +89,7 @@ export const ASSETS = { module: require('@/assets/images/brl-fiat-currency.tsx'), hash: '75a2ebe8', }, + 'images/bsc.png': { module: require('@/assets/images/bsc.png'), hash: '4135f6a8' }, 'images/buy_crypto.png': { module: require('@/assets/images/buy_crypto.png'), hash: 'a072a946' }, 'images/card-earn.png': { module: require('@/assets/images/card-earn.png'), hash: '3e55e4d1' }, 'images/card-effortless.png': { @@ -152,7 +157,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 +199,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 +292,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 +565,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', @@ -611,6 +625,10 @@ export const ASSETS = { module: require('@/assets/images/wallet_connect.png'), hash: '7816178c', }, + 'images/welcome-card.png': { + module: require('@/assets/images/welcome-card.png'), + hash: '01502018', + }, 'images/weth.png': { module: require('@/assets/images/weth.png'), hash: '6d2ae2d7' }, 'images/wfuse.png': { module: require('@/assets/images/wfuse.png'), hash: 'af627457' }, 'images/withdraw-green.png': { 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/getTokenIcon.tsx b/lib/getTokenIcon.tsx index 40374db4f..43fade8a3 100644 --- a/lib/getTokenIcon.tsx +++ b/lib/getTokenIcon.tsx @@ -17,6 +17,10 @@ const getTokenIcon = ({ logoUrl, tokenSymbol, size = 24 }: GetTokenIconProps): T // Fallback to default token icons based on symbol switch (tokenSymbol?.toUpperCase()) { case 'USDC': + // Bridged USDC variants (e.g. USDC.e on Fuse/Arbitrum, used by the + // borrow-and-deposit-to-card flow) share the USDC icon. Without this the + // detail page fell back to the "U" placeholder. + case 'USDC.E': return { type: 'image', source: getAsset('images/usdc-4x.png'), diff --git a/lib/observe.ts b/lib/observe.ts new file mode 100644 index 000000000..1a194d35f --- /dev/null +++ b/lib/observe.ts @@ -0,0 +1,50 @@ +import { EXPO_PUBLIC_ENVIRONMENT } from '@/lib/config'; + +import type { ComponentType } from 'react'; + +type ObserveModule = typeof import('expo-observe'); + +// expo-observe (and its expo-app-metrics dependency) resolve their native +// modules at import time and throw when the binary doesn't include them, e.g. +// an OTA update reaching a build created before expo-observe was added +// (runtimeVersion policy is appVersion). Metrics are best-effort, so fall back +// to no-ops instead of crashing the app at startup. +let observe: ObserveModule | undefined; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + observe = require('expo-observe'); +} catch { + observe = undefined; +} + +/** + * Configures EAS Observe metric dispatching. Call once at app startup, before + * the first metrics are collected. + * + * Debug builds collect but never dispatch metrics by default; pass + * `dispatchInDebug: true` here to test the pipeline locally. + */ +export function configureObserve() { + observe?.default.configure({ + environment: EXPO_PUBLIC_ENVIRONMENT || 'development', + }); +} + +/** + * Records the time-to-interactive startup metric. Call once the splash screen + * is hidden and the first real UI is visible. + */ +export function markAppInteractive() { + observe?.AppMetrics.markInteractive(); +} + +/** + * Wraps the root layout with `AppMetricsRoot`, which records the + * time-to-first-render startup metric (the SDK 55 equivalent of SDK 56's + * `ObserveRoot`). + */ +export function withObserve

>( + Component: ComponentType

, +): ComponentType

{ + return observe ? observe.AppMetricsRoot.wrap(Component) : Component; +} diff --git a/lib/thirdweb.ts b/lib/thirdweb.ts index 757fab5d6..91839f6a5 100644 --- a/lib/thirdweb.ts +++ b/lib/thirdweb.ts @@ -1,5 +1,5 @@ import { createThirdwebClient, defineChain } from 'thirdweb'; -import { arbitrum, base, mainnet, polygon } from 'thirdweb/chains'; +import { arbitrum, base, bsc, mainnet, polygon } from 'thirdweb/chains'; import { darkTheme } from 'thirdweb/react'; import { createWallet } from 'thirdweb/wallets'; @@ -50,7 +50,7 @@ const fuse = defineChain({ }, }); -const chains = [mainnet, fuse, polygon, base, arbitrum]; +const chains = [mainnet, fuse, polygon, base, arbitrum, bsc]; export const getChain = (chainId: number) => { return chains.find(chain => chain.id === chainId); diff --git a/lib/types.ts b/lib/types.ts index 71ca785c7..cd6593b93 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,9 @@ 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', + RESCUE_TOKEN = 'rescue_token', } export enum TransactionDirection { @@ -791,6 +811,7 @@ export type BridgeDeposit = { deadline: number; }; trackingId?: string; + category?: DepositCategory; }; export type BridgeTransactionRequest = { @@ -814,8 +835,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 +950,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 +1274,7 @@ export interface CardTransaction { merchant_city?: string; merchant_country?: string; local_transaction_details?: LocalTransactionDetails; + declined_reason?: string; } export interface CardTransactionsResponse { @@ -1317,7 +1348,8 @@ export interface ActivityEvents { export interface UpdateActivityEvent { status?: TransactionStatus; - txHash?: string; + hash?: string; + url?: string; userOpHash?: string; metadata?: Record; } @@ -1519,6 +1551,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/__tests__/card-deposit-activity.test.ts b/lib/utils/__tests__/card-deposit-activity.test.ts new file mode 100644 index 000000000..098beb9b8 --- /dev/null +++ b/lib/utils/__tests__/card-deposit-activity.test.ts @@ -0,0 +1,185 @@ +/// +import { getTransactionCategory, isSourceReceiptFinalizable } from '@/constants/transaction'; +import { + ActivityEvent, + TransactionCategory, + TransactionStatus, + TransactionType, +} from '@/lib/types'; +import { + deduplicateTransactions, + resolveCardDepositTransferTx, +} from '@/lib/utils/deduplicateTransactions'; + +function makeActivity(overrides: Partial = {}): ActivityEvent { + return { + clientTxId: 'tx-1', + type: TransactionType.CARD_DEPOSIT, + status: TransactionStatus.SUCCESS, + amount: '0.01', + symbol: 'USDC', + timestamp: '1781426763', + title: 'Deposit to Card', + ...overrides, + } as ActivityEvent; +} + +describe('deduplicateTransactions — connect-wallet card deposit', () => { + // Real shape from a Wallet-source Rain card deposit: the frontend creates the + // base trackingId activity (with the on-chain hash) and the backend Temporal + // workflow creates `${trackingId}_card`. They must render as ONE row. + const frontend = makeActivity({ + clientTxId: 'mqdjhtjp-g038q1a0', + title: 'Deposit USDC to Card', + userOpHash: '0x92d8602bde4171b6686544d4d2fa61ba0b5db07cb4458da85a94ae6347a1b527', + hash: '0x7843ea7492494d0adfb3b913c6bfb2f87daf5a6f6714a12df6c79387d60664cb', + toAddress: '0x9e852a0d1bd9738d52b90a5e907138575822d69e', + metadata: { source: 'transaction-hook' }, + }); + const backendCard = makeActivity({ + clientTxId: 'mqdjhtjp-g038q1a0_card', + title: 'Deposit to Card', + userOpHash: '0x4acf1672858e422d8a760b2ffde946f3ad90d4bfe0965a77d8e2150bf9f665f3', + toAddress: '0xcf06a945cecc2651b78d055b6246ae1622c9e966', + metadata: {}, + }); + + it('collapses trackingId and trackingId_card into a single row', () => { + const result = deduplicateTransactions([backendCard, frontend]); + expect(result).toHaveLength(1); + }); + + it('keeps the row carrying the on-chain hash (the explorer link)', () => { + const result = deduplicateTransactions([backendCard, frontend]); + expect(result[0].clientTxId).toBe('mqdjhtjp-g038q1a0'); + expect(result[0].hash).toBe(frontend.hash); + }); + + it('removes the Blockscout-synced Send that mirrors a Wallet→card deposit', () => { + // Real shape: the frontend card_deposit (approve userOp hash) and the + // Blockscout-synced "Send USDC" (the on-chain transfer hash) share the same + // card funding toAddress + timestamp but have different hashes. + const cardDeposit = makeActivity({ + clientTxId: 'mqdtqm1z-0xxndjzj', + title: 'Deposit USDC to Card', + hash: '0x1e6e5edd8850a6d072aa7cb592843b2638c1604b1a48b0cfdffc9ea56456cfe7', + userOpHash: '0x5f5b9152b8ef76d3b55adc363efbe5cf7fcda5643eb02e74339be32531473553', + toAddress: '0x9e852a0d1bd9738d52b90a5e907138575822d69e', + metadata: { source: 'transaction-hook' }, + }); + const blockscoutSend = makeActivity({ + clientTxId: 'blockscout_8453_0xeb41_outgoing', + type: TransactionType.SEND, + title: 'Send USDC', + shortTitle: 'Send', + hash: '0xeb41c0c152e3183d217a60ccae5ab5a4818366bdca58149e71e9b8172688733d', + toAddress: '0x9e852a0d1bd9738d52b90a5e907138575822d69e', + metadata: { source: 'blockscout' }, + }); + const result = deduplicateTransactions([blockscoutSend, cardDeposit]); + expect(result).toHaveLength(1); + expect(result[0].type).toBe(TransactionType.CARD_DEPOSIT); + }); + + it('keeps an unrelated Send to a different address', () => { + const cardDeposit = makeActivity({ + clientTxId: 'dep-x', + toAddress: '0x9e852a0d1bd9738d52b90a5e907138575822d69e', + hash: '0xaaaa000000000000000000000000000000000000000000000000000000000001', + }); + const unrelatedSend = makeActivity({ + clientTxId: 'send-x', + type: TransactionType.SEND, + title: 'Send USDC', + toAddress: '0x1111111111111111111111111111111111111111', + hash: '0xbbbb000000000000000000000000000000000000000000000000000000000002', + }); + const result = deduplicateTransactions([cardDeposit, unrelatedSend]); + expect(result).toHaveLength(2); + }); + + it('still keeps a savings deposit and its _savings step separate', () => { + const base = makeActivity({ + clientTxId: 'dep-1', + type: TransactionType.DEPOSIT, + title: 'Deposit USDC', + hash: '0x1111111111111111111111111111111111111111111111111111111111111111', + }); + const savings = makeActivity({ + clientTxId: 'dep-1_savings', + type: TransactionType.DEPOSIT, + title: 'Deposit soUSD to Savings', + }); + const result = deduplicateTransactions([base, savings]); + expect(result).toHaveLength(2); + }); +}); + +describe('resolveCardDepositTransferTx', () => { + const cardDeposit = makeActivity({ + clientTxId: 'mqdtqm1z-0xxndjzj', + title: 'Deposit USDC to Card', + hash: '0x1e6e5edd8850a6d072aa7cb592843b2638c1604b1a48b0cfdffc9ea56456cfe7', // approve userOp + toAddress: '0x9e852a0d1bd9738d52b90a5e907138575822d69e', + }); + const transferSend = makeActivity({ + clientTxId: 'blockscout_8453_0xeb41_outgoing', + type: TransactionType.SEND, + title: 'Send USDC', + hash: '0xeb41c0c152e3183d217a60ccae5ab5a4818366bdca58149e71e9b8172688733d', // real transfer + toAddress: '0x9e852a0d1bd9738d52b90a5e907138575822d69e', + url: 'https://base.blockscout.com/tx/0xeb41c0c152e3183d217a60ccae5ab5a4818366bdca58149e71e9b8172688733d', + }); + + it('returns the sibling Send transfer tx (hash + url) for a card deposit', () => { + const result = resolveCardDepositTransferTx(cardDeposit, [cardDeposit, transferSend]); + expect(result).toEqual({ hash: transferSend.hash, url: transferSend.url }); + }); + + it('returns undefined when there is no sibling transfer', () => { + expect(resolveCardDepositTransferTx(cardDeposit, [cardDeposit])).toBeUndefined(); + }); + + it('returns undefined for non card-deposit types', () => { + const send = makeActivity({ clientTxId: 's', type: TransactionType.SEND }); + expect(resolveCardDepositTransferTx(send, [send, transferSend])).toBeUndefined(); + }); +}); + +describe('getTransactionCategory', () => { + it('labels a card-bound bridge_deposit as Card deposit', () => { + expect(getTransactionCategory(TransactionType.BRIDGE_DEPOSIT, 'Deposit soUSD to Card')).toBe( + TransactionCategory.CARD_DEPOSIT, + ); + }); + + it('leaves a real bridge_deposit as External wallet transfer', () => { + expect(getTransactionCategory(TransactionType.BRIDGE_DEPOSIT, 'Bridge to Arbitrum')).toBe( + TransactionCategory.EXTERNAL_WALLET_TRANSFER, + ); + }); + + it('falls back to the static category for other types', () => { + expect(getTransactionCategory(TransactionType.CARD_TRANSACTION, 'Card Deposit')).toBe( + TransactionCategory.CARD_DEPOSIT, + ); + expect(getTransactionCategory(TransactionType.SEND, 'Sent USDC')).toBe( + TransactionCategory.WALLET_TRANSFER, + ); + }); +}); + +describe('isSourceReceiptFinalizable', () => { + // Cross-chain card deposits must NOT be marked complete on their source-chain + // receipt — they bridge for minutes and finalize via the Rain webhook. + it('is false for cross-chain card deposit types', () => { + expect(isSourceReceiptFinalizable(TransactionType.BRIDGE_DEPOSIT)).toBe(false); + expect(isSourceReceiptFinalizable(TransactionType.BORROW_AND_DEPOSIT_TO_CARD)).toBe(false); + expect(isSourceReceiptFinalizable(TransactionType.CARD_DEPOSIT)).toBe(false); + }); + + it('is true for same-chain types resolved by a source-chain receipt', () => { + expect(isSourceReceiptFinalizable(TransactionType.SEND)).toBe(true); + expect(isSourceReceiptFinalizable(TransactionType.CARD_TRANSACTION)).toBe(true); + }); +}); 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/deduplicateTransactions.ts b/lib/utils/deduplicateTransactions.ts index 0532f8158..904a2e9f6 100644 --- a/lib/utils/deduplicateTransactions.ts +++ b/lib/utils/deduplicateTransactions.ts @@ -32,6 +32,23 @@ function isDuplicate(a: ActivityEvent, b: ActivityEvent): boolean { } } + // A connect-wallet card deposit creates TWO card_deposit activities for the + // same user action: the frontend's optimistic one (trackingId) and the + // backend Temporal workflow's card-funding one (`${trackingId}_card`). + // Unlike the savings flow above (two distinct user-visible steps), these are + // the same step — collapse them so the deposit shows once. The keep-decision + // below prefers the row with an on-chain hash (the frontend doc), which + // carries the explorer link. + if (a.clientTxId && b.clientTxId && a.clientTxId !== b.clientTxId) { + const aIsCard = a.clientTxId.endsWith('_card'); + const bIsCard = b.clientTxId.endsWith('_card'); + if (aIsCard !== bIsCard) { + const cardId = aIsCard ? a.clientTxId : b.clientTxId; + const otherId = aIsCard ? b.clientTxId : a.clientTxId; + if (cardId === `${otherId}_card`) return true; + } + } + // Normalize hash values for comparison (lowercase, trim) const normalizeHash = (hash: string | undefined) => hash?.toLowerCase().trim(); const aHash = normalizeHash(a.hash); @@ -59,6 +76,47 @@ function isDuplicate(a: ActivityEvent, b: ActivityEvent): boolean { return false; } +/** + * Activity types that represent a "deposit to card" from the user's + * perspective. Used to (a) prioritise the card-deposit row over the raw SEND + * when they collide, and (b) suppress the Blockscout-synced SEND that mirrors + * the on-chain transfer of a card deposit. + */ +export const CARD_DEPOSIT_ACTIVITY_TYPES: readonly TransactionType[] = [ + TransactionType.BRIDGE_DEPOSIT, + TransactionType.CARD_TRANSACTION, + TransactionType.CARD_DEPOSIT, + TransactionType.BORROW_AND_DEPOSIT_TO_CARD, +]; + +/** + * For a card-deposit activity, find the sibling on-chain USDC transfer that the + * Blockscout/Alchemy sync indexed as a separate "Send" (the real money + * movement). The card-deposit row's own hash is, for connect-wallet deposits, + * the approve userOp — not the transfer — so the UI should link to this Send's + * tx instead. Matches the same toAddress + chain + 5-minute window used by the + * Send-dedup pass. Returns the transfer's hash (and url, if synced). + */ +export function resolveCardDepositTransferTx( + activity: ActivityEvent, + allActivities: ActivityEvent[], +): { hash: string; url?: string } | undefined { + if (!activity?.toAddress || !CARD_DEPOSIT_ACTIVITY_TYPES.includes(activity.type)) { + return undefined; + } + const toAddress = activity.toAddress.toLowerCase(); + const ts = parseInt(activity.timestamp || '0'); + const send = allActivities.find( + a => + a.type === TransactionType.SEND && + !!a.hash && + a.toAddress?.toLowerCase() === toAddress && + a.chainId === activity.chainId && + Math.abs(parseInt(a.timestamp || '0') - ts) < 300, + ); + return send?.hash ? { hash: send.hash, url: send.url } : undefined; +} + /** * Check if a transaction is a card deposit */ @@ -66,12 +124,12 @@ function isCardDeposit(transaction: ActivityEvent): boolean { // Guard against null/corrupted transactions if (!transaction || !transaction.type) return false; + if (CARD_DEPOSIT_ACTIVITY_TYPES.includes(transaction.type)) return true; + return ( - transaction.type === TransactionType.BRIDGE_DEPOSIT || - transaction.type === TransactionType.CARD_TRANSACTION || - (transaction.type === TransactionType.SEND && - transaction.toAddress && - transaction.metadata?.description?.toLowerCase().includes('card')) + transaction.type === TransactionType.SEND && + !!transaction.toAddress && + !!transaction.metadata?.description?.toLowerCase().includes('card') ); } @@ -162,6 +220,8 @@ export function deduplicateTransactions(transactions: ActivityEvent[]): Activity if (currentIsCardDeposit || existingIsCardDeposit || sameCardAddress) { const typePriority = { [TransactionType.BRIDGE_DEPOSIT]: 3, + [TransactionType.BORROW_AND_DEPOSIT_TO_CARD]: 3, + [TransactionType.CARD_DEPOSIT]: 3, [TransactionType.CARD_TRANSACTION]: 2, [TransactionType.SEND]: 1, }; @@ -238,18 +298,20 @@ export function deduplicateTransactions(transactions: ActivityEvent[]): Activity let deduplicatedArray = Array.from(deduplicated.values()); - // Second pass: Remove SEND transactions that have a corresponding BRIDGE_DEPOSIT or CARD_TRANSACTION - // with the same toAddress (card funding address) and similar timestamp + // Second pass: Remove SEND transactions that mirror a card deposit. The + // Blockscout/Alchemy sync indexes the on-chain USDC transfer of a card + // deposit as a separate "Send USDC" row (a different tx hash than the + // frontend activity — e.g. the transfer vs the approve userOp — so hash + // dedup misses it). Drop the SEND when a card-deposit activity shares the + // same toAddress (card funding address) within 5 minutes. deduplicatedArray = deduplicatedArray.filter(transaction => { // Keep all non-SEND transactions if (transaction.type !== TransactionType.SEND) return true; - // For SEND transactions, check if there's a BRIDGE_DEPOSIT or CARD_TRANSACTION with same toAddress const hasCardDepositTransaction = deduplicatedArray.some( tx => tx !== transaction && - (tx.type === TransactionType.BRIDGE_DEPOSIT || - tx.type === TransactionType.CARD_TRANSACTION) && + CARD_DEPOSIT_ACTIVITY_TYPES.includes(tx.type) && tx.toAddress?.toLowerCase() === transaction.toAddress?.toLowerCase() && Math.abs(parseInt(tx.timestamp || '0') - parseInt(transaction.timestamp || '0')) < 300, // Within 5 minutes ); 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/lib/wagmi.ts b/lib/wagmi.ts index acd98a732..ba909cfb0 100644 --- a/lib/wagmi.ts +++ b/lib/wagmi.ts @@ -6,6 +6,7 @@ import { arbitrum, base, baseSepolia, + bsc, fuse, mainnet, polygon, @@ -15,7 +16,7 @@ import { EXPO_PUBLIC_ALCHEMY_API_KEY } from './config'; polyfill(); -const chains: [Chain, ...Chain[]] = [fuse, mainnet, polygon, base, baseSepolia, arbitrum]; +const chains: [Chain, ...Chain[]] = [fuse, mainnet, polygon, base, baseSepolia, arbitrum, bsc]; export const getChain = (chainId: number): Chain | undefined => { return chains.find((chain: Chain) => chain.id === chainId); @@ -28,6 +29,7 @@ export const rpcUrls: Record = { [base.id]: `https://base-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, [baseSepolia.id]: `https://base-sepolia.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, [arbitrum.id]: `https://arb-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, + [bsc.id]: `https://bnb-mainnet.g.alchemy.com/v2/${EXPO_PUBLIC_ALCHEMY_API_KEY}`, }; const transports: Record> = { @@ -37,6 +39,7 @@ const transports: Record> = { [base.id]: http(rpcUrls[base.id]), [baseSepolia.id]: http(rpcUrls[baseSepolia.id]), [arbitrum.id]: http(rpcUrls[arbitrum.id]), + [bsc.id]: http(rpcUrls[bsc.id]), }; export const publicClient = (chainId: number) => diff --git a/package-lock.json b/package-lock.json index 9d369c6ed..a4e0025df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,10 +80,12 @@ "expo-font": "~55.0.4", "expo-haptics": "~55.0.13", "expo-image": "~55.0.8", + "expo-insights": "~55.0.15", "expo-intent-launcher": "~55.0.11", "expo-linear-gradient": "~55.0.12", "expo-linking": "~55.0.11", "expo-notifications": "~55.0.17", + "expo-observe": "~0.2.2", "expo-router": "~55.0.11", "expo-splash-screen": "~55.0.16", "expo-symbols": "~55.0.7", @@ -22393,6 +22395,20 @@ } } }, + "node_modules/expo-app-metrics": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/expo-app-metrics/-/expo-app-metrics-0.2.2.tgz", + "integrity": "sha512-S+4/3SV92wjBizj2QJmmVG6PlcwvaDedFSJSInp1XLIjgVkngpc/oAFS5UTMiH2X+lW6H/RSoPRk7Hd+fuoOmg==", + "license": "MIT", + "dependencies": { + "expo-updates-interface": "~55.1.6" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-application": { "version": "55.0.13", "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-55.0.13.tgz", @@ -22668,6 +22684,18 @@ } } }, + "node_modules/expo-insights": { + "version": "55.0.17", + "resolved": "https://registry.npmjs.org/expo-insights/-/expo-insights-55.0.17.tgz", + "integrity": "sha512-X1uELdl4lP7+qs5ewtAPaFWrWa7Lp0Ltkq93skDc8fBVgl3aUqLrAdnz4UMyy5waM2JnVr0WtUqOIQW9+6e+jg==", + "license": "MIT", + "dependencies": { + "expo-eas-client": "~55.0.5" + }, + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-intent-launcher": { "version": "55.0.11", "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-55.0.11.tgz", @@ -22777,6 +22805,20 @@ "react-native": "*" } }, + "node_modules/expo-observe": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/expo-observe/-/expo-observe-0.2.2.tgz", + "integrity": "sha512-LF0Fmjrl+p3j/VgIhsKVE/6gffQM28nawNBA7/rGkEKqcYDKA3JBBPo0zAEE4OMJzOHExr7FYS/z6LzzaUYfJA==", + "license": "MIT", + "dependencies": { + "expo-app-metrics": "~0.2.2", + "expo-eas-client": "~55.0.5" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "55.0.11", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-55.0.11.tgz", @@ -22968,9 +23010,9 @@ } }, "node_modules/expo-updates-interface": { - "version": "55.1.5", - "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-55.1.5.tgz", - "integrity": "sha512-YOk9vhplWi0djoeqxMlEQgcDFeOGhnj4dWU0v1QvF5RqpqwLGdx780E0k3zL85xw6LXljVN78d6g8z51qIZu5g==", + "version": "55.1.6", + "resolved": "https://registry.npmjs.org/expo-updates-interface/-/expo-updates-interface-55.1.6.tgz", + "integrity": "sha512-evxNpagCkjT3lE6bGV570TFzRtKuIuLY8I37RYHoriXCJ+ZKCN1hbmklK29uAixya+BxGpeTI2K4FqYeJLvfrw==", "license": "MIT", "peerDependencies": { "expo": "*" diff --git a/package.json b/package.json index aebe5d499..384df2745 100644 --- a/package.json +++ b/package.json @@ -101,10 +101,12 @@ "expo-font": "~55.0.4", "expo-haptics": "~55.0.13", "expo-image": "~55.0.8", + "expo-insights": "~55.0.15", "expo-intent-launcher": "~55.0.11", "expo-linear-gradient": "~55.0.12", "expo-linking": "~55.0.11", "expo-notifications": "~55.0.17", + "expo-observe": "~0.2.2", "expo-router": "~55.0.11", "expo-splash-screen": "~55.0.16", "expo-symbols": "~55.0.7", @@ -226,6 +228,5 @@ } } }, - "private": true, - "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" + "private": true } diff --git a/patches/@didit-protocol+sdk-react-native+3.2.8.patch b/patches/@didit-protocol+sdk-react-native+3.2.8.patch new file mode 100644 index 000000000..709aa91f4 --- /dev/null +++ b/patches/@didit-protocol+sdk-react-native+3.2.8.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@didit-protocol/sdk-react-native/app.plugin.js b/node_modules/@didit-protocol/sdk-react-native/app.plugin.js +index 7943542..45ae038 100644 +--- a/node_modules/@didit-protocol/sdk-react-native/app.plugin.js ++++ b/node_modules/@didit-protocol/sdk-react-native/app.plugin.js +@@ -12,7 +12,7 @@ const MAVEN_REPO = + const MAVEN_LINE = ` maven { url "${MAVEN_REPO}" }`; + + const PODSPEC_URL = +- 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/main/DiditSDK.podspec'; ++ 'https://raw.githubusercontent.com/didit-protocol/sdk-ios/3.2.9/DiditSDK.podspec'; + + const POD_LINE = ` pod 'DiditSDK', :podspec => '${PODSPEC_URL}'`; + diff --git a/store/useCardWelcomePopupStore.ts b/store/useCardWelcomePopupStore.ts new file mode 100644 index 000000000..1f795e1a5 --- /dev/null +++ b/store/useCardWelcomePopupStore.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand'; +import { createJSONStorage, persist } from 'zustand/middleware'; + +import mmkvStorage from '@/lib/mmvkStorage'; + +interface CardWelcomePopupState { + shouldShowWelcomePopup: boolean; + setShouldShowWelcomePopup: (value: boolean) => void; +} + +const CARD_WELCOME_POPUP_STORAGE_KEY = 'card-welcome-popup-storage'; + +export const useCardWelcomePopupStore = create()( + persist( + set => ({ + shouldShowWelcomePopup: false, + setShouldShowWelcomePopup: (value: boolean) => set({ shouldShowWelcomePopup: value }), + }), + { + name: CARD_WELCOME_POPUP_STORAGE_KEY, + storage: createJSONStorage(() => mmkvStorage(CARD_WELCOME_POPUP_STORAGE_KEY)), + }, + ), +); 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; }, },