diff --git a/src/components/Send/views/Contacts.view.tsx b/src/components/Send/views/Contacts.view.tsx
new file mode 100644
index 000000000..0fd722b1f
--- /dev/null
+++ b/src/components/Send/views/Contacts.view.tsx
@@ -0,0 +1,207 @@
+'use client'
+
+import { useAppDispatch } from '@/redux/hooks'
+import { sendFlowActions } from '@/redux/slices/send-flow-slice'
+import { useRouter, useSearchParams } from 'next/navigation'
+import NavHeader from '@/components/Global/NavHeader'
+import { ActionListCard } from '@/components/ActionListCard'
+import { useContacts } from '@/hooks/useContacts'
+import { useInfiniteScroll } from '@/hooks/useInfiniteScroll'
+import { useMemo, useState } from 'react'
+import AvatarWithBadge from '@/components/Profile/AvatarWithBadge'
+import { VerifiedUserLabel } from '@/components/UserHeader'
+import { SearchInput } from '@/components/SearchInput'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import EmptyState from '@/components/Global/EmptyStates/EmptyState'
+import { Button } from '@/components/0_Bruddle'
+
+export default function ContactsView() {
+ const router = useRouter()
+ const dispatch = useAppDispatch()
+ const searchParams = useSearchParams()
+ const isSendingByLink = searchParams.get('view') === 'link' || searchParams.get('createLink') === 'true'
+ const isSendingToContacts = searchParams.get('view') === 'contacts'
+ const {
+ contacts,
+ isLoading: isFetchingContacts,
+ error: isError,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ } = useContacts({ limit: 50 })
+ const [searchQuery, setSearchQuery] = useState('')
+
+ // infinite scroll hook - disabled when searching (search is client-side)
+ const { loaderRef } = useInfiniteScroll({
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ enabled: !searchQuery, // disable when user is searching
+ })
+
+ // client-side search filtering
+ const filteredContacts = useMemo(() => {
+ if (!searchQuery.trim()) return contacts
+
+ const query = searchQuery.trim().toLowerCase()
+ return contacts.filter((contact) => {
+ const fullName = contact.fullName?.toLowerCase() ?? ''
+ return contact.username.toLowerCase().includes(query) || fullName.includes(query)
+ })
+ }, [contacts, searchQuery])
+
+ const redirectToSendByLink = () => {
+ // reset send flow state when entering link creation flow
+ dispatch(sendFlowActions.resetSendFlow())
+ router.push(`${window.location.pathname}?view=link`)
+ }
+
+ const handlePrev = () => {
+ // reset send flow state and navigate deterministically
+ // when in sub-views (link or contacts), go back to base send page
+ // otherwise, go to home
+ dispatch(sendFlowActions.resetSendFlow())
+ if (isSendingByLink || isSendingToContacts) {
+ router.push('/send')
+ } else {
+ router.push('/home')
+ }
+ }
+
+ const handleLinkCtaClick = () => {
+ redirectToSendByLink()
+ }
+
+ // handle user selection from contacts
+ const handleUserSelect = (username: string) => {
+ router.push(`/send/${username}`)
+ }
+
+ if (isFetchingContacts) {
+ return
+ }
+
+ // handle error state before checking for empty contacts
+ if (!!isError) {
+ return (
+
+ )
+}
diff --git a/src/components/Send/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx
index 8ccea511e..c24f83ec4 100644
--- a/src/components/Send/views/SendRouter.view.tsx
+++ b/src/components/Send/views/SendRouter.view.tsx
@@ -15,11 +15,11 @@ import { ACTION_METHODS, type PaymentMethod } from '@/constants/actionlist.const
import Image from 'next/image'
import StatusBadge from '@/components/Global/Badges/StatusBadge'
import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions'
-import { useRecentUsers } from '@/hooks/useRecentUsers'
+import { useContacts } from '@/hooks/useContacts'
import { getInitialsFromName } from '@/utils/general.utils'
import { useCallback, useMemo } from 'react'
import AvatarWithBadge from '@/components/Profile/AvatarWithBadge'
-import { VerifiedUserLabel } from '@/components/UserHeader'
+import ContactsView from './Contacts.view'
export const SendRouterView = () => {
const router = useRouter()
@@ -27,25 +27,28 @@ export const SendRouterView = () => {
const searchParams = useSearchParams()
const isSendingByLink = searchParams.get('view') === 'link' || searchParams.get('createLink') === 'true'
const isSendingToContacts = searchParams.get('view') === 'contacts'
- const { recentTransactions, isFetchingRecentUsers } = useRecentUsers()
+ // only fetch 3 contacts for avatar display
+ const { contacts, isLoading: isFetchingContacts } = useContacts({ limit: 3 })
- // fallback initials when no recent transactions
+ // fallback initials when no contacts
const fallbackInitials = ['PE', 'AN', 'UT']
- const recentUsersAvatarInitials = useCallback(() => {
- // if we have recent transactions, use them (max 3)
- if (recentTransactions.length > 0) {
- return recentTransactions.slice(0, 3).map((transaction) => {
- return getInitialsFromName(transaction.username)
+ const recentContactsAvatarInitials = useCallback(() => {
+ // if we have contacts, use them (already limited to 3 by API)
+ if (contacts.length > 0) {
+ return contacts.map((contact) => {
+ return getInitialsFromName(
+ contact.showFullName ? contact.fullName || contact.username : contact.username
+ )
})
}
// fallback to default initials if no data
return fallbackInitials
- }, [recentTransactions])
+ }, [contacts])
- const recentUsersAvatars = useMemo(() => {
+ const contactsAvatars = useMemo(() => {
// show loading skeleton while fetching
- if (isFetchingRecentUsers) {
+ if (isFetchingContacts) {
return (
{/* display the action icon and type text */}
-
+
{getActionIcon(type, transaction.direction)}
{isPerkReward ? 'Refund' : getActionText(type)}
{status && }
@@ -175,13 +184,17 @@ const TransactionCard: React.FC = ({
{/* Transaction Details Drawer */}
-
+
+
+
+
+
>
)
}
diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx
index 1c31790c7..9467e786f 100644
--- a/src/components/UserHeader/index.tsx
+++ b/src/components/UserHeader/index.tsx
@@ -111,7 +111,7 @@ export const VerifiedUserLabel = ({
{(isInvitedByLoggedInUser || isInviter) && (
diff --git a/src/config/wagmi.config.tsx b/src/config/wagmi.config.tsx
index d98b041cc..639589c06 100644
--- a/src/config/wagmi.config.tsx
+++ b/src/config/wagmi.config.tsx
@@ -19,9 +19,28 @@ import {
import { createAppKit } from '@reown/appkit/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { WagmiProvider, cookieToInitialState, type Config } from 'wagmi'
+import { RETRY_STRATEGIES } from '@/utils/retry.utils'
-// 0. Setup queryClient
-const queryClient = new QueryClient()
+// 0. Setup queryClient with network resilience defaults
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ ...RETRY_STRATEGIES.FAST,
+ staleTime: 30 * 1000, // Cache data as fresh for 30s
+ gcTime: 5 * 60 * 1000, // Keep inactive queries in memory for 5min
+ refetchOnWindowFocus: true, // Refetch stale data when user returns
+ refetchOnReconnect: true, // Refetch when connectivity restored
+ // Allow queries when offline to read from TanStack Query in-memory cache
+ // Service Worker provides additional HTTP API response caching (user data, history, prices)
+ networkMode: 'always', // Run queries even when offline (reads from cache)
+ },
+ mutations: {
+ retry: 1, // Total 2 attempts: immediate + 1 retry (conservative for write operations)
+ retryDelay: 1000, // Fixed 1s delay
+ networkMode: 'online', // Pause mutations while offline (writes require network)
+ },
+ },
+})
// 1. Get projectId at https://cloud.reown.com
const projectId = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? ''
diff --git a/src/constants/actionlist.consts.ts b/src/constants/actionlist.consts.ts
index e3295b371..320e345f8 100644
--- a/src/constants/actionlist.consts.ts
+++ b/src/constants/actionlist.consts.ts
@@ -15,7 +15,7 @@ export const ACTION_METHODS: PaymentMethod[] = [
{
id: 'bank',
title: 'Bank',
- description: 'EUR, USD, ARS (more coming soon)',
+ description: 'EUR, USD, MXN, ARS & more',
icons: [
'https://flagcdn.com/w160/ar.png',
'https://flagcdn.com/w160/de.png',
diff --git a/src/constants/cache.consts.ts b/src/constants/cache.consts.ts
new file mode 100644
index 000000000..5583b26fb
--- /dev/null
+++ b/src/constants/cache.consts.ts
@@ -0,0 +1,35 @@
+/**
+ * Service Worker cache name constants
+ * Used across sw.ts and authContext.tsx to ensure consistent cache management
+ */
+
+/**
+ * Base cache names (without version suffix)
+ */
+export const CACHE_NAMES = {
+ USER_API: 'user-api',
+ TRANSACTIONS: 'transactions-api',
+ KYC_MERCHANT: 'kyc-merchant-api',
+ PRICES: 'prices-api',
+ EXTERNAL_RESOURCES: 'external-resources',
+} as const
+
+/**
+ * Cache names that contain user-specific data
+ * These should be cleared on logout to prevent data leakage between users
+ */
+export const USER_DATA_CACHE_PATTERNS = [
+ CACHE_NAMES.USER_API,
+ CACHE_NAMES.TRANSACTIONS,
+ CACHE_NAMES.KYC_MERCHANT,
+] as const
+
+/**
+ * Generates a versioned cache name
+ * @param name - Base cache name
+ * @param version - Cache version string
+ * @returns Versioned cache name (e.g., "user-api-v1")
+ */
+export const getCacheNameWithVersion = (name: string, version: string): string => {
+ return `${name}-${version}`
+}
diff --git a/src/constants/index.ts b/src/constants/index.ts
index f4a42c5f7..d47cbb342 100644
--- a/src/constants/index.ts
+++ b/src/constants/index.ts
@@ -6,5 +6,6 @@ export * from './loadingStates.consts'
export * from './query.consts'
export * from './zerodev.consts'
export * from './manteca.consts'
+export * from './payment.consts'
export * from './routes'
export * from './stateCodes.consts'
diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts
new file mode 100644
index 000000000..7d62bd236
--- /dev/null
+++ b/src/constants/payment.consts.ts
@@ -0,0 +1,32 @@
+// minimum amount requirements for different payment methods (in USD)
+export const MIN_BANK_TRANSFER_AMOUNT = 5
+export const MIN_MERCADOPAGO_AMOUNT = 5
+export const MIN_PIX_AMOUNT = 5
+
+// deposit limits for manteca regional onramps (in USD)
+export const MAX_MANTECA_DEPOSIT_AMOUNT = 2000
+export const MIN_MANTECA_DEPOSIT_AMOUNT = 1
+
+// QR payment limits for manteca (PIX, MercadoPago, QR3)
+export const MIN_MANTECA_QR_PAYMENT_AMOUNT = 0.1 // Manteca provider minimum
+
+// time constants for devconnect intent cleanup
+export const DEVCONNECT_INTENT_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
+
+// maximum number of devconnect intents to store per user
+export const MAX_DEVCONNECT_INTENTS = 10
+
+/**
+ * validate if amount meets minimum requirement for a payment method
+ * @param amount - amount in USD
+ * @param methodId - payment method identifier
+ * @returns true if amount is valid, false otherwise
+ */
+export const validateMinimumAmount = (amount: number, methodId: string): boolean => {
+ const minimums: Record = {
+ bank: MIN_BANK_TRANSFER_AMOUNT,
+ mercadopago: MIN_MERCADOPAGO_AMOUNT,
+ pix: MIN_PIX_AMOUNT,
+ }
+ return amount >= (minimums[methodId] ?? 0)
+}
diff --git a/src/constants/query.consts.ts b/src/constants/query.consts.ts
index 72714ea5b..adcfb970c 100644
--- a/src/constants/query.consts.ts
+++ b/src/constants/query.consts.ts
@@ -1,5 +1,6 @@
export const USER = 'user'
export const TRANSACTIONS = 'transactions'
+export const CONTACTS = 'contacts'
export const CLAIM_LINK = 'claimLink'
export const CLAIM_LINK_XCHAIN = 'claimLinkXChain'
diff --git a/src/context/RequestFulfillmentFlowContext.tsx b/src/context/RequestFulfillmentFlowContext.tsx
index 82ee98a12..d9400aa24 100644
--- a/src/context/RequestFulfillmentFlowContext.tsx
+++ b/src/context/RequestFulfillmentFlowContext.tsx
@@ -5,8 +5,6 @@ import { type CountryData } from '@/components/AddMoney/consts'
import { type IOnrampData } from './OnrampFlowContext'
import { type User } from '@/interfaces'
-export type ExternalWalletFulfilMethod = 'exchange' | 'wallet'
-
export enum RequestFulfillmentBankFlowStep {
BankCountryList = 'bank-country-list',
DepositBankDetails = 'deposit-bank-details',
@@ -16,12 +14,8 @@ export enum RequestFulfillmentBankFlowStep {
interface RequestFulfillmentFlowContextType {
resetFlow: () => void
- showExternalWalletFulfillMethods: boolean
- setShowExternalWalletFulfillMethods: (showExternalWalletFulfillMethods: boolean) => void
showRequestFulfilmentBankFlowManager: boolean
setShowRequestFulfilmentBankFlowManager: (showRequestFulfilmentBankFlowManager: boolean) => void
- externalWalletFulfillMethod: ExternalWalletFulfilMethod | null
- setExternalWalletFulfillMethod: (externalWalletFulfillMethod: ExternalWalletFulfilMethod | null) => void
flowStep: RequestFulfillmentBankFlowStep | null
setFlowStep: (step: RequestFulfillmentBankFlowStep | null) => void
selectedCountry: CountryData | null
@@ -43,10 +37,6 @@ interface RequestFulfillmentFlowContextType {
const RequestFulfillmentFlowContext = createContext(undefined)
export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
- const [showExternalWalletFulfillMethods, setShowExternalWalletFulfillMethods] = useState(false)
- const [externalWalletFulfillMethod, setExternalWalletFulfillMethod] = useState(
- null
- )
const [showRequestFulfilmentBankFlowManager, setShowRequestFulfilmentBankFlowManager] = useState(false)
const [flowStep, setFlowStep] = useState(null)
const [selectedCountry, setSelectedCountry] = useState(null)
@@ -58,8 +48,6 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod
const [triggerPayWithPeanut, setTriggerPayWithPeanut] = useState(false) // To trigger the pay with peanut from Action List
const resetFlow = useCallback(() => {
- setExternalWalletFulfillMethod(null)
- setShowExternalWalletFulfillMethods(false)
setFlowStep(null)
setShowRequestFulfilmentBankFlowManager(false)
setSelectedCountry(null)
@@ -73,10 +61,6 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod
const value = useMemo(
() => ({
resetFlow,
- externalWalletFulfillMethod,
- setExternalWalletFulfillMethod,
- showExternalWalletFulfillMethods,
- setShowExternalWalletFulfillMethods,
flowStep,
setFlowStep,
showRequestFulfilmentBankFlowManager,
@@ -98,8 +82,6 @@ export const RequestFulfilmentFlowContextProvider: React.FC<{ children: ReactNod
}),
[
resetFlow,
- externalWalletFulfillMethod,
- showExternalWalletFulfillMethods,
flowStep,
showRequestFulfilmentBankFlowManager,
selectedCountry,
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index b7e9de6a9..165bcd4d7 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -16,6 +16,8 @@ import { useQueryClient } from '@tanstack/react-query'
import { useRouter, usePathname } from 'next/navigation'
import { createContext, type ReactNode, useContext, useState, useEffect, useMemo, useCallback } from 'react'
import { captureException } from '@sentry/nextjs'
+// import { PUBLIC_ROUTES_REGEX } from '@/constants/routes'
+import { USER_DATA_CACHE_PATTERNS } from '@/constants/cache.consts'
interface AuthContextType {
user: interfaces.IUserProfile | null
@@ -143,6 +145,27 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => {
// clear JWT cookie by setting it to expire
document.cookie = 'jwt-token=; path=/; expires=Thu, 01 Jan 1970 00:00:01 GMT;'
+ // Clear service worker caches to prevent user data leakage
+ // When User A logs out and User B logs in on the same device, cached API responses
+ // could expose User A's data (profile, transactions, KYC) to User B
+ // Only clears user-specific caches; preserves prices and external resources
+ if ('caches' in window) {
+ try {
+ const cacheNames = await caches.keys()
+ await Promise.all(
+ cacheNames
+ .filter((name) => USER_DATA_CACHE_PATTERNS.some((pattern) => name.includes(pattern)))
+ .map((name) => {
+ console.log('Logout: Clearing cache:', name)
+ return caches.delete(name)
+ })
+ )
+ } catch (error) {
+ console.error('Failed to clear caches on logout:', error)
+ // Non-fatal: logout continues even if cache clearing fails
+ }
+ }
+
// clear the iOS PWA prompt session flag
if (typeof window !== 'undefined') {
sessionStorage.removeItem('hasSeenIOSPWAPromptThisSession')
diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx
index 52e415f3d..e6d75d4a6 100644
--- a/src/context/kernelClient.context.tsx
+++ b/src/context/kernelClient.context.tsx
@@ -190,30 +190,42 @@ export const KernelClientProvider = ({ children }: { children: ReactNode }) => {
}
const initializeClients = async () => {
+ // NETWORK RESILIENCE: Parallelize kernel client initialization across chains
+ // Currently only 1 chain configured (Arbitrum), but this enables future multi-chain support
+ const clientPromises = Object.entries(PUBLIC_CLIENTS_BY_CHAIN).map(
+ async ([chainId, { client, chain, bundlerUrl, paymasterUrl }]) => {
+ try {
+ const kernelClient = await createKernelClientForChain(
+ client,
+ chain,
+ isAfterZeroDevMigration,
+ webAuthnKey,
+ isAfterZeroDevMigration
+ ? undefined
+ : (user?.accounts.find((a) => a.type === 'peanut-wallet')!.identifier as Address),
+ {
+ bundlerUrl,
+ paymasterUrl,
+ }
+ )
+ return { chainId, kernelClient, success: true } as const
+ } catch (error) {
+ console.error(`Error creating kernel client for chain ${chainId}:`, error)
+ captureException(error)
+ return { chainId, error, success: false } as const
+ }
+ }
+ )
+
+ const results = await Promise.allSettled(clientPromises)
+
const newClientsByChain: Record = {}
- for (const chainId in PUBLIC_CLIENTS_BY_CHAIN) {
- const { client, chain, bundlerUrl, paymasterUrl } = PUBLIC_CLIENTS_BY_CHAIN[chainId]
- try {
- const kernelClient = await createKernelClientForChain(
- client,
- chain,
- isAfterZeroDevMigration,
- webAuthnKey,
- isAfterZeroDevMigration
- ? undefined
- : (user?.accounts.find((a) => a.type === 'peanut-wallet')!.identifier as Address),
- {
- bundlerUrl,
- paymasterUrl,
- }
- )
- newClientsByChain[chainId] = kernelClient
- } catch (error) {
- console.error(`Error creating kernel client for chain ${chainId}:`, error)
- captureException(error)
- continue
+ for (const result of results) {
+ if (result.status === 'fulfilled' && result.value.success) {
+ newClientsByChain[result.value.chainId] = result.value.kernelClient
}
}
+
if (isMounted) {
fetchUser()
setClientsByChain(newClientsByChain)
diff --git a/src/context/pushProvider.tsx b/src/context/pushProvider.tsx
index 0f212eaa3..61663e41f 100644
--- a/src/context/pushProvider.tsx
+++ b/src/context/pushProvider.tsx
@@ -29,17 +29,21 @@ export function PushProvider({ children }: { children: React.ReactNode }) {
const [subscription, setSubscription] = useState(null)
const registerServiceWorker = async () => {
- console.log('Registering service worker')
+ console.log('[PushProvider] Getting service worker registration')
try {
- const reg = await navigator.serviceWorker.register('/sw.js', {
- scope: '/',
- updateViaCache: 'none',
- })
- console.log({ reg })
+ // Use existing SW registration (registered in layout.tsx for offline support)
+ // navigator.serviceWorker.ready waits for SW to be registered and active
+ // Timeout after 10s to prevent infinite wait if SW registration fails
+ const reg = (await Promise.race([
+ navigator.serviceWorker.ready,
+ new Promise((_, reject) => setTimeout(() => reject(new Error('SW registration timeout')), 10000)),
+ ])) as ServiceWorkerRegistration
+
+ console.log('[PushProvider] SW already registered:', reg.scope)
setRegistration(reg)
const sub = await reg.pushManager.getSubscription()
- console.log({ sub })
+ console.log('[PushProvider] Push subscription:', sub)
if (sub) {
// @ts-ignore
@@ -47,8 +51,9 @@ export function PushProvider({ children }: { children: React.ReactNode }) {
setIsSubscribed(true)
}
} catch (error) {
- console.error('Service Worker registration failed:', error)
+ console.error('[PushProvider] Failed to get SW registration:', error)
captureException(error)
+ // toast.error('Failed to initialize notifications')
}
}
@@ -65,7 +70,7 @@ export function PushProvider({ children }: { children: React.ReactNode }) {
.catch((error) => {
console.error('Service Worker not ready:', error)
captureException(error)
- toast.error('Failed to initialize notifications')
+ // toast.error('Failed to initialize notifications')
})
} else {
console.log('Service Worker and Push Manager are not supported')
diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts
index 1b1404800..9f08f765c 100644
--- a/src/hooks/query/user.ts
+++ b/src/hooks/query/user.ts
@@ -8,7 +8,7 @@ import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { usePWAStatus } from '../usePWAStatus'
import { useDeviceType } from '../useGetDeviceType'
-export const useUserQuery = (dependsOn?: boolean) => {
+export const useUserQuery = (dependsOn: boolean = true) => {
const isPwa = usePWAStatus()
const { deviceType } = useDeviceType()
const dispatch = useAppDispatch()
@@ -45,19 +45,25 @@ export const useUserQuery = (dependsOn?: boolean) => {
queryKey: [USER],
queryFn: fetchUser,
retry: 0,
- // only enable the query if:
- // 1. dependsOn is true
- // 2. no user is currently in the Redux store
+ // Enable if dependsOn is true (defaults to true) and no Redux user exists yet
enabled: dependsOn && !authUser?.user.userId,
- // cache the data for 10 minutes
- staleTime: 1000 * 60 * 10,
- // refetch only when window is focused if data is stale
+ // Two-tier caching strategy for optimal performance:
+ // TIER 1: TanStack Query in-memory cache (5 min)
+ // - Zero latency for active sessions
+ // - Lost on page refresh (intentional - forces SW cache check)
+ // TIER 2: Service Worker disk cache (1 week StaleWhileRevalidate)
+ // - <50ms response on cold start/offline
+ // - Persists across sessions
+ // Flow: TQ cache → if stale → fetch() → SW intercepts → SW cache → Network
+ staleTime: 5 * 60 * 1000, // 5 min (balance: fresh enough + reduces SW hits)
+ gcTime: 10 * 60 * 1000, // Keep unused data 10 min before garbage collection
+ // Refetch on mount - TQ automatically skips if data is fresh (< staleTime)
+ refetchOnMount: true,
+ // Refetch on focus - TQ automatically skips if data is fresh (< staleTime)
refetchOnWindowFocus: true,
- // prevent unnecessary refetches
- refetchOnMount: false,
- // add initial data from Redux if available
+ // Initialize with Redux data if available (hydration)
initialData: authUser || undefined,
- // keep previous data
+ // Keep previous data during refetch (smooth UX, no flicker)
placeholderData: keepPreviousData,
})
}
diff --git a/src/hooks/useContacts.ts b/src/hooks/useContacts.ts
new file mode 100644
index 000000000..84ee7074a
--- /dev/null
+++ b/src/hooks/useContacts.ts
@@ -0,0 +1,58 @@
+'use client'
+import { useInfiniteQuery } from '@tanstack/react-query'
+import { CONTACTS } from '@/constants/query.consts'
+import { getContacts } from '@/app/actions/users'
+import { type Contact, type ContactsResponse } from '@/interfaces'
+
+export type { Contact }
+
+interface UseContactsOptions {
+ limit?: number
+}
+
+/**
+ * hook to fetch all contacts for the current user with infinite scroll
+ * includes: inviter, invitees, and all transaction counterparties (sent/received money, request pots)
+ */
+export function useContacts(options: UseContactsOptions = {}) {
+ const { limit = 50 } = options
+
+ const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = useInfiniteQuery({
+ queryKey: [CONTACTS, limit],
+ queryFn: async ({ pageParam = 0 }): Promise => {
+ const result = await getContacts({
+ limit,
+ offset: pageParam * limit,
+ })
+
+ if (result.error) {
+ throw new Error(result.error)
+ }
+
+ if (!result.data) {
+ throw new Error('No data returned from server')
+ }
+
+ return result.data
+ },
+ getNextPageParam: (lastPage, allPages) => {
+ // if hasMore is true, return next page number
+ return lastPage.hasMore ? allPages.length : undefined
+ },
+ initialPageParam: 0,
+ staleTime: 5 * 60 * 1000, // 5 minutes
+ })
+
+ // flatten all pages into single contacts array
+ const allContacts = data?.pages.flatMap((page) => page.contacts) || []
+
+ return {
+ contacts: allContacts,
+ isLoading,
+ error,
+ fetchNextPage,
+ hasNextPage,
+ isFetchingNextPage,
+ refetch,
+ }
+}
diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx
index bdea5d4e7..ce30d24ce 100644
--- a/src/hooks/useHomeCarouselCTAs.tsx
+++ b/src/hooks/useHomeCarouselCTAs.tsx
@@ -8,6 +8,9 @@ import { useRouter } from 'next/navigation'
import useKycStatus from './useKycStatus'
import type { StaticImageData } from 'next/image'
import { useQrCodeContext } from '@/context/QrCodeContext'
+import { getUserPreferences, updateUserPreferences } from '@/utils'
+import { DEVCONNECT_LOGO } from '@/assets'
+import { DEVCONNECT_INTENT_EXPIRY_MS } from '@/constants'
export type CarouselCTA = {
id: string
@@ -34,6 +37,51 @@ export const useHomeCarouselCTAs = () => {
const { setIsQRScannerOpen } = useQrCodeContext()
+ // --------------------------------------------------------------------------------------------------
+ /**
+ * check if there's a pending devconnect intent and clean up old ones
+ *
+ * @dev: note, this code needs to be deleted post devconnect, this is just to temporarily support onramp to devconnect wallet using bank accounts
+ */
+ const [pendingDevConnectIntent, setPendingDevConnectIntent] = useState<
+ | {
+ id: string
+ recipientAddress: string
+ chain: string
+ amount: string
+ onrampId?: string
+ createdAt: number
+ status: 'pending' | 'completed'
+ }
+ | undefined
+ >(undefined)
+
+ useEffect(() => {
+ if (!user?.user?.userId) {
+ setPendingDevConnectIntent(undefined)
+ return
+ }
+
+ const prefs = getUserPreferences(user.user.userId)
+ const intents = prefs?.devConnectIntents ?? []
+
+ // clean up intents older than 7 days
+ const expiryTime = Date.now() - DEVCONNECT_INTENT_EXPIRY_MS
+ const recentIntents = intents.filter((intent) => intent.createdAt >= expiryTime && intent.status === 'pending')
+
+ // update user preferences if we cleaned up any old intents
+ if (recentIntents.length !== intents.length) {
+ updateUserPreferences(user.user.userId, {
+ devConnectIntents: recentIntents,
+ })
+ }
+
+ // get the most recent pending intent (sorted by createdAt descending)
+ const mostRecentIntent = recentIntents.sort((a, b) => b.createdAt - a.createdAt)[0]
+ setPendingDevConnectIntent(mostRecentIntent)
+ }, [user?.user?.userId])
+ // --------------------------------------------------------------------------------------------------
+
const generateCarouselCTAs = useCallback(() => {
const _carouselCTAs: CarouselCTA[] = []
@@ -60,6 +108,33 @@ export const useHomeCarouselCTAs = () => {
})
}
+ // ------------------------------------------------------------------------------------------------
+ // add devconnect payment cta if there's a pending intent
+ // @dev: note, this code needs to be deleted post devconnect, this is just to temporarily support onramp to devconnect wallet using bank accounts
+ if (pendingDevConnectIntent) {
+ _carouselCTAs.push({
+ id: 'devconnect-payment',
+ title: 'Fund your DevConnect wallet',
+ description: `Deposit funds to your DevConnect wallet`,
+ logo: DEVCONNECT_LOGO,
+ icon: 'arrow-up-right',
+ onClick: () => {
+ // navigate to the semantic request flow where user can pay with peanut wallet
+ const paymentUrl = `/${pendingDevConnectIntent.recipientAddress}@${pendingDevConnectIntent.chain}`
+ router.push(paymentUrl)
+ },
+ onClose: () => {
+ // remove the intent when user dismisses the cta
+ if (user?.user?.userId) {
+ updateUserPreferences(user.user.userId, {
+ devConnectIntents: [],
+ })
+ }
+ },
+ })
+ }
+ // --------------------------------------------------------------------------------------------------
+
// add notification prompt as first item if it should be shown
if (showReminderBanner) {
_carouselCTAs.push({
@@ -101,6 +176,8 @@ export const useHomeCarouselCTAs = () => {
setCarouselCTAs(_carouselCTAs)
}, [
+ pendingDevConnectIntent,
+ user?.user?.userId,
showReminderBanner,
isPermissionDenied,
isUserKycApproved,
diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts
new file mode 100644
index 000000000..6c12f9570
--- /dev/null
+++ b/src/hooks/useInfiniteScroll.ts
@@ -0,0 +1,56 @@
+'use client'
+
+import { useEffect, useRef } from 'react'
+
+interface UseInfiniteScrollOptions {
+ hasNextPage: boolean
+ isFetchingNextPage: boolean
+ fetchNextPage: () => void
+ enabled?: boolean // optional flag to disable infinite scroll (e.g., when searching)
+ threshold?: number // intersection observer threshold
+}
+
+/**
+ * custom hook for viewport-based infinite scroll using intersection observer
+ * abstracts the common pattern used across history, contacts, etc.
+ */
+export function useInfiniteScroll({
+ hasNextPage,
+ isFetchingNextPage,
+ fetchNextPage,
+ enabled = true,
+ threshold = 0.1,
+}: UseInfiniteScrollOptions) {
+ const loaderRef = useRef(null)
+
+ useEffect(() => {
+ // skip if disabled
+ if (!enabled) return
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ const target = entries[0]
+ // trigger fetchNextPage when loader comes into view
+ if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
+ fetchNextPage()
+ }
+ },
+ {
+ threshold,
+ }
+ )
+
+ const currentLoaderRef = loaderRef.current
+ if (currentLoaderRef) {
+ observer.observe(currentLoaderRef)
+ }
+
+ return () => {
+ if (currentLoaderRef) {
+ observer.unobserve(currentLoaderRef)
+ }
+ }
+ }, [hasNextPage, isFetchingNextPage, fetchNextPage, enabled, threshold])
+
+ return { loaderRef }
+}
diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts
new file mode 100644
index 000000000..74ffdfb29
--- /dev/null
+++ b/src/hooks/useNetworkStatus.ts
@@ -0,0 +1,92 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * NETWORK RESILIENCE: Detects online/offline status using navigator.onLine
+ *
+ * ⚠️ LIMITATION: navigator.onLine has false positives (WiFi connected but no internet,
+ * captive portals, VPN/firewall issues). Use as UI hint only. TanStack Query's network
+ * detection tests actual connectivity and is more reliable for request retries.
+ *
+ * 🔄 AUTO-RELOAD: When connection is restored, page automatically reloads to fetch fresh data
+ *
+ * @returns isOnline - Current connection status per navigator.onLine
+ * @returns wasOffline - Deprecated (kept for backward compatibility, always false)
+ * @returns isInitialized - True after component has mounted (prevents showing offline screen on initial load)
+ */
+export function useNetworkStatus() {
+ const [isOnline, setIsOnline] = useState(() =>
+ typeof navigator !== 'undefined' ? navigator.onLine : true
+ )
+ const [wasOffline, setWasOffline] = useState(false)
+ const [isInitialized, setIsInitialized] = useState(false)
+
+ useEffect(() => {
+ let timeoutId: ReturnType | null = null
+ let pollIntervalId: ReturnType | null = null
+
+ const handleOnline = () => {
+ setIsOnline(true)
+ // reload immediately when connection is restored to get fresh content
+ // skip the "back online" screen for faster/cleaner ux
+ window.location.reload()
+ }
+
+ const handleOffline = () => {
+ setIsOnline(false)
+ setWasOffline(false)
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ timeoutId = null
+ }
+ }
+
+ // check current status after mount and mark as initialized
+ const checkOnlineStatus = () => {
+ const currentStatus = navigator.onLine
+ if (currentStatus !== isOnline) {
+ if (currentStatus) {
+ handleOnline()
+ } else {
+ handleOffline()
+ }
+ }
+ }
+
+ // initial check and mark as initialized after short delay
+ // this ensures we catch the actual status after mount
+ setTimeout(() => {
+ checkOnlineStatus()
+ setIsInitialized(true)
+ }, 100)
+
+ // poll every 2 seconds to catch DevTools offline toggle
+ // necessary because online/offline events don't always fire reliably in DevTools
+ pollIntervalId = setInterval(checkOnlineStatus, 2000)
+
+ // listen to standard events (works in production/real network changes)
+ window.addEventListener('online', handleOnline)
+ window.addEventListener('offline', handleOffline)
+
+ // also check on visibility change (user returns to tab)
+ const handleVisibilityChange = () => {
+ if (!document.hidden) {
+ checkOnlineStatus()
+ }
+ }
+ document.addEventListener('visibilitychange', handleVisibilityChange)
+
+ return () => {
+ window.removeEventListener('online', handleOnline)
+ window.removeEventListener('offline', handleOffline)
+ document.removeEventListener('visibilitychange', handleVisibilityChange)
+ if (timeoutId) {
+ clearTimeout(timeoutId)
+ }
+ if (pollIntervalId) {
+ clearInterval(pollIntervalId)
+ }
+ }
+ }, [isOnline])
+
+ return { isOnline, wasOffline, isInitialized }
+}
diff --git a/src/hooks/usePullToRefresh.ts b/src/hooks/usePullToRefresh.ts
new file mode 100644
index 000000000..166faa92d
--- /dev/null
+++ b/src/hooks/usePullToRefresh.ts
@@ -0,0 +1,65 @@
+import { useRouter } from 'next/navigation'
+import { useEffect, useRef } from 'react'
+import PullToRefresh from 'pulltorefreshjs'
+
+// pull-to-refresh configuration constants
+const DIST_THRESHOLD = 70 // minimum pull distance to trigger refresh
+const DIST_MAX = 120 // maximum pull distance (visual limit)
+const DIST_RELOAD = 80 // distance at which refresh is triggered when released
+
+interface UsePullToRefreshOptions {
+ // custom function to determine if pull-to-refresh should be enabled
+ // defaults to checking if window is at the top
+ shouldPullToRefresh?: () => boolean
+ // whether to enable pull-to-refresh (defaults to true)
+ enabled?: boolean
+}
+
+/**
+ * hook to enable pull-to-refresh functionality on mobile devices
+ * native pull-to-refresh is disabled via css (overscroll-behavior-y: none in globals.css)
+ * this hook uses pulltorefreshjs library for consistent behavior across ios and android
+ */
+export const usePullToRefresh = (options: UsePullToRefreshOptions = {}) => {
+ const router = useRouter()
+ const { shouldPullToRefresh, enabled = true } = options
+
+ // store callback in ref to avoid re-initialization when function reference changes
+ const shouldPullToRefreshRef = useRef(shouldPullToRefresh)
+
+ // update ref when callback changes
+ useEffect(() => {
+ shouldPullToRefreshRef.current = shouldPullToRefresh
+ }, [shouldPullToRefresh])
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || !enabled) return
+
+ // default behavior: allow pull-to-refresh when window is at the top
+ const defaultShouldPullToRefresh = () => window.scrollY === 0
+
+ PullToRefresh.init({
+ mainElement: 'body',
+ onRefresh: () => {
+ // router.refresh() returns void, wrap in promise for pulltorefreshjs
+ router.refresh()
+ return Promise.resolve()
+ },
+ instructionsPullToRefresh: 'Pull down to refresh',
+ instructionsReleaseToRefresh: 'Release to refresh',
+ instructionsRefreshing: 'Refreshing...',
+ shouldPullToRefresh: () => {
+ // use latest callback from ref
+ const callback = shouldPullToRefreshRef.current
+ return callback ? callback() : defaultShouldPullToRefresh()
+ },
+ distThreshold: DIST_THRESHOLD,
+ distMax: DIST_MAX,
+ distReload: DIST_RELOAD,
+ })
+
+ return () => {
+ PullToRefresh.destroyAll()
+ }
+ }, [router, enabled])
+}
diff --git a/src/hooks/useRecentUsers.ts b/src/hooks/useRecentUsers.ts
deleted file mode 100644
index e403618f5..000000000
--- a/src/hooks/useRecentUsers.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-'use client'
-import { useMemo } from 'react'
-import { useTransactionHistory, type HistoryEntry } from '@/hooks/useTransactionHistory'
-import { type RecentUser } from '@/services/users'
-
-export function useRecentUsers() {
- const { data, isLoading } = useTransactionHistory({ mode: 'latest', limit: 20 })
-
- const recentTransactions = useMemo(() => {
- if (!data) {
- return []
- }
- return data.entries.reduce((acc: RecentUser[], entry: HistoryEntry) => {
- let account
- if (entry.userRole === 'SENDER') {
- account = entry.recipientAccount
- } else if (entry.userRole === 'RECIPIENT') {
- account = entry.senderAccount
- }
- if (!account?.isUser || !account.username) return acc
- const isDuplicate = acc.some(
- (user) =>
- user.userId === account.userId || user.username.toLowerCase() === account.username.toLowerCase()
- )
- if (isDuplicate) return acc
- acc.push({
- userId: account.userId!,
- username: account.username!,
- fullName: account.fullName!,
- bridgeKycStatus: entry.isVerified ? 'approved' : 'not_started',
- })
- return acc
- }, [])
- }, [data])
-
- return { recentTransactions, isFetchingRecentUsers: isLoading }
-}
diff --git a/src/hooks/useTransactionHistory.ts b/src/hooks/useTransactionHistory.ts
index cbb7689e0..87f078dbf 100644
--- a/src/hooks/useTransactionHistory.ts
+++ b/src/hooks/useTransactionHistory.ts
@@ -83,6 +83,8 @@ export function useTransactionHistory({
}
// Latest transactions mode (for home page)
+ // Two-tier caching: TQ in-memory (30s) → SW disk cache (1 week) → Network
+ // Balance: Fresh enough for home page + reduces redundant SW cache hits
if (mode === 'latest') {
// if filterMutualTxs is true, we need to add the username to the query key to invalidate the query when the username changes
const queryKeyTxn = TRANSACTIONS + (filterMutualTxs ? username : '')
@@ -90,17 +92,26 @@ export function useTransactionHistory({
queryKey: [queryKeyTxn, 'latest', { limit }],
queryFn: () => fetchHistory({ limit }),
enabled,
- staleTime: 5 * 60 * 1000, // 5 minutes
+ // 30s cache: Fresh enough for home page widget
+ // On cold start, will fetch → SW responds <50ms from cache
+ staleTime: 30 * 1000, // 30 seconds (balance: freshness vs performance)
+ gcTime: 5 * 60 * 1000, // Keep in memory for 5min
+ // Refetch on mount - TQ automatically skips if data is fresh (< staleTime)
+ refetchOnMount: true,
+ // Refetch on focus - TQ automatically skips if data is fresh (< staleTime)
+ refetchOnWindowFocus: true,
})
}
// Infinite query mode (for main history page)
+ // Uses longer staleTime since user is actively browsing (less critical for instant updates)
return useInfiniteQuery({
queryKey: [TRANSACTIONS, 'infinite', { limit }],
queryFn: ({ pageParam }) => fetchHistory({ cursor: pageParam, limit }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => (lastPage.hasMore ? lastPage.cursor : undefined),
enabled,
- staleTime: 5 * 60 * 1000, // 5 minutes
+ staleTime: 30 * 1000, // 30 seconds (infinite scroll doesn't need instant updates)
+ gcTime: 5 * 60 * 1000, // Keep in memory for 5min
})
}
diff --git a/src/hooks/wallet/useBalance.ts b/src/hooks/wallet/useBalance.ts
index 99f595aa8..ef6a89fa4 100644
--- a/src/hooks/wallet/useBalance.ts
+++ b/src/hooks/wallet/useBalance.ts
@@ -6,12 +6,27 @@ import { PEANUT_WALLET_TOKEN, peanutPublicClient } from '@/constants'
/**
* Hook to fetch and auto-refresh wallet balance using TanStack Query
*
+ * ⚠️ NOTE: Service Worker CANNOT cache RPC POST requests
+ * - Blockchain RPC calls use POST method (not cacheable by Cache Storage API)
+ * - See: https://w3c.github.io/ServiceWorker/#cache-put (point 4)
+ * - Future: Consider server-side proxy to enable SW caching
+ *
+ * Current caching strategy (in-memory only):
+ * - TanStack Query caches balance for 30 seconds in memory
+ * - Cache is lost on page refresh/reload
+ * - Balance refetches from blockchain RPC on every app open
+ *
+ * Why staleTime: 30s:
+ * - Balances data that's 30s old during active session
+ * - Reduces RPC calls during navigation (balance displayed on multiple pages)
+ * - Prevents rate limiting from RPC providers
+ * - Balance still updates every 30s automatically
+ *
* Features:
+ * - In-memory cache for 30s (fast during active session)
* - Auto-refreshes every 30 seconds
- * - Refetches when window regains focus
- * - Refetches after network reconnection
- * - Built-in retry on failure
- * - Caching and deduplication
+ * - Built-in retry with exponential backoff
+ * - Refetches on window focus and network reconnection
*/
export const useBalance = (address: Address | undefined) => {
return useQuery({
@@ -27,7 +42,8 @@ export const useBalance = (address: Address | undefined) => {
return balance
},
enabled: !!address, // Only run query if address exists
- staleTime: 10 * 1000, // Consider data stale after 10 seconds
+ staleTime: 30 * 1000, // Cache balance for 30s in memory (no SW caching for POST requests)
+ gcTime: 5 * 60 * 1000, // Keep in memory for 5min
refetchInterval: 30 * 1000, // Auto-refresh every 30 seconds
refetchOnWindowFocus: true, // Refresh when tab regains focus
refetchOnReconnect: true, // Refresh after network reconnection
diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts
index 1630bb94c..2e4d5e9a0 100644
--- a/src/hooks/wallet/useSendMoney.ts
+++ b/src/hooks/wallet/useSendMoney.ts
@@ -2,7 +2,6 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import { parseUnits, encodeFunctionData, erc20Abi } from 'viem'
import type { Address, Hash, Hex, TransactionReceipt } from 'viem'
import { PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS, PEANUT_WALLET_CHAIN } from '@/constants'
-import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import { TRANSACTIONS, BALANCE_DECREASE, SEND_MONEY } from '@/constants/query.consts'
import { useToast } from '@/components/0_Bruddle/Toast'
@@ -40,6 +39,10 @@ export const useSendMoney = ({ address, handleSendUserOpEncoded }: UseSendMoneyO
return useMutation({
mutationKey: [BALANCE_DECREASE, SEND_MONEY],
+ // Disable retry for financial transactions to prevent duplicate payments
+ // Blockchain transactions are not idempotent at the mutation level
+ // If a transaction succeeds but times out, retrying would create a duplicate payment
+ retry: false,
mutationFn: async ({ toAddress, amountInUsd }: SendMoneyParams) => {
const amountToSend = parseUnits(amountInUsd, PEANUT_WALLET_TOKEN_DECIMALS)
@@ -89,12 +92,17 @@ export const useSendMoney = ({ address, handleSendUserOpEncoded }: UseSendMoneyO
// On success, refresh real data from blockchain
onSuccess: () => {
- // Invalidate balance to fetch real value
+ // Invalidate TanStack Query balance cache to fetch fresh value
queryClient.invalidateQueries({ queryKey: ['balance', address] })
// Invalidate transaction history to show new transaction
queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
+ // NOTE: We intentionally do NOT clear Service Worker RPC cache here
+ // This prioritizes instant load (<50ms) over immediate accuracy
+ // User sees cached balance instantly, then it updates via background refresh (1-2s)
+ // Tradeoff: After payment, user may see old balance briefly on next app open
+
console.log('[useSendMoney] Transaction successful, refreshing balance and history')
},
diff --git a/src/hooks/wallet/useSignUserOp.ts b/src/hooks/wallet/useSignUserOp.ts
new file mode 100644
index 000000000..1735d6602
--- /dev/null
+++ b/src/hooks/wallet/useSignUserOp.ts
@@ -0,0 +1,104 @@
+'use client'
+
+import { useCallback } from 'react'
+import { useKernelClient } from '@/context/kernelClient.context'
+import { signUserOperation } from '@zerodev/sdk/actions'
+import {
+ PEANUT_WALLET_CHAIN,
+ PEANUT_WALLET_TOKEN,
+ PEANUT_WALLET_TOKEN_DECIMALS,
+ USER_OP_ENTRY_POINT,
+} from '@/constants/zerodev.consts'
+import { parseUnits, encodeFunctionData, erc20Abi } from 'viem'
+import type { Hex, Address } from 'viem'
+import type { SignUserOperationReturnType } from '@zerodev/sdk/actions'
+import { captureException } from '@sentry/nextjs'
+
+export interface SignedUserOpData {
+ signedUserOp: SignUserOperationReturnType
+ chainId: string
+ entryPointAddress: Address
+}
+
+/**
+ * Hook to sign UserOperations without broadcasting them to the network.
+ * This allows for a two-phase commit pattern where the transaction is signed first,
+ * then submitted from the backend after confirming external dependencies (e.g., Manteca payment).
+ */
+export const useSignUserOp = () => {
+ const { getClientForChain } = useKernelClient()
+
+ /**
+ * Signs a USDC transfer UserOperation without broadcasting it.
+ *
+ * @param toAddress - Recipient address
+ * @param amountInUsd - Amount in USD (will be converted to USDC token decimals)
+ * @param chainId - Chain ID (defaults to Peanut wallet chain)
+ * @returns Signed UserOperation data ready for backend submission
+ *
+ * @throws Error if signing fails (e.g., user cancels, invalid parameters)
+ */
+ const signTransferUserOp = useCallback(
+ async (
+ toAddress: Address,
+ amountInUsd: string,
+ chainId: string = PEANUT_WALLET_CHAIN.id.toString()
+ ): Promise => {
+ try {
+ const client = getClientForChain(chainId)
+
+ // Ensure account is initialized
+ if (!client.account) {
+ throw new Error('Smart account not initialized')
+ }
+
+ // Parse amount to USDC decimals (6 decimals)
+ const amount = parseUnits(amountInUsd.replace(/,/g, ''), PEANUT_WALLET_TOKEN_DECIMALS)
+
+ // Encode USDC transfer function call
+ const txData = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [toAddress, amount],
+ }) as Hex
+
+ // Build USDC transfer call (not native token transfer)
+ const calls = [
+ {
+ to: PEANUT_WALLET_TOKEN as Hex, // USDC contract address
+ value: 0n, // No native token sent
+ data: txData, // Encoded transfer call
+ },
+ ]
+
+ // Sign the UserOperation (does NOT broadcast)
+ // This fills in all required fields (gas, nonce, paymaster, signature)
+ const signedUserOp = await signUserOperation(client, {
+ account: client.account,
+ calls,
+ })
+
+ // Return everything the backend needs to submit the UserOp
+ return {
+ signedUserOp,
+ chainId,
+ entryPointAddress: USER_OP_ENTRY_POINT.address,
+ }
+ } catch (error) {
+ console.error('[useSignUserOp] Error signing UserOperation:', error)
+ captureException(error, {
+ tags: { feature: 'sign-user-op' },
+ extra: {
+ toAddress,
+ amountInUsd,
+ chainId,
+ },
+ })
+ throw error
+ }
+ },
+ [getClientForChain]
+ )
+
+ return { signTransferUserOp }
+}
diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts
index bab25903e..890ce4037 100644
--- a/src/interfaces/interfaces.ts
+++ b/src/interfaces/interfaces.ts
@@ -330,25 +330,26 @@ export interface IUserProfile {
invitedBy: string | null // Username of the person who invited this user
}
-interface Contact {
- user_id: string
- contact_id: string
- peanut_account_id: string | null
- account_identifier: string
- account_type: string
- nickname: string | null
- ens_name: string | null
- created_at: string
- updated_at: string
- n_interactions: number
- usd_volume_transacted: string
- last_interacted_with: string | null
- username: string | null
- profile_picture: string | null
-}
-
export type JSONValue = string | number | boolean | null | JSONValue[] | { [key: string]: JSONValue }
export type JSONObject = {
[key: string]: JSONValue
}
+
+export interface Contact {
+ userId: string
+ username: string
+ fullName: string | null
+ bridgeKycStatus: string | null
+ showFullName: boolean
+ relationshipTypes: ('inviter' | 'invitee' | 'sent_money' | 'received_money')[]
+ firstInteractionDate: string
+ lastInteractionDate: string
+ transactionCount: number
+}
+
+export interface ContactsResponse {
+ contacts: Contact[]
+ total: number
+ hasMore: boolean
+}
diff --git a/src/lib/url-parser/parser.ts b/src/lib/url-parser/parser.ts
index 8df2f5ad7..0cd0c247b 100644
--- a/src/lib/url-parser/parser.ts
+++ b/src/lib/url-parser/parser.ts
@@ -6,7 +6,6 @@ import { validateAndResolveRecipient } from '../validation/recipient'
import { getChainDetails, getTokenAndChainDetails } from '../validation/token'
import { AmountValidationError } from './errors'
import { type ParsedURL } from './types/payment'
-import { areEvmAddressesEqual } from '@/utils'
export function parseAmountAndToken(amountString: string): { amount?: string; token?: string } {
// remove all whitespace
@@ -160,13 +159,23 @@ export async function parsePaymentURL(
tokenDetails = chainDetails.tokens.find((t) => t.symbol.toLowerCase() === 'USDC'.toLowerCase())
}
- // 6. Construct and return the final result
+ // 6. Determine if this is a DevConnect flow
+ // @dev: note, this needs to be deleted post devconnect
+ // devconnect flow: external address + base chain specified in URL
+ const isDevConnectFlow =
+ recipientDetails.recipientType === 'ADDRESS' &&
+ chainId !== undefined &&
+ chainId.toLowerCase() === 'base' &&
+ chainDetails !== undefined
+
+ // 7. Construct and return the final result
return {
parsedUrl: {
recipient: recipientDetails,
amount: parsedAmount?.amount,
token: tokenDetails,
chain: chainDetails,
+ isDevConnectFlow,
},
error: null,
}
diff --git a/src/lib/url-parser/types/payment.ts b/src/lib/url-parser/types/payment.ts
index 1c7a9b31e..37f7d46ce 100644
--- a/src/lib/url-parser/types/payment.ts
+++ b/src/lib/url-parser/types/payment.ts
@@ -13,4 +13,6 @@ export interface ParsedURL {
amount?: string
token?: interfaces.ISquidToken
chain?: interfaces.ISquidChain & { tokens: interfaces.ISquidToken[] }
+ /** @dev: flag indicating if this is a devconnect flow (external address + base chain), to be deleted post devconnect */
+ isDevConnectFlow?: boolean
}
diff --git a/src/services/manteca.ts b/src/services/manteca.ts
index 5c04a156a..68394e9ff 100644
--- a/src/services/manteca.ts
+++ b/src/services/manteca.ts
@@ -5,9 +5,10 @@ import {
type MantecaWithdrawResponse,
type CreateMantecaOnrampParams,
} from '@/types/manteca.types'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry, jsonStringify } from '@/utils'
import Cookies from 'js-cookie'
-import type { Address, Hash } from 'viem'
+import type { Address } from 'viem'
+import type { SignUserOperationReturnType } from '@zerodev/sdk/actions'
export interface QrPaymentRequest {
qrCode: string
@@ -107,7 +108,7 @@ export const mantecaApi = {
'Content-Type': 'application/json',
Authorization: `Bearer ${Cookies.get('jwt-token')}`,
},
- body: JSON.stringify(data),
+ body: jsonStringify(data),
})
if (!response.ok) {
@@ -117,25 +118,66 @@ export const mantecaApi = {
return response.json()
},
- completeQrPayment: async ({
+ /**
+ * Complete QR payment with a pre-signed UserOperation.
+ * This allows the backend to complete the Manteca payment BEFORE broadcasting the transaction,
+ * preventing funds from being stuck in Manteca if their payment fails.
+ *
+ * Flow:
+ * 1. Frontend signs UserOp (funds still in user's wallet)
+ * 2. Backend receives signed UserOp
+ * 3. Backend completes Manteca payment first
+ * 4. If Manteca succeeds, backend broadcasts UserOp
+ * 5. If Manteca fails, UserOp is never broadcasted (funds safe)
+ */
+ completeQrPaymentWithSignedTx: async ({
paymentLockCode,
- txHash,
+ signedUserOp,
+ chainId,
+ entryPointAddress,
}: {
paymentLockCode: string
- txHash: Hash
+ signedUserOp: Pick<
+ SignUserOperationReturnType,
+ | 'sender'
+ | 'nonce'
+ | 'callData'
+ | 'signature'
+ | 'callGasLimit'
+ | 'verificationGasLimit'
+ | 'preVerificationGas'
+ | 'maxFeePerGas'
+ | 'maxPriorityFeePerGas'
+ | 'paymaster'
+ | 'paymasterData'
+ | 'paymasterVerificationGasLimit'
+ | 'paymasterPostOpGasLimit'
+ | 'initCode'
+ >
+ chainId: string
+ entryPointAddress: Address
}): Promise => {
- const response = await fetchWithSentry(`${PEANUT_API_URL}/manteca/qr-payment/complete`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${Cookies.get('jwt-token')}`,
+ const response = await fetchWithSentry(
+ `${PEANUT_API_URL}/manteca/qr-payment/complete-with-signed-tx`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${Cookies.get('jwt-token')}`,
+ },
+ body: jsonStringify({
+ paymentLockCode,
+ signedUserOp,
+ chainId,
+ entryPointAddress,
+ }),
},
- body: JSON.stringify({ paymentLockCode, txHash }),
- })
+ 120000
+ )
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
- throw new Error(errorData.message || `QR payment failed: ${response.statusText}`)
+ throw new Error(errorData?.message || errorData?.error || `QR payment failed: ${response.statusText}`)
}
return response.json()
@@ -152,7 +194,7 @@ export const mantecaApi = {
'Content-Type': 'application/json',
Authorization: `Bearer ${Cookies.get('jwt-token')}`,
},
- body: JSON.stringify({ mantecaTransferId }),
+ body: jsonStringify({ mantecaTransferId }),
})
if (!response.ok) {
@@ -188,7 +230,7 @@ export const mantecaApi = {
'Content-Type': 'application/json',
Authorization: `Bearer ${Cookies.get('jwt-token')}`,
},
- body: JSON.stringify(params),
+ body: jsonStringify(params),
})
if (!response.ok) {
@@ -209,7 +251,7 @@ export const mantecaApi = {
'Content-Type': 'application/json',
Authorization: `Bearer ${Cookies.get('jwt-token')}`,
},
- body: JSON.stringify({
+ body: jsonStringify({
usdAmount: params.usdAmount,
currency: params.currency,
chargeId: params.chargeId,
@@ -269,7 +311,7 @@ export const mantecaApi = {
'Content-Type': 'application/json',
Authorization: `Bearer ${Cookies.get('jwt-token')}`,
},
- body: JSON.stringify(data),
+ body: jsonStringify(data),
})
const result = await response.json()
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 3e0907d5f..a431af22e 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -8,6 +8,12 @@
color-scheme: light;
}
+ html,
+ body {
+ /* disable native pull-to-refresh on mobile devices - we use custom implementation */
+ overscroll-behavior-y: none;
+ }
+
body {
/* disable text selection */
@apply select-none;
@@ -281,6 +287,7 @@ Firefox input[type='number'] {
100% {
transform: translateX(0) rotate(0deg);
}
+
10%,
30%,
50%,
@@ -288,6 +295,7 @@ Firefox input[type='number'] {
90% {
transform: translateX(-4px) rotate(-0.5deg);
}
+
20%,
40%,
60%,
@@ -301,6 +309,7 @@ Firefox input[type='number'] {
100% {
transform: translateX(0) rotate(0deg);
}
+
10%,
30%,
50%,
@@ -308,6 +317,7 @@ Firefox input[type='number'] {
90% {
transform: translateX(-8px) rotate(-1deg);
}
+
20%,
40%,
60%,
@@ -321,6 +331,7 @@ Firefox input[type='number'] {
100% {
transform: translate(0, 0) rotate(0deg);
}
+
10%,
30%,
50%,
@@ -328,6 +339,7 @@ Firefox input[type='number'] {
90% {
transform: translate(-12px, -2px) rotate(-1.5deg);
}
+
20%,
40%,
60%,
@@ -341,6 +353,7 @@ Firefox input[type='number'] {
100% {
transform: translate(0, 0) rotate(0deg);
}
+
10%,
30%,
50%,
@@ -348,6 +361,7 @@ Firefox input[type='number'] {
90% {
transform: translate(-16px, -3px) rotate(-2deg);
}
+
20%,
40%,
60%,
diff --git a/src/utils/__tests__/url-parser.test.ts b/src/utils/__tests__/url-parser.test.ts
index d45325baa..cdd19fe73 100644
--- a/src/utils/__tests__/url-parser.test.ts
+++ b/src/utils/__tests__/url-parser.test.ts
@@ -267,6 +267,7 @@ describe('URL Parser Tests', () => {
chain: expect.objectContaining({ chainId: 42161 }),
amount: '0.1',
token: expect.objectContaining({ symbol: 'USDC' }),
+ isDevConnectFlow: false,
})
})
@@ -318,6 +319,7 @@ describe('URL Parser Tests', () => {
chain: undefined,
token: undefined,
amount: undefined,
+ isDevConnectFlow: false,
})
})
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index 3992ca303..f13c7a6ea 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -1,15 +1,8 @@
import * as consts from '@/constants'
-import {
- PEANUT_WALLET_SUPPORTED_TOKENS,
- STABLE_COINS,
- USER_OPERATION_REVERT_REASON_TOPIC,
- ENS_NAME_REGEX,
-} from '@/constants'
-import * as interfaces from '@/interfaces'
+import { STABLE_COINS, USER_OPERATION_REVERT_REASON_TOPIC, ENS_NAME_REGEX } from '@/constants'
import { AccountType } from '@/interfaces'
import * as Sentry from '@sentry/nextjs'
import peanut, { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
-import { SiweMessage } from 'siwe'
import type { Address, TransactionReceipt } from 'viem'
import { getAddress, isAddress, erc20Abi } from 'viem'
import * as wagmiChains from 'wagmi/chains'
@@ -17,6 +10,7 @@ import { getPublicClient, type ChainId } from '@/app/actions/clients'
import { NATIVE_TOKEN_ADDRESS, SQUID_ETH_ADDRESS } from './token.utils'
import { type ChargeEntry } from '@/services/services.types'
import { toWebAuthnKey } from '@zerodev/passkey-validator'
+import type { ParsedURL } from '@/lib/url-parser/types/payment'
export function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
@@ -462,6 +456,16 @@ export type UserPreferences = {
notifBannerShowAt?: number
notifModalClosed?: boolean
hasSeenBalanceWarning?: { value: boolean; expiry: number }
+ // @dev: note, this needs to be deleted post devconnect
+ devConnectIntents?: Array<{
+ id: string
+ recipientAddress: string
+ chain: string
+ amount: string
+ onrampId?: string
+ createdAt: number
+ status: 'pending' | 'completed'
+ }>
}
export const updateUserPreferences = (
@@ -935,3 +939,109 @@ export const getContributorsFromCharge = (charges: ChargeEntry[]) => {
}
})
}
+
+/**
+ * helper function to save devconnect intent to user preferences
+ * @dev: note, this needs to be deleted post devconnect
+ */
+/**
+ * create deterministic id for devconnect intent based on recipient + chain only
+ * amount is not included as it can change during the flow
+ * @dev: to be deleted post devconnect
+ */
+const createDevConnectIntentId = (recipientAddress: string, chain: string): string => {
+ const str = `${recipientAddress.toLowerCase()}-${chain.toLowerCase()}`
+ let hash = 0
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i)
+ hash = (hash << 5) - hash + char
+ hash = hash & hash // convert to 32bit integer
+ }
+ return Math.abs(hash).toString(36)
+}
+
+export const saveDevConnectIntent = (
+ userId: string | undefined,
+ parsedPaymentData: ParsedURL | null,
+ amount: string,
+ onrampId?: string
+): void => {
+ if (!userId) return
+
+ // check both redux state and user preferences (fallback if state was reset)
+ const devconnectFlowData =
+ parsedPaymentData?.isDevConnectFlow && parsedPaymentData.recipient && parsedPaymentData.chain
+ ? {
+ recipientAddress: parsedPaymentData.recipient.resolvedAddress,
+ chain: parsedPaymentData.chain.chainId,
+ }
+ : (() => {
+ try {
+ const prefs = getUserPreferences(userId)
+ const intents = prefs?.devConnectIntents ?? []
+ // get the most recent pending intent
+ return intents.find((i) => i.status === 'pending') ?? null
+ } catch (e) {
+ console.error('Failed to read devconnect intent from user preferences:', e)
+ }
+ return null
+ })()
+
+ if (devconnectFlowData) {
+ // validate required fields
+ const recipientAddress = devconnectFlowData.recipientAddress
+ const chain = devconnectFlowData.chain
+ const cleanedAmount = amount.replace(/,/g, '')
+
+ if (!recipientAddress || !chain || !cleanedAmount) {
+ console.warn('Skipping DevConnect intent: missing required fields')
+ return
+ }
+
+ try {
+ // create deterministic id based on address + chain only
+ const intentId = createDevConnectIntentId(recipientAddress, chain)
+
+ const prefs = getUserPreferences(userId)
+ const existingIntents = prefs?.devConnectIntents ?? []
+
+ // check if intent with same id already exists
+ const existingIntent = existingIntents.find((intent) => intent.id === intentId)
+
+ if (!existingIntent) {
+ // create new intent
+ const { MAX_DEVCONNECT_INTENTS } = require('@/constants/payment.consts')
+ const sortedIntents = existingIntents.sort((a, b) => b.createdAt - a.createdAt)
+ const prunedIntents = sortedIntents.slice(0, MAX_DEVCONNECT_INTENTS - 1)
+
+ updateUserPreferences(userId, {
+ devConnectIntents: [
+ {
+ id: intentId,
+ recipientAddress,
+ chain,
+ amount: cleanedAmount,
+ onrampId,
+ createdAt: Date.now(),
+ status: 'pending',
+ },
+ ...prunedIntents,
+ ],
+ })
+ } else {
+ // update existing intent with new amount and onrampId
+ const updatedIntents = existingIntents.map((intent) =>
+ intent.id === intentId
+ ? { ...intent, amount: cleanedAmount, onrampId, createdAt: Date.now() }
+ : intent
+ )
+ updateUserPreferences(userId, {
+ devConnectIntents: updatedIntents,
+ })
+ }
+ } catch (intentError) {
+ console.error('Failed to save DevConnect intent:', intentError)
+ // don't block the flow if intent storage fails
+ }
+ }
+}
diff --git a/src/utils/retry.utils.ts b/src/utils/retry.utils.ts
new file mode 100644
index 000000000..fb9fd8309
--- /dev/null
+++ b/src/utils/retry.utils.ts
@@ -0,0 +1,56 @@
+/**
+ * Shared retry utilities for network resilience
+ * Provides consistent retry strategies across the application
+ */
+
+/**
+ * Creates an exponential backoff function
+ * Delays increase exponentially: baseDelay, baseDelay*2, baseDelay*4, etc., up to maxDelay
+ *
+ * @param baseDelay - Initial delay in milliseconds (default: 1000ms)
+ * @param maxDelay - Maximum delay in milliseconds (default: 5000ms)
+ * @returns Function that calculates delay for a given attempt index
+ */
+export const createExponentialBackoff = (baseDelay: number = 1000, maxDelay: number = 5000) => {
+ return (attemptIndex: number) => Math.min(baseDelay * 2 ** attemptIndex, maxDelay)
+}
+
+/**
+ * Predefined retry strategies for different use cases
+ */
+export const RETRY_STRATEGIES = {
+ /**
+ * Fast retry: 2 retries with exponential backoff (1s, 2s, max 5s)
+ * Use for: User-facing queries that need quick feedback
+ */
+ FAST: {
+ retry: 2,
+ retryDelay: createExponentialBackoff(1000, 5000),
+ },
+
+ /**
+ * Standard retry: 3 retries with exponential backoff (1s, 2s, 4s, max 30s)
+ * Use for: Background queries, non-critical data
+ */
+ STANDARD: {
+ retry: 3,
+ retryDelay: createExponentialBackoff(1000, 30000),
+ },
+
+ /**
+ * Aggressive retry: 4 retries with exponential backoff (1s, 2s, 4s, 8s, max 10s)
+ * Use for: Critical data that must succeed
+ */
+ AGGRESSIVE: {
+ retry: 4,
+ retryDelay: createExponentialBackoff(1000, 10000),
+ },
+
+ /**
+ * No retry: Financial transactions must not be retried automatically
+ * Use for: Payments, withdrawals, claims - any operation that transfers funds
+ */
+ FINANCIAL: {
+ retry: false,
+ },
+} as const