@@ -48,7 +45,7 @@ const LinkSendSuccessView = () => {
title="Send"
onPrev={() => {
router.push('/home')
- dispatch(sendFlowActions.resetSendFlow())
+ resetLinkSendFlow()
}}
/>
diff --git a/src/components/Send/views/Contacts.view.tsx b/src/components/Send/views/Contacts.view.tsx
index 0fd722b1f..148b4e59b 100644
--- a/src/components/Send/views/Contacts.view.tsx
+++ b/src/components/Send/views/Contacts.view.tsx
@@ -1,26 +1,32 @@
'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 { useState, useEffect } 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'
+import { Button } from '@/components/0_Bruddle/Button'
+import { useDebounce } from '@/hooks/useDebounce'
+import { ContactsListSkeleton } from '@/components/Common/ContactsListSkeleton'
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 [searchQuery, setSearchQuery] = useState('')
+ const [hasLoadedOnce, setHasLoadedOnce] = useState(false)
+
+ // debounce search query to avoid excessive API calls
+ const debouncedSearchQuery = useDebounce(searchQuery, 300)
+
+ // fetch contacts with server-side search
const {
contacts,
isLoading: isFetchingContacts,
@@ -29,39 +35,33 @@ export default function ContactsView() {
hasNextPage,
isFetchingNextPage,
refetch,
- } = useContacts({ limit: 50 })
- const [searchQuery, setSearchQuery] = useState('')
+ } = useContacts({
+ limit: 50,
+ search: debouncedSearchQuery || undefined,
+ })
- // infinite scroll hook - disabled when searching (search is client-side)
+ // infinite scroll hook - always enabled for server-side pagination
const { loaderRef } = useInfiniteScroll({
hasNextPage,
isFetchingNextPage,
fetchNextPage,
- enabled: !searchQuery, // disable when user is searching
+ enabled: true,
})
- // 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])
+ // track when we've loaded data at least once
+ useEffect(() => {
+ if (!hasLoadedOnce && !isFetchingContacts) {
+ setHasLoadedOnce(true)
+ }
+ }, [isFetchingContacts, hasLoadedOnce])
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 {
@@ -78,7 +78,8 @@ export default function ContactsView() {
router.push(`/send/${username}`)
}
- if (isFetchingContacts) {
+ // only show full loading on initial load (before any data has been fetched)
+ if (isFetchingContacts && !hasLoadedOnce) {
return
}
@@ -109,13 +110,18 @@ export default function ContactsView() {
)
}
+ // determine if we have any contacts (initial load without search)
+ const hasContacts = contacts.length > 0 || !!debouncedSearchQuery
+ const isSearching = !!debouncedSearchQuery
+ const hasNoSearchResults = isSearching && contacts.length === 0
+
return (
- {contacts.length > 0 ? (
+ {hasContacts ? (
- {/* search input */}
+ {/* search input - always show when there are contacts or when searching */}
setSearchQuery(e.target.value)}
@@ -123,12 +129,15 @@ export default function ContactsView() {
placeholder="Search contacts..."
/>
- {/* contacts list */}
- {filteredContacts.length > 0 ? (
+ {/* contacts list or search results */}
+ {isFetchingContacts ? (
+ // show skeleton when searching/refetching
+
+ ) : contacts.length > 0 ? (
Your contacts
- {filteredContacts.map((contact, index) => {
+ {contacts.map((contact, index) => {
const isVerified = contact.bridgeKycStatus === 'approved'
const displayName = contact.showFullName
? contact.fullName || contact.username
@@ -136,11 +145,11 @@ export default function ContactsView() {
return (
- {/* infinite scroll loader - only active when not searching */}
- {!searchQuery && (
-
- {isFetchingNextPage && (
-
Loading more...
- )}
-
- )}
+ {/* infinite scroll loader */}
+
+ {isFetchingNextPage && (
+
Loading more...
+ )}
+
- ) : (
- // no search results
+ ) : hasNoSearchResults ? (
+ // no search results - keep search input visible
- )}
+ ) : null}
) : (
- // empty state - no contacts at all
+ // empty state - no contacts at all (initial load with no contacts)
{
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'
@@ -77,16 +74,12 @@ export const SendRouterView = () => {
}, [isFetchingContacts, recentContactsAvatarInitials])
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 {
@@ -137,7 +130,7 @@ export const SendRouterView = () => {
...method,
identifierIcon: (
-
+
),
}
diff --git a/src/components/Setup/Views/InstallPWA.tsx b/src/components/Setup/Views/InstallPWA.tsx
index c82699e4d..34243f177 100644
--- a/src/components/Setup/Views/InstallPWA.tsx
+++ b/src/components/Setup/Views/InstallPWA.tsx
@@ -1,4 +1,4 @@
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import { useToast } from '@/components/0_Bruddle/Toast'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { Icon } from '@/components/Global/Icons/Icon'
@@ -11,26 +11,22 @@ import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { captureException } from '@sentry/nextjs'
import { DeviceType } from '@/hooks/useGetDeviceType'
+import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState'
const StepTitle = ({ text }: { text: string }) => {text}
-// detect if the browser is brave
-const isBraveBrowser = () => {
- if (typeof window === 'undefined') return false
- // brave browser has a specific navigator.brave property
- return !!(navigator as Navigator & { brave?: { isBrave: () => Promise } }).brave
-}
-
const InstallPWA = ({
canInstall,
deferredPrompt,
deviceType,
screenId,
+ setShowBraveSuccessMessage,
}: {
canInstall?: boolean
deferredPrompt?: BeforeInstallPromptEvent | null
deviceType?: DeviceType
screenId?: ScreenId
+ setShowBraveSuccessMessage?: (show: boolean) => void
}) => {
const toast = useToast()
const { handleNext, isLoading: isSetupFlowLoading } = useSetupFlow()
@@ -39,7 +35,8 @@ const InstallPWA = ({
const [installCancelled, setInstallCancelled] = useState(false)
const [isInstallInProgress, setIsInstallInProgress] = useState(false)
const [isPWAInstalled, setIsPWAInstalled] = useState(false)
- const [isBrave, setIsBrave] = useState(false)
+ const { isBrave } = useBravePWAInstallState()
+
const { user } = useAuth()
const { push } = useRouter()
@@ -74,12 +71,7 @@ const InstallPWA = ({
useEffect(() => {
if (!!user) push('/home')
- }, [user])
-
- // detect brave browser on mount
- useEffect(() => {
- setIsBrave(isBraveBrowser())
- }, [])
+ }, [user, push])
useEffect(() => {
const handleAppInstalled = () => {
@@ -100,6 +92,23 @@ const InstallPWA = ({
}
}, [])
+ // notify parent when installation is complete on brave
+ useEffect(() => {
+ if (
+ isBrave &&
+ (isPWAInstalled || installComplete) &&
+ !window.matchMedia('(display-mode: standalone)').matches
+ ) {
+ setShowBraveSuccessMessage?.(true)
+ } else {
+ setShowBraveSuccessMessage?.(false)
+ }
+
+ return () => {
+ setShowBraveSuccessMessage?.(false)
+ }
+ }, [isBrave, isPWAInstalled, installComplete, setShowBraveSuccessMessage])
+
useEffect(() => {
if (screenId === 'pwa-install' && (deviceType === DeviceType.WEB || deviceType === DeviceType.IOS)) {
const timer = setTimeout(() => {
@@ -150,17 +159,7 @@ const InstallPWA = ({
// if on brave browser, show instructions instead of trying to auto-open
// because brave doesn't support auto-opening pwa from browser
if (isBrave) {
- return (
-
-
App installed successfully!
-
- Please open the Peanut app from your home screen to continue setup.
-
- {/*
handleNext()} className="mt-4 w-full" shadowSize="4" variant="purple">
- Continue
- */}
-
- )
+ return null
}
// for other browsers, try to open the pwa in a new tab
diff --git a/src/components/Setup/Views/JoinBeta.tsx b/src/components/Setup/Views/JoinBeta.tsx
index 52e8e7804..b68a1f625 100644
--- a/src/components/Setup/Views/JoinBeta.tsx
+++ b/src/components/Setup/Views/JoinBeta.tsx
@@ -1,10 +1,10 @@
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import BaseInput from '@/components/0_Bruddle/BaseInput'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useAppDispatch, useSetupStore } from '@/redux/hooks'
import { setupActions } from '@/redux/slices/setup-slice'
-import React, { useState } from 'react'
+import { useState } from 'react'
const JoinBeta = () => {
const { handleNext } = useSetupFlow()
diff --git a/src/components/Setup/Views/JoinWaitlist.tsx b/src/components/Setup/Views/JoinWaitlist.tsx
index b63a35204..5c3271284 100644
--- a/src/components/Setup/Views/JoinWaitlist.tsx
+++ b/src/components/Setup/Views/JoinWaitlist.tsx
@@ -1,10 +1,9 @@
'use client'
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import { useToast } from '@/components/0_Bruddle/Toast'
import ValidatedInput from '@/components/Global/ValidatedInput'
-import { useZeroDev } from '@/hooks/useZeroDev'
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { twMerge } from 'tailwind-merge'
import * as Sentry from '@sentry/nextjs'
import { useSetupFlow } from '@/hooks/useSetupFlow'
diff --git a/src/components/Setup/Views/Landing.tsx b/src/components/Setup/Views/Landing.tsx
index 7f154dbb3..d14aec6e2 100644
--- a/src/components/Setup/Views/Landing.tsx
+++ b/src/components/Setup/Views/Landing.tsx
@@ -1,11 +1,12 @@
'use client'
-import { Button, Card } from '@/components/0_Bruddle'
import { useToast } from '@/components/0_Bruddle/Toast'
import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useLogin } from '@/hooks/useLogin'
import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
+import { Button } from '@/components/0_Bruddle/Button'
+import { Card } from '@/components/0_Bruddle/Card'
const LandingStep = () => {
const { handleNext } = useSetupFlow()
diff --git a/src/components/Setup/Views/PasskeySetupHelpModal.tsx b/src/components/Setup/Views/PasskeySetupHelpModal.tsx
index 042738966..c201081a6 100644
--- a/src/components/Setup/Views/PasskeySetupHelpModal.tsx
+++ b/src/components/Setup/Views/PasskeySetupHelpModal.tsx
@@ -1,9 +1,9 @@
'use client'
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import ActionModal from '@/components/Global/ActionModal'
import InfoCard from '@/components/Global/InfoCard'
-import { PASSKEY_TROUBLESHOOTING_STEPS, PASSKEY_WARNINGS, WebAuthnErrorName } from '@/utils'
+import { PASSKEY_TROUBLESHOOTING_STEPS, PASSKEY_WARNINGS, WebAuthnErrorName } from '@/utils/webauthn.utils'
interface PasskeySetupHelpModalProps {
visible: boolean
diff --git a/src/components/Setup/Views/SetupPasskey.tsx b/src/components/Setup/Views/SetupPasskey.tsx
index d73f43374..f6994f14e 100644
--- a/src/components/Setup/Views/SetupPasskey.tsx
+++ b/src/components/Setup/Views/SetupPasskey.tsx
@@ -1,11 +1,13 @@
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import { useSetupStore } from '@/redux/hooks'
import { useZeroDev } from '@/hooks/useZeroDev'
import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useDeviceType } from '@/hooks/useGetDeviceType'
import { useEffect, useState } from 'react'
import Link from 'next/link'
-import { capturePasskeyDebugInfo, checkPasskeySupport, WebAuthnErrorName, withWebAuthnRetry } from '@/utils'
+import { capturePasskeyDebugInfo } from '@/utils/passkeyDebug'
+import { checkPasskeySupport } from '@/utils/passkeyPreflight'
+import { WebAuthnErrorName, withWebAuthnRetry } from '@/utils/webauthn.utils'
import { PasskeySetupHelpModal } from './PasskeySetupHelpModal'
import ErrorAlert from '@/components/Global/ErrorAlert'
import * as Sentry from '@sentry/nextjs'
diff --git a/src/components/Setup/Views/SignTestTransaction.tsx b/src/components/Setup/Views/SignTestTransaction.tsx
index 01d1cc5e4..2b98eab6c 100644
--- a/src/components/Setup/Views/SignTestTransaction.tsx
+++ b/src/components/Setup/Views/SignTestTransaction.tsx
@@ -1,4 +1,4 @@
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import { setupActions } from '@/redux/slices/setup-slice'
import { useAppDispatch } from '@/redux/hooks'
import { useZeroDev } from '@/hooks/useZeroDev'
@@ -7,18 +7,16 @@ import { useAuth } from '@/context/authContext'
import { AccountType } from '@/interfaces'
import { useState, useEffect } from 'react'
import { encodeFunctionData, erc20Abi, type Address, type Hex } from 'viem'
-import { PEANUT_WALLET_TOKEN, PEANUT_WALLET_CHAIN } from '@/constants'
-import { capturePasskeyDebugInfo } from '@/utils'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
+import { capturePasskeyDebugInfo } from '@/utils/passkeyDebug'
import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
-import { useRouter } from 'next/navigation'
import { twMerge } from 'tailwind-merge'
const SignTestTransaction = () => {
- const router = useRouter()
const dispatch = useAppDispatch()
const { address, handleSendUserOpEncoded } = useZeroDev()
- const { finalizeAccountSetup, isProcessing, error: setupError } = useAccountSetup()
+ const { finalizeAccountSetup, isProcessing, error: setupError, handleRedirect } = useAccountSetup()
const { user, isFetchingUser, fetchUser } = useAuth()
const [error, setError] = useState(null)
const [isSigning, setIsSigning] = useState(false)
@@ -57,10 +55,10 @@ const SignTestTransaction = () => {
useEffect(() => {
if (accountExists) {
- console.log('[SignTestTransaction] Account exists, redirecting to home')
- router.push('/home')
+ console.log('[SignTestTransaction] Account exists, redirecting to the app')
+ handleRedirect()
}
- }, [accountExists, router])
+ }, [accountExists])
const handleTestTransaction = async () => {
if (!address) {
@@ -129,13 +127,12 @@ const SignTestTransaction = () => {
}
// account setup complete - addAccount() already fetched and verified user data
- console.log('[SignTestTransaction] Account setup complete, redirecting to home')
- router.push('/home')
+ console.log('[SignTestTransaction] Account setup complete, redirecting to the app')
+
// keep loading state active until redirect completes
} else {
// if account already exists, just navigate home (login flow)
- console.log('[SignTestTransaction] Account exists, redirecting to home')
- router.push('/home')
+ console.log('[SignTestTransaction] Account exists, redirecting to the app')
// keep loading state active until redirect completes
}
} catch (e) {
diff --git a/src/components/Setup/Views/Signup.tsx b/src/components/Setup/Views/Signup.tsx
index 0efebf83d..d62fe631e 100644
--- a/src/components/Setup/Views/Signup.tsx
+++ b/src/components/Setup/Views/Signup.tsx
@@ -1,11 +1,11 @@
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import ErrorAlert from '@/components/Global/ErrorAlert'
import ValidatedInput from '@/components/Global/ValidatedInput'
-import { next_proxy_url } from '@/constants'
+import { next_proxy_url } from '@/constants/general.consts'
import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useAppDispatch, useSetupStore } from '@/redux/hooks'
import { setupActions } from '@/redux/slices/setup-slice'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import * as Sentry from '@sentry/nextjs'
import Link from 'next/link'
import { useState } from 'react'
diff --git a/src/components/Setup/Views/Welcome.tsx b/src/components/Setup/Views/Welcome.tsx
index ea375f5a6..9a69796bb 100644
--- a/src/components/Setup/Views/Welcome.tsx
+++ b/src/components/Setup/Views/Welcome.tsx
@@ -1,11 +1,12 @@
'use client'
-import { Button, Card } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
+import { Card } from '@/components/0_Bruddle/Card'
import { useToast } from '@/components/0_Bruddle/Toast'
import { useAuth } from '@/context/authContext'
import { useSetupFlow } from '@/hooks/useSetupFlow'
import { useZeroDev } from '@/hooks/useZeroDev'
-import { getRedirectUrl, sanitizeRedirectURL, clearRedirectUrl } from '@/utils'
+import { getRedirectUrl, sanitizeRedirectURL, clearRedirectUrl } from '@/utils/general.utils'
import * as Sentry from '@sentry/nextjs'
import { useRouter, useSearchParams } from 'next/navigation'
import { useEffect } from 'react'
diff --git a/src/components/Setup/components/SetupWrapper.tsx b/src/components/Setup/components/SetupWrapper.tsx
index f04d32b5d..4ce9ce47c 100644
--- a/src/components/Setup/components/SetupWrapper.tsx
+++ b/src/components/Setup/components/SetupWrapper.tsx
@@ -1,13 +1,14 @@
import starImage from '@/assets/icons/star.png'
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import CloudsBackground from '@/components/0_Bruddle/CloudsBackground'
import { Icon } from '@/components/Global/Icons/Icon'
import { type BeforeInstallPromptEvent, type LayoutType, type ScreenId } from '@/components/Setup/Setup.types'
import InstallPWA from '@/components/Setup/Views/InstallPWA'
+import { useBravePWAInstallState } from '@/hooks/useBravePWAInstallState'
import { DeviceType } from '@/hooks/useGetDeviceType'
import classNames from 'classnames'
import Image from 'next/image'
-import { Children, type ReactNode, cloneElement, memo, type ReactElement } from 'react'
+import { Children, type ReactNode, cloneElement, memo, type ReactElement, useState } from 'react'
import { twMerge } from 'tailwind-merge'
/**
@@ -208,6 +209,19 @@ export const SetupWrapper = memo(
deviceType,
titleClassName,
}: SetupWrapperProps) => {
+ const { isBrave } = useBravePWAInstallState()
+ const [showBraveSuccessMessage, setShowBraveSuccessMessage] = useState(false)
+
+ const shouldShowBraveInstalledHeaderOnly =
+ (screenId === 'pwa-install' || screenId === 'android-initial-pwa-install') &&
+ isBrave &&
+ showBraveSuccessMessage
+
+ const headingTitle = shouldShowBraveInstalledHeaderOnly ? 'Success!' : title
+ const headingDescription = shouldShowBraveInstalledHeaderOnly
+ ? 'Please open the Peanut app from your home screen to continue setup.'
+ : description
+
return (
{/* navigation buttons */}
@@ -248,17 +262,19 @@ export const SetupWrapper = memo(
(screenId === 'signup' || screenId == 'join-beta') && 'md:max-h-12'
)}
>
- {title && (
+ {headingTitle && (
- {title}
+ {headingTitle}
)}
- {description &&
{description}
}
+ {headingDescription && (
+
{headingDescription}
+ )}
{/* main content area */}
@@ -269,6 +285,7 @@ export const SetupWrapper = memo(
canInstall,
deviceType,
screenId,
+ setShowBraveSuccessMessage,
})
}
return child
diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx
index 12b3c65b4..de888e5c3 100644
--- a/src/components/TransactionDetails/TransactionCard.tsx
+++ b/src/components/TransactionDetails/TransactionCard.tsx
@@ -8,11 +8,10 @@ import {
formatNumberForDisplay,
formatCurrency,
printableAddress,
- getAvatarUrl,
- getTransactionSign,
isStableCoin,
shortenStringLong,
-} from '@/utils'
+} from '@/utils/general.utils'
+import { getAvatarUrl, getTransactionSign } from '@/utils/history.utils'
import React, { lazy, Suspense } from 'react'
import Image from 'next/image'
import StatusPill, { type StatusPillType } from '../Global/StatusPill'
@@ -58,6 +57,7 @@ interface TransactionCardProps {
transaction: TransactionDetails
isPending?: boolean
haveSentMoneyToUser?: boolean
+ hideTxnAmount?: boolean
}
/**
@@ -75,6 +75,7 @@ const TransactionCard: React.FC
= ({
transaction,
isPending = false,
haveSentMoneyToUser = false,
+ hideTxnAmount = false,
}) => {
// hook to manage the state of the details drawer (open/closed, selected transaction)
const { isDrawerOpen, selectedTransaction, openTransactionDetails, closeTransactionDetails } =
@@ -195,9 +196,17 @@ const TransactionCard: React.FC = ({
) : (
- {displayAmount}
- {currencyDisplayAmount && (
- {currencyDisplayAmount}
+ {hideTxnAmount ? (
+ ****
+ ) : (
+ <>
+ {displayAmount}
+ {currencyDisplayAmount && (
+
+ {currencyDisplayAmount}
+
+ )}
+ >
)}
diff --git a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx
index f32c19cd2..0bd667c89 100644
--- a/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx
+++ b/src/components/TransactionDetails/TransactionDetailsHeaderCard.tsx
@@ -3,7 +3,7 @@
import StatusBadge, { type StatusType } from '@/components/Global/Badges/StatusBadge'
import TransactionAvatarBadge from '@/components/TransactionDetails/TransactionAvatarBadge'
import { type TransactionType } from '@/components/TransactionDetails/TransactionCard'
-import { printableAddress } from '@/utils'
+import { printableAddress } from '@/utils/general.utils'
import Image from 'next/image'
import React from 'react'
import { isAddress as isWalletAddress } from 'viem'
@@ -280,7 +280,7 @@ export const TransactionDetailsHeaderCard: React.FC
diff --git a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
index 8e912bf35..dc8852918 100644
--- a/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
+++ b/src/components/TransactionDetails/TransactionDetailsReceipt.tsx
@@ -16,7 +16,8 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { useUserStore } from '@/redux/hooks'
import { chargesApi } from '@/services/charges'
import useClaimLink from '@/components/Claim/useClaimLink'
-import { formatAmount, formatDate, getInitialsFromName, isStableCoin, formatCurrency, getAvatarUrl } from '@/utils'
+import { formatAmount, formatDate, isStableCoin, formatCurrency } from '@/utils/general.utils'
+import { getAvatarUrl } from '@/utils/history.utils'
import {
formatIban,
getContributorsFromCharge,
@@ -31,7 +32,7 @@ import { useQueryClient } from '@tanstack/react-query'
import Link from 'next/link'
import Image from 'next/image'
import React, { useMemo, useState, useEffect } from 'react'
-import { Button } from '../0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import DisplayIcon from '../Global/DisplayIcon'
import { Icon } from '../Global/Icons/Icon'
import { PerkIcon } from './PerkIcon'
@@ -49,7 +50,7 @@ import {
type TransactionDetailsRowKey,
transactionDetailsRowKeys,
} from './transaction-details.utils'
-import { useSupportModalContext } from '@/context/SupportModalContext'
+import { useModalsContext } from '@/context/ModalsContext'
import { useRouter } from 'next/navigation'
import { countryData } from '@/components/AddMoney/consts'
import { useToast } from '@/components/0_Bruddle/Toast'
@@ -60,7 +61,7 @@ import {
} from '@/constants/manteca.consts'
import { mantecaApi } from '@/services/manteca'
import { getReceiptUrl } from '@/utils/history.utils'
-import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts'
import ContributorCard from '../Global/Contributors/ContributorCard'
import { requestsApi } from '@/services/requests'
import { PasskeyDocsLink } from '../Setup/Views/SignTestTransaction'
@@ -99,7 +100,7 @@ export const TransactionDetailsReceipt = ({
const [showCancelLinkModal, setShowCancelLinkModal] = useState(false)
const [tokenData, setTokenData] = useState<{ symbol: string; icon: string } | null>(null)
const [isTokenDataLoading, setIsTokenDataLoading] = useState(true)
- const { setIsSupportModalOpen } = useSupportModalContext()
+ const { setIsSupportModalOpen } = useModalsContext()
const toast = useToast()
const router = useRouter()
const [cancelLinkText, setCancelLinkText] = useState<'Cancelling' | 'Cancelled' | 'Cancel link'>('Cancel link')
@@ -160,25 +161,33 @@ export const TransactionDetailsReceipt = ({
)
),
txId: !!transaction.txHash,
- cancelled: !!(transaction.status === 'cancelled' && transaction.cancelledDate),
+ // show cancelled row if status is cancelled, use cancelledDate or fallback to createdAt
+ cancelled: transaction.status === 'cancelled',
claimed: !!(transaction.status === 'completed' && transaction.claimedAt),
completed: !!(
transaction.status === 'completed' &&
transaction.completedAt &&
transaction.extraDataForDrawer?.originalType !== EHistoryEntryType.DIRECT_SEND
),
- fee: transaction.fee !== undefined,
+ refunded: transaction.status === 'refunded',
+ fee: transaction.fee !== undefined && transaction.status !== 'cancelled',
exchangeRate: !!(
(transaction.direction === 'bank_deposit' ||
transaction.direction === 'qr_payment' ||
transaction.direction === 'bank_withdraw') &&
transaction.currency?.code &&
- transaction.currency.code.toUpperCase() !== 'USD'
+ transaction.currency.code.toUpperCase() !== 'USD' &&
+ transaction.status !== 'cancelled'
+ ),
+ bankAccountDetails: !!(
+ transaction.bankAccountDetails &&
+ transaction.bankAccountDetails.identifier &&
+ transaction.status !== 'cancelled'
),
- bankAccountDetails: !!(transaction.bankAccountDetails && transaction.bankAccountDetails.identifier),
transferId: !!(
transaction.id &&
- (transaction.direction === 'bank_withdraw' || transaction.direction === 'bank_claim')
+ (transaction.direction === 'bank_withdraw' || transaction.direction === 'bank_claim') &&
+ transaction.status !== 'cancelled'
),
depositInstructions: !!(
(transaction.extraDataForDrawer?.originalType === EHistoryEntryType.BRIDGE_ONRAMP ||
@@ -189,10 +198,14 @@ export const TransactionDetailsReceipt = ({
transaction.extraDataForDrawer.depositInstructions.bank_name
),
peanutFee: false, // Perk fee logic removed - perks now show as separate transactions
- points: !!(transaction.points && transaction.points > 0),
- comment: !!transaction.memo?.trim(),
- networkFee: !!(transaction.networkFeeDetails && transaction.sourceView === 'status'),
- attachment: !!transaction.attachmentUrl,
+ points: !!(transaction.points && transaction.points > 0 && transaction.status !== 'cancelled'),
+ comment: !!(transaction.memo?.trim() && transaction.status !== 'cancelled'),
+ networkFee: !!(
+ transaction.networkFeeDetails &&
+ transaction.sourceView === 'status' &&
+ transaction.status !== 'cancelled'
+ ),
+ attachment: !!(transaction.attachmentUrl && transaction.status !== 'cancelled'),
mantecaDepositInfo:
!isPublic &&
transaction.extraDataForDrawer?.originalType === EHistoryEntryType.MANTECA_ONRAMP &&
@@ -621,7 +634,9 @@ export const TransactionDetailsReceipt = ({
{rowVisibilityConfig.cancelled && (
)}
@@ -642,6 +657,14 @@ export const TransactionDetailsReceipt = ({
/>
)}
+ {rowVisibilityConfig.refunded && (
+
+ )}
+
{rowVisibilityConfig.closed && (
<>
{transaction.cancelledDate && (
@@ -849,12 +872,16 @@ export const TransactionDetailsReceipt = ({
value={
- {transaction.extraDataForDrawer.depositInstructions.deposit_message}
+ {transaction.extraDataForDrawer.depositInstructions.deposit_message.slice(
+ 0,
+ 10
+ )}
@@ -877,8 +904,32 @@ export const TransactionDetailsReceipt = ({
{/* Collapsible bank details */}
+
{showBankDetails && (
<>
+ {transaction.extraDataForDrawer.depositInstructions.account_holder_name && (
+
+
+ {
+ transaction.extraDataForDrawer.depositInstructions
+ .account_holder_name
+ }
+
+
+
+ }
+ hideBottomBorder={false}
+ />
+ )}
}
- hideBottomBorder={false}
+ hideBottomBorder={true}
/>
- {transaction.extraDataForDrawer.depositInstructions.account_holder_name && (
-
-
- {
- transaction.extraDataForDrawer.depositInstructions
- .account_holder_name
- }
-
-
-
- }
- hideBottomBorder={true}
- />
- )}
>
) : (
/* US format (Account Number/Routing Number) */
diff --git a/src/components/TransactionDetails/transaction-details.utils.ts b/src/components/TransactionDetails/transaction-details.utils.ts
index e1b93915b..8b4af2775 100644
--- a/src/components/TransactionDetails/transaction-details.utils.ts
+++ b/src/components/TransactionDetails/transaction-details.utils.ts
@@ -7,6 +7,7 @@ export type TransactionDetailsRowKey =
| 'txId'
| 'cancelled'
| 'completed'
+ | 'refunded'
| 'exchangeRate'
| 'bankAccountDetails'
| 'transferId'
@@ -26,6 +27,7 @@ export const transactionDetailsRowKeys: TransactionDetailsRowKey[] = [
'cancelled',
'claimed',
'completed',
+ 'refunded',
'closed',
'to',
'tokenAndNetwork',
diff --git a/src/components/TransactionDetails/transactionTransformer.ts b/src/components/TransactionDetails/transactionTransformer.ts
index 0a271bdc1..9bee557d2 100644
--- a/src/components/TransactionDetails/transactionTransformer.ts
+++ b/src/components/TransactionDetails/transactionTransformer.ts
@@ -12,7 +12,7 @@ import {
} from '@/utils/general.utils'
import { type StatusPillType } from '../Global/StatusPill'
import type { Address } from 'viem'
-import { PEANUT_WALLET_CHAIN } from '@/constants'
+import { PEANUT_WALLET_CHAIN } from '@/constants/zerodev.consts'
import { type HistoryEntryPerkReward, type ChargeEntry } from '@/services/services.types'
/**
@@ -436,6 +436,9 @@ export function mapTransactionDataForDrawer(entry: HistoryEntry): MappedTransact
case 'EXPIRED':
uiStatus = 'cancelled'
break
+ case 'REFUNDED':
+ uiStatus = 'refunded'
+ break
case 'CLOSED':
// If the total amount collected is 0, the link is treated as cancelled
uiStatus = entry.totalAmountCollected === 0 ? 'cancelled' : 'closed'
diff --git a/src/components/User/UserCard.tsx b/src/components/User/UserCard.tsx
index e0a96ecb8..a6d156356 100644
--- a/src/components/User/UserCard.tsx
+++ b/src/components/User/UserCard.tsx
@@ -9,9 +9,11 @@ import AvatarWithBadge, { type AvatarSize } from '../Profile/AvatarWithBadge'
import { VerifiedUserLabel } from '../UserHeader'
import { twMerge } from 'tailwind-merge'
import ProgressBar from '../Global/ProgressBar'
+import { ContributorsDrawer } from '@/features/payments/flows/contribute-pot/components/ContributorsDrawer'
+import type { PotContributor } from '@/features/payments/flows/contribute-pot/ContributePotFlowContext'
interface UserCardProps {
- type: 'send' | 'request' | 'received_link' | 'request_pay'
+ type: 'send' | 'request' | 'received_link' | 'request_pay' | 'request_fulfilment'
username: string
fullName?: string
recipientType?: RecipientType
@@ -23,6 +25,7 @@ interface UserCardProps {
amount?: number
amountCollected?: number
isRequestPot?: boolean
+ contributors?: PotContributor[]
}
const UserCard = ({
@@ -38,11 +41,13 @@ const UserCard = ({
amount,
amountCollected,
isRequestPot,
+ contributors,
}: UserCardProps) => {
const getIcon = (): IconName | undefined => {
if (type === 'send') return 'arrow-up-right'
if (type === 'request') return 'arrow-down-left'
if (type === 'received_link') return 'arrow-down-left'
+ if (type === 'request_fulfilment') return 'arrow-up-right'
}
const getTitle = useCallback(() => {
@@ -52,7 +57,7 @@ const UserCard = ({
if (type === 'request') title = `Requesting money from`
if (type === 'received_link') title = `You received`
if (type === 'request_pay') title = `${fullName ?? username} is requesting`
-
+ if (type === 'request_fulfilment') title = `Sending ${fullName ?? username}`
return (
{icon &&
} {title}
@@ -87,16 +92,30 @@ const UserCard = ({
/>
{getTitle()}
- {recipientType !== 'USERNAME' || type === 'request_pay' ? (
-
+ {type === 'request_fulfilment' && (
+
+
${amount}
+
+
+
Send the exact amount!
+
+
)}
- isLink={type !== 'request_pay'}
- />
+
+ {type !== 'request_fulfilment' && (
+
+ )}
+ >
) : (
0 && (
= amount} />
)}
+
+ {/* request pot contributors drawer */}
+ {isRequestPot && contributors && }
)
}
diff --git a/src/components/UserHeader/index.tsx b/src/components/UserHeader/index.tsx
index 10765347f..ce7e0d0a2 100644
--- a/src/components/UserHeader/index.tsx
+++ b/src/components/UserHeader/index.tsx
@@ -6,10 +6,10 @@ import { Icon } from '../Global/Icons/Icon'
import { twMerge } from 'tailwind-merge'
import { Tooltip } from '../Tooltip'
import { useMemo } from 'react'
-import { isAddress } from 'viem'
import { useAuth } from '@/context/authContext'
import AddressLink from '../Global/AddressLink'
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
+import { isCryptoAddress } from '@/utils/general.utils'
interface UserHeaderProps {
username: string
@@ -79,8 +79,8 @@ export const VerifiedUserLabel = ({
tooltipContent = "This is a verified user and you've sent them money before."
}
- const isCryptoAddress = useMemo(() => {
- return isAddress(username)
+ const isCryptoAddressComputed = useMemo(() => {
+ return isCryptoAddress(username)
}, [username])
// O(1) lookup in pre-computed Set
@@ -90,7 +90,7 @@ export const VerifiedUserLabel = ({
return (
- {isCryptoAddress ? (
+ {isCryptoAddressComputed ? (
+ *
+ *
+ * ```
+ */
+
+import { useState, useEffect, type ReactNode } from 'react'
+import { verifyPeanutUsername } from '@/lib/validation/recipient'
+import type { ValidationErrorViewProps } from '@/components/Payment/Views/Error.validation.view'
+import ValidationErrorView from '@/components/Payment/Views/Error.validation.view'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+
+interface ValidatedUsernameWrapperProps {
+ username: string
+ children: ReactNode
+ errorProps?: Partial
+ loadingClassName?: string
+}
+
+export function ValidatedUsernameWrapper({
+ username,
+ children,
+ errorProps,
+ loadingClassName = 'flex min-h-[calc(100dvh-180px)] w-full items-center justify-center',
+}: ValidatedUsernameWrapperProps) {
+ const [error, setError] = useState(null)
+ const [isValidating, setIsValidating] = useState(false)
+ const [isValidated, setIsValidated] = useState(false)
+
+ // validate username before showing children
+ useEffect(() => {
+ let isMounted = true
+
+ const validateUsername = async () => {
+ setIsValidating(true)
+ setError(null)
+
+ const isValid = await verifyPeanutUsername(username)
+
+ if (!isMounted) return
+
+ if (!isValid) {
+ setError({
+ title: `We don't know any @${username}`,
+ message: 'Are you sure you clicked on the right link?',
+ buttonText: 'Go back to home',
+ redirectTo: '/home',
+ showLearnMore: false,
+ supportMessageTemplate: 'I clicked on this link but got an error: {url}',
+ ...errorProps,
+ })
+ setIsValidated(false)
+ } else {
+ setIsValidated(true)
+ }
+
+ setIsValidating(false)
+ }
+
+ validateUsername()
+
+ return () => {
+ isMounted = false
+ }
+ }, [username, errorProps])
+
+ // show loading while validating
+ if (isValidating) {
+ return (
+
+ )
+ }
+
+ // show error if validation failed
+ if (error) {
+ return (
+
+
+
+ )
+ }
+
+ // show children only after successful validation
+ if (!isValidated) {
+ return (
+
+ )
+ }
+
+ return <>{children}>
+}
diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx
index d556a048a..7c507c3da 100644
--- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx
@@ -1,6 +1,6 @@
'use client'
-import { Button } from '@/components/0_Bruddle'
+import { Button } from '@/components/0_Bruddle/Button'
import AddressLink from '@/components/Global/AddressLink'
import Card from '@/components/Global/Card'
import DisplayIcon from '@/components/Global/DisplayIcon'
@@ -10,12 +10,12 @@ import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { useTokenChainIcons } from '@/hooks/useTokenChainIcons'
import { type ITokenPriceData } from '@/interfaces'
-import { formatAmount, isStableCoin } from '@/utils'
+import { formatAmount, isStableCoin } from '@/utils/general.utils'
import { interfaces } from '@squirrel-labs/peanut-sdk'
import { type PeanutCrossChainRoute } from '@/services/swap'
import { useMemo, useState } from 'react'
import { formatUnits } from 'viem'
-import { ROUTE_NOT_FOUND_ERROR } from '@/constants'
+import { ROUTE_NOT_FOUND_ERROR } from '@/constants/general.consts'
interface WithdrawConfirmViewProps {
amount: string
diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx
index a7f51f7a1..5ee4b5b56 100644
--- a/src/components/Withdraw/views/Initial.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx
@@ -5,7 +5,6 @@ import ErrorAlert from '@/components/Global/ErrorAlert'
import GeneralRecipientInput, { type GeneralRecipientUpdate } from '@/components/Global/GeneralRecipientInput'
import NavHeader from '@/components/Global/NavHeader'
import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard'
-import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants'
import { useWithdrawFlow } from '@/context/WithdrawFlowContext'
import { tokenSelectorContext } from '@/context/tokenSelector.context'
import { type ITokenPriceData } from '@/interfaces'
@@ -14,6 +13,7 @@ import { interfaces } from '@squirrel-labs/peanut-sdk'
import { useRouter } from 'next/navigation'
import { useContext, useEffect } from 'react'
import TokenSelector from '@/components/Global/TokenSelector/TokenSelector'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
interface InitialWithdrawViewProps {
amount: string
diff --git a/src/components/utils/utils.ts b/src/components/utils/utils.ts
index 034fd3f8b..0fc744b35 100644
--- a/src/components/utils/utils.ts
+++ b/src/components/utils/utils.ts
@@ -1,6 +1,6 @@
-import * as consts from '@/constants'
-import { fetchWithSentry, getExplorerUrl } from '@/utils'
-import * as Sentry from '@sentry/nextjs'
+import { SQUID_API_URL, SQUID_INTEGRATOR_ID } from '@/constants/general.consts'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { getExplorerUrl } from '@/utils/general.utils'
type ISquidChainData = {
id: string
@@ -70,8 +70,8 @@ type ISquidStatusResponse = {
export async function checkTransactionStatus(txHash: string): Promise {
try {
- const response = await fetchWithSentry(`${consts.SQUID_API_URL}/status?transactionId=${txHash}`, {
- headers: { 'x-integrator-id': consts.SQUID_INTEGRATOR_ID }, // TODO: request v2 removes checking squid status
+ const response = await fetchWithSentry(`${SQUID_API_URL}/status?transactionId=${txHash}`, {
+ headers: { 'x-integrator-id': SQUID_INTEGRATOR_ID }, // TODO: request v2 removes checking squid status
})
const data = await response.json()
return data
diff --git a/src/config/peanut.config.tsx b/src/config/peanut.config.tsx
index f663bd95a..79cef901b 100644
--- a/src/config/peanut.config.tsx
+++ b/src/config/peanut.config.tsx
@@ -9,20 +9,17 @@ import { Provider as ReduxProvider } from 'react-redux'
import store from '@/redux/store'
import 'react-tooltip/dist/react-tooltip.css'
-import '../../sentry.client.config'
-import '../../sentry.edge.config'
-import '../../sentry.server.config'
+// Note: Sentry configs are auto-loaded by @sentry/nextjs via next.config.js
+// DO NOT import them here - it bundles server/edge configs into client code
export function PeanutProvider({ children }: { children: React.ReactNode }) {
- if (process.env.NODE_ENV !== 'development') {
- useEffect(() => {
+ useEffect(() => {
+ if (process.env.NODE_ENV !== 'development') {
peanut.toggleVerbose(true)
// LogRocket.init('x2zwq1/peanut-protocol')
countries.registerLocale(enLocale)
- }, [])
- }
-
- console.log('NODE_ENV:', process.env.NODE_ENV)
+ }
+ }, [])
return (
diff --git a/src/config/theme.config.tsx b/src/config/theme.config.tsx
index 8e85b4984..1d6791fa7 100644
--- a/src/config/theme.config.tsx
+++ b/src/config/theme.config.tsx
@@ -1,14 +1,7 @@
'use client'
-import { theme } from '@/styles/theme'
-import { ColorModeProvider, ColorModeScript } from '@chakra-ui/color-mode'
-import { ChakraProvider } from '@chakra-ui/react'
+import { ThemeProvider as MuiThemeProvider, createTheme } from '@mui/material/styles'
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
- return (
-
-
- {children}
-
- )
+ return {children}
}
diff --git a/src/constants/countryCurrencyMapping.ts b/src/constants/countryCurrencyMapping.ts
index 3af5d0e37..26c926cca 100644
--- a/src/constants/countryCurrencyMapping.ts
+++ b/src/constants/countryCurrencyMapping.ts
@@ -7,7 +7,7 @@ export interface CountryCurrencyMapping {
path?: string
}
-export const countryCurrencyMappings: CountryCurrencyMapping[] = [
+const countryCurrencyMappings: CountryCurrencyMapping[] = [
// SEPA Countries (Eurozone)
{ currencyCode: 'EUR', currencyName: 'Euro', country: 'Eurozone', flagCode: 'eu' },
diff --git a/src/constants/index.ts b/src/constants/index.ts
deleted file mode 100644
index 64edf849b..000000000
--- a/src/constants/index.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-export * from './cashout.consts'
-export * from './chains.consts'
-export * from './carousel.consts'
-export * from './general.consts'
-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'
-export * from './tweets.consts'
diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts
index 7d62bd236..49b09ddff 100644
--- a/src/constants/payment.consts.ts
+++ b/src/constants/payment.consts.ts
@@ -10,12 +10,6 @@ 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
diff --git a/src/constants/rhino.consts.ts b/src/constants/rhino.consts.ts
new file mode 100644
index 000000000..a99899f88
--- /dev/null
+++ b/src/constants/rhino.consts.ts
@@ -0,0 +1,59 @@
+/** Chain name to logo URL mapping - reusable across the app */
+export const CHAIN_LOGOS = {
+ ARBITRUM: 'https://assets.coingecko.com/asset_platforms/images/33/standard/AO_logomark.png?1706606717',
+ ETHEREUM: 'https://assets.coingecko.com/asset_platforms/images/279/standard/ethereum.png?1706606803',
+ BASE: 'https://assets.coingecko.com/asset_platforms/images/131/standard/base.png?1759905869',
+ OPTIMISM: 'https://assets.coingecko.com/asset_platforms/images/41/standard/optimism.png?1706606778',
+ GNOSIS: 'https://assets.coingecko.com/asset_platforms/images/11062/standard/Aatar_green_white.png?1706606458',
+ POLYGON: 'https://assets.coingecko.com/asset_platforms/images/15/standard/polygon_pos.png?1706606645',
+ BNB: 'https://assets.coingecko.com/asset_platforms/images/1/standard/bnb_smart_chain.png?1706606721',
+ KATANA: 'https://assets.coingecko.com/asset_platforms/images/32239/standard/katana.jpg?1751496126',
+ SCROLL: 'https://assets.coingecko.com/asset_platforms/images/153/standard/scroll.jpeg?1706606782',
+ CELO: 'https://assets.coingecko.com/asset_platforms/images/21/standard/celo.jpeg?1711358666',
+ TRON: 'https://assets.coingecko.com/asset_platforms/images/1094/standard/TRON_LOGO.png?1706606652',
+ SOLANA: 'https://assets.coingecko.com/asset_platforms/images/5/standard/solana.png?1706606708',
+} as const
+
+/** Token symbol to logo URL mapping - reusable across the app */
+export const TOKEN_LOGOS = {
+ USDT: 'https://assets.coingecko.com/coins/images/325/standard/Tether.png?1696501661',
+ USDC: 'https://assets.coingecko.com/coins/images/6319/small/USD_Coin_icon.png',
+} as const
+
+export type ChainName = keyof typeof CHAIN_LOGOS
+export type TokenName = keyof typeof TOKEN_LOGOS
+
+export const SUPPORTED_EVM_CHAINS = [
+ 'ARBITRUM',
+ 'ETHEREUM',
+ 'BASE',
+ 'OPTIMISM',
+ 'BNB',
+ 'POLYGON',
+ 'KATANA',
+ 'SCROLL',
+ 'GNOSIS',
+ 'CELO',
+] as const
+
+export const OTHER_SUPPORTED_CHAINS = ['SOLANA', 'TRON'] as const
+
+/** Rhino-supported chains with their logos */
+export const RHINO_SUPPORTED_CHAINS = (Object.keys(CHAIN_LOGOS) as ChainName[]).map((name) => ({
+ name,
+ logoUrl: CHAIN_LOGOS[name],
+}))
+
+export const RHINO_SUPPORTED_EVM_CHAINS = RHINO_SUPPORTED_CHAINS.filter((chain) =>
+ (SUPPORTED_EVM_CHAINS as readonly string[]).includes(chain.name)
+)
+
+export const RHINO_SUPPORTED_OTHER_CHAINS = RHINO_SUPPORTED_CHAINS.filter((chain) =>
+ (OTHER_SUPPORTED_CHAINS as readonly string[]).includes(chain.name)
+)
+
+/** Rhino-supported tokens with their logos */
+export const RHINO_SUPPORTED_TOKENS = (Object.keys(TOKEN_LOGOS) as TokenName[]).map((name) => ({
+ name,
+ logoUrl: TOKEN_LOGOS[name],
+}))
diff --git a/src/constants/zerodev.consts.ts b/src/constants/zerodev.consts.ts
index ce9ce65a6..5e9c9b4c4 100644
--- a/src/constants/zerodev.consts.ts
+++ b/src/constants/zerodev.consts.ts
@@ -1,8 +1,5 @@
import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants'
-import type { Chain, PublicClient } from 'viem'
-import { createPublicClient } from 'viem'
-import { getTransportWithFallback } from '@/app/actions/clients'
-import { arbitrum, mainnet, base, linea } from 'viem/chains'
+import { arbitrum } from 'viem/chains'
// consts needed to define low level SDK kernel
// as per: https://docs.zerodev.app/sdk/getting-started/tutorial-passkeys
@@ -38,81 +35,3 @@ export const PEANUT_WALLET_SUPPORTED_TOKENS: Record = {
export const USER_OP_ENTRY_POINT = getEntryPoint('0.7')
export const ZERODEV_KERNEL_VERSION = KERNEL_V3_1
export const USER_OPERATION_REVERT_REASON_TOPIC = '0x1c4fada7374c0a9ee8841fc38afe82932dc0f8e69012e927f061a8bae611a201'
-
-const ZERODEV_V3_URL = process.env.NEXT_PUBLIC_ZERO_DEV_RECOVERY_BUNDLER_URL
-const zerodevV3Url = (chainId: number | string) => `${ZERODEV_V3_URL}/chain/${chainId}`
-
-/**
- * This is a mapping of chain ID to the public client and chain details
- * This is for the standard chains supported in the app. Arbitrum is always included
- * as it's the primary wallet chain. Additional chains (mainnet, base, linea) are only
- * included if NEXT_PUBLIC_ZERO_DEV_RECOVERY_BUNDLER_URL is configured.
- */
-export const PUBLIC_CLIENTS_BY_CHAIN: Record<
- string,
- {
- client: PublicClient
- chain: Chain
- bundlerUrl: string
- paymasterUrl: string
- }
-> = {
- // Arbitrum (primary wallet chain - always included)
- [arbitrum.id]: {
- client: createPublicClient({
- transport: getTransportWithFallback(arbitrum.id),
- chain: arbitrum,
- pollingInterval: 500,
- }),
- chain: PEANUT_WALLET_CHAIN,
- bundlerUrl: BUNDLER_URL,
- paymasterUrl: PAYMASTER_URL,
- },
-}
-
-// Conditionally add recovery chains if env var is configured
-if (ZERODEV_V3_URL) {
- const mainnetUrl = zerodevV3Url(mainnet.id)
- if (mainnetUrl) {
- PUBLIC_CLIENTS_BY_CHAIN[mainnet.id] = {
- client: createPublicClient({
- transport: getTransportWithFallback(mainnet.id),
- chain: mainnet,
- pollingInterval: 12000,
- }),
- chain: mainnet,
- bundlerUrl: mainnetUrl,
- paymasterUrl: mainnetUrl,
- }
- }
-
- const baseUrl = zerodevV3Url(base.id)
- if (baseUrl) {
- PUBLIC_CLIENTS_BY_CHAIN[base.id] = {
- client: createPublicClient({
- transport: getTransportWithFallback(base.id),
- chain: base,
- pollingInterval: 2000,
- }) as PublicClient,
- chain: base,
- bundlerUrl: baseUrl,
- paymasterUrl: baseUrl,
- }
- }
-
- const lineaUrl = zerodevV3Url(linea.id)
- if (lineaUrl) {
- PUBLIC_CLIENTS_BY_CHAIN[linea.id] = {
- client: createPublicClient({
- transport: getTransportWithFallback(linea.id),
- chain: linea,
- pollingInterval: 3000,
- }),
- chain: linea,
- bundlerUrl: lineaUrl,
- paymasterUrl: lineaUrl,
- }
- }
-}
-
-export const peanutPublicClient = PUBLIC_CLIENTS_BY_CHAIN[PEANUT_WALLET_CHAIN.id].client
diff --git a/src/context/LinkSendFlowContext.tsx b/src/context/LinkSendFlowContext.tsx
new file mode 100644
index 000000000..2887b1095
--- /dev/null
+++ b/src/context/LinkSendFlowContext.tsx
@@ -0,0 +1,111 @@
+'use client'
+
+/**
+ * context for send link flow state management
+ *
+ * send links let users create claimable payment links that can be:
+ * - shared via any messaging app
+ * - claimed by anyone with the link
+ * - cross-chain claimable
+ *
+ * this context manages state for creating these links
+ */
+
+import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
+import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
+
+// view states for link send flow
+export type LinkSendFlowView = 'INITIAL' | 'SUCCESS'
+
+// attachment options for link (matches IAttachmentOptions from shared types)
+export interface LinkSendAttachmentOptions {
+ fileUrl: string | undefined
+ message: string | undefined
+ rawFile: File | undefined
+}
+
+// error state
+export interface LinkSendErrorState {
+ showError: boolean
+ errorMessage: string
+}
+
+// context type
+interface LinkSendFlowContextType {
+ // state
+ view: LinkSendFlowView
+ setView: (view: LinkSendFlowView) => void
+ tokenValue: string | undefined
+ setTokenValue: (value: string | undefined) => void
+ link: string | undefined
+ setLink: (link: string | undefined) => void
+ attachmentOptions: LinkSendAttachmentOptions
+ setAttachmentOptions: (options: LinkSendAttachmentOptions) => void
+ errorState: LinkSendErrorState | undefined
+ setErrorState: (state: LinkSendErrorState | undefined) => void
+ crossChainDetails: peanutInterfaces.ISquidChain[] | undefined
+ setCrossChainDetails: (details: peanutInterfaces.ISquidChain[] | undefined) => void
+
+ // actions
+ resetLinkSendFlow: () => void
+}
+
+const LinkSendFlowContext = createContext(undefined)
+
+interface LinkSendFlowProviderProps {
+ children: ReactNode
+}
+
+export const LinkSendFlowProvider: React.FC = ({ children }) => {
+ const [view, setView] = useState('INITIAL')
+ const [tokenValue, setTokenValue] = useState(undefined)
+ const [link, setLink] = useState(undefined)
+ const [attachmentOptions, setAttachmentOptions] = useState({
+ fileUrl: undefined,
+ message: undefined,
+ rawFile: undefined,
+ })
+ const [errorState, setErrorState] = useState(undefined)
+ const [crossChainDetails, setCrossChainDetails] = useState(undefined)
+
+ const resetLinkSendFlow = useCallback(() => {
+ setView('INITIAL')
+ setTokenValue(undefined)
+ setLink(undefined)
+ setAttachmentOptions({
+ fileUrl: undefined,
+ message: undefined,
+ rawFile: undefined,
+ })
+ setErrorState(undefined)
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ view,
+ setView,
+ tokenValue,
+ setTokenValue,
+ link,
+ setLink,
+ attachmentOptions,
+ setAttachmentOptions,
+ errorState,
+ setErrorState,
+ crossChainDetails,
+ setCrossChainDetails,
+ resetLinkSendFlow,
+ }),
+ [view, tokenValue, link, attachmentOptions, errorState, crossChainDetails, resetLinkSendFlow]
+ )
+
+ return {children}
+}
+
+export const useLinkSendFlow = (): LinkSendFlowContextType => {
+ const context = useContext(LinkSendFlowContext)
+ if (context === undefined) {
+ throw new Error('useLinkSendFlow must be used within LinkSendFlowProvider')
+ }
+ return context
+}
diff --git a/src/context/ModalsContext.tsx b/src/context/ModalsContext.tsx
index 8658d6f63..98c237028 100644
--- a/src/context/ModalsContext.tsx
+++ b/src/context/ModalsContext.tsx
@@ -1,27 +1,81 @@
'use client'
-import { createContext, useContext, useState, type ReactNode } from 'react'
+import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react'
interface ModalsContextType {
+ // iOS PWA Install Modal
isIosPwaInstallModalOpen: boolean
setIsIosPwaInstallModalOpen: (isOpen: boolean) => void
+
+ // Guest Login/Sign In Modal
+ isSignInModalOpen: boolean
+ setIsSignInModalOpen: (isOpen: boolean) => void
+
+ // Support Drawer
+ isSupportModalOpen: boolean
+ setIsSupportModalOpen: (isOpen: boolean) => void
+ supportPrefilledMessage: string
+ setSupportPrefilledMessage: (message: string) => void
+ openSupportWithMessage: (message: string) => void
+
+ // QR Scanner
+ isQRScannerOpen: boolean
+ setIsQRScannerOpen: (isOpen: boolean) => void
}
const ModalsContext = createContext(undefined)
export function ModalsProvider({ children }: { children: ReactNode }) {
+ // iOS PWA Install Modal
const [isIosPwaInstallModalOpen, setIsIosPwaInstallModalOpen] = useState(false)
- return (
-
- {children}
-
+ // Guest Login/Sign In Modal
+ const [isSignInModalOpen, setIsSignInModalOpen] = useState(false)
+
+ // Support Drawer
+ const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
+ const [supportPrefilledMessage, setSupportPrefilledMessage] = useState('')
+
+ // QR Scanner
+ const [isQRScannerOpen, setIsQRScannerOpen] = useState(false)
+
+ const openSupportWithMessage = useCallback((message: string) => {
+ setSupportPrefilledMessage(message)
+ setIsSupportModalOpen(true)
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ // iOS PWA Install Modal
+ isIosPwaInstallModalOpen,
+ setIsIosPwaInstallModalOpen,
+
+ // Guest Login/Sign In Modal
+ isSignInModalOpen,
+ setIsSignInModalOpen,
+
+ // Support Drawer
+ isSupportModalOpen,
+ setIsSupportModalOpen,
+ supportPrefilledMessage,
+ setSupportPrefilledMessage,
+ openSupportWithMessage,
+
+ // QR Scanner
+ isQRScannerOpen,
+ setIsQRScannerOpen,
+ }),
+ [
+ isIosPwaInstallModalOpen,
+ isSignInModalOpen,
+ isSupportModalOpen,
+ supportPrefilledMessage,
+ openSupportWithMessage,
+ isQRScannerOpen,
+ ]
)
+
+ return {children}
}
export function useModalsContext() {
diff --git a/src/context/QrCodeContext.tsx b/src/context/QrCodeContext.tsx
deleted file mode 100644
index 53c740f71..000000000
--- a/src/context/QrCodeContext.tsx
+++ /dev/null
@@ -1,23 +0,0 @@
-'use client'
-
-import { createContext, useContext, useState, type ReactNode } from 'react'
-
-interface QrCodeContextType {
- isQRScannerOpen: boolean
- setIsQRScannerOpen: (isOpen: boolean) => void
-}
-
-const QrCodeContext = createContext(undefined)
-
-export function QrCodeProvider({ children }: { children: ReactNode }) {
- const [isQRScannerOpen, setIsQRScannerOpen] = useState(false)
- return {children}
-}
-
-export function useQrCodeContext() {
- const context = useContext(QrCodeContext)
- if (context === undefined) {
- throw new Error('useQrCodeContext must be used within a QrCodeProvider')
- }
- return context
-}
diff --git a/src/context/SupportModalContext.tsx b/src/context/SupportModalContext.tsx
deleted file mode 100644
index 1ef2ca6e3..000000000
--- a/src/context/SupportModalContext.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-'use client'
-
-import { createContext, useContext, useState, type ReactNode } from 'react'
-
-interface SupportModalContextType {
- isSupportModalOpen: boolean
- setIsSupportModalOpen: (isOpen: boolean) => void
- prefilledMessage: string
- setPrefilledMessage: (message: string) => void
- openSupportWithMessage: (message: string) => void
-}
-
-const SupportModalContext = createContext(undefined)
-
-export function SupportModalProvider({ children }: { children: ReactNode }) {
- const [isSupportModalOpen, setIsSupportModalOpen] = useState(false)
- const [prefilledMessage, setPrefilledMessage] = useState('')
-
- const openSupportWithMessage = (message: string) => {
- setPrefilledMessage(message)
- setIsSupportModalOpen(true)
- }
-
- return (
-
- {children}
-
- )
-}
-
-export function useSupportModalContext() {
- const context = useContext(SupportModalContext)
- if (context === undefined) {
- throw new Error('useSupportModal must be used within a SupportModalProvider')
- }
- return context
-}
diff --git a/src/context/WithdrawFlowContext.tsx b/src/context/WithdrawFlowContext.tsx
index 7898bf0d0..aa594e65b 100644
--- a/src/context/WithdrawFlowContext.tsx
+++ b/src/context/WithdrawFlowContext.tsx
@@ -1,6 +1,7 @@
'use client'
import { type ITokenPriceData, type Account } from '@/interfaces'
+import { type TRequestChargeResponse, type PaymentCreationResponse } from '@/services/services.types'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import React, { createContext, type ReactNode, useContext, useMemo, useState, useCallback } from 'react'
@@ -61,6 +62,13 @@ interface WithdrawFlowContextType {
setShowAllWithdrawMethods: (show: boolean) => void
selectedMethod: WithdrawMethod | null
setSelectedMethod: (method: WithdrawMethod | null) => void
+ // charge and payment state (local to withdraw flow)
+ chargeDetails: TRequestChargeResponse | null
+ setChargeDetails: (charge: TRequestChargeResponse | null) => void
+ transactionHash: string | null
+ setTransactionHash: (hash: string | null) => void
+ paymentDetails: PaymentCreationResponse | null
+ setPaymentDetails: (payment: PaymentCreationResponse | null) => void
resetWithdrawFlow: () => void
}
@@ -86,6 +94,11 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
const [showAllWithdrawMethods, setShowAllWithdrawMethods] = useState(false)
const [selectedMethod, setSelectedMethod] = useState(null)
+ // charge and payment state (local to withdraw flow)
+ const [chargeDetails, setChargeDetails] = useState(null)
+ const [transactionHash, setTransactionHash] = useState(null)
+ const [paymentDetails, setPaymentDetails] = useState(null)
+
const resetWithdrawFlow = useCallback(() => {
setAmountToWithdraw('')
setCurrentView('INITIAL')
@@ -97,6 +110,10 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
setShowAllWithdrawMethods(false)
setUsdAmount('')
setSelectedMethod(null)
+ // reset charge and payment state
+ setChargeDetails(null)
+ setTransactionHash(null)
+ setPaymentDetails(null)
}, [])
const value = useMemo(
@@ -129,6 +146,12 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
setShowAllWithdrawMethods,
selectedMethod,
setSelectedMethod,
+ chargeDetails,
+ setChargeDetails,
+ transactionHash,
+ setTransactionHash,
+ paymentDetails,
+ setPaymentDetails,
resetWithdrawFlow,
}),
[
@@ -146,7 +169,9 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
selectedBankAccount,
showAllWithdrawMethods,
selectedMethod,
- setShowAllWithdrawMethods,
+ chargeDetails,
+ transactionHash,
+ paymentDetails,
resetWithdrawFlow,
]
)
diff --git a/src/context/authContext.tsx b/src/context/authContext.tsx
index 52aa33bd1..43139b0fc 100644
--- a/src/context/authContext.tsx
+++ b/src/context/authContext.tsx
@@ -6,12 +6,12 @@ import { useAppDispatch } from '@/redux/hooks'
import { setupActions } from '@/redux/slices/setup-slice'
import { zerodevActions } from '@/redux/slices/zerodev-slice'
import {
- fetchWithSentry,
removeFromCookie,
syncLocalStorageToCookie,
clearRedirectUrl,
updateUserPreferences,
-} from '@/utils'
+} from '@/utils/general.utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import { resetCrispProxySessions } from '@/utils/crisp'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
diff --git a/src/context/contextProvider.tsx b/src/context/contextProvider.tsx
index 2b142af28..cdd9f4f49 100644
--- a/src/context/contextProvider.tsx
+++ b/src/context/contextProvider.tsx
@@ -3,43 +3,37 @@ import { OnrampFlowContextProvider } from './OnrampFlowContext'
import { AuthProvider } from './authContext'
import { KernelClientProvider } from './kernelClient.context'
import { LoadingStateContextProvider } from './loadingStates.context'
-import { PushProvider } from './pushProvider'
import { TokenContextProvider } from './tokenSelector.context'
import { WithdrawFlowContextProvider } from './WithdrawFlowContext'
import { ClaimBankFlowContextProvider } from './ClaimBankFlowContext'
import { RequestFulfilmentFlowContextProvider } from './RequestFulfillmentFlowContext'
-import { SupportModalProvider } from './SupportModalContext'
import { PasskeySupportProvider } from './passkeySupportContext'
-import { QrCodeProvider } from './QrCodeContext'
import { ModalsProvider } from './ModalsContext'
+// note: push notifications are now handled by onesignal via useNotifications hook.
+// the legacy PushProvider (web-push based) has been removed.
+
export const ContextProvider = ({ children }: { children: React.ReactNode }) => {
return (
-
-
-
-
-
-
-
-
-
-
-
- {children}
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
+
+
)
diff --git a/src/context/kernelClient.context.tsx b/src/context/kernelClient.context.tsx
index 4524727b8..66f8f145c 100644
--- a/src/context/kernelClient.context.tsx
+++ b/src/context/kernelClient.context.tsx
@@ -1,15 +1,10 @@
'use client'
-import {
- PEANUT_WALLET_CHAIN,
- PUBLIC_CLIENTS_BY_CHAIN,
- USER_OP_ENTRY_POINT,
- ZERODEV_KERNEL_VERSION,
-} from '@/constants/zerodev.consts'
+import { PEANUT_WALLET_CHAIN, USER_OP_ENTRY_POINT, ZERODEV_KERNEL_VERSION } from '@/constants/zerodev.consts'
import { useAuth } from '@/context/authContext'
import { createKernelMigrationAccount } from '@zerodev/sdk/accounts'
import { useAppDispatch } from '@/redux/hooks'
import { zerodevActions } from '@/redux/slices/zerodev-slice'
-import { getFromCookie, updateUserPreferences, getUserPreferences } from '@/utils'
+import { getFromCookie, updateUserPreferences, getUserPreferences } from '@/utils/general.utils'
import { PasskeyValidatorContractVersion, toPasskeyValidator, toWebAuthnKey } from '@zerodev/passkey-validator'
import {
createKernelAccount,
@@ -21,6 +16,7 @@ import { createContext, type ReactNode, useCallback, useContext, useEffect, useS
import { type Chain, http, type PublicClient, type Transport } from 'viem'
import type { Address } from 'viem'
import { captureException } from '@sentry/nextjs'
+import { PUBLIC_CLIENTS_BY_CHAIN } from '@/app/actions/clients'
interface KernelClientContextType {
setWebAuthnKey: (webAuthnKey: WebAuthnKey) => void
diff --git a/src/context/loadingStates.context.tsx b/src/context/loadingStates.context.tsx
index cf683e7db..6329de93f 100644
--- a/src/context/loadingStates.context.tsx
+++ b/src/context/loadingStates.context.tsx
@@ -1,10 +1,10 @@
'use client'
-import React, { createContext, useContext, useMemo, useState } from 'react'
+import React, { createContext, useMemo, useState } from 'react'
-import * as consts from '@/constants'
+import type { LoadingStates } from '@/constants/loadingStates.consts'
export const loadingStateContext = createContext({
- loadingState: '' as consts.LoadingStates,
- setLoadingState: (state: consts.LoadingStates) => {},
+ loadingState: '' as LoadingStates,
+ setLoadingState: (state: LoadingStates) => {},
isLoading: false as boolean,
})
@@ -14,7 +14,7 @@ export const loadingStateContext = createContext({
* Used for all loading states; e.g., fetching data, processing transactions, switching chains...
*/
export const LoadingStateContextProvider = ({ children }: { children: React.ReactNode }) => {
- const [loadingState, setLoadingState] = useState('Idle')
+ const [loadingState, setLoadingState] = useState('Idle')
const isLoading = useMemo(() => loadingState !== 'Idle', [loadingState])
return (
diff --git a/src/context/pushProvider.tsx b/src/context/pushProvider.tsx
deleted file mode 100644
index 61663e41f..000000000
--- a/src/context/pushProvider.tsx
+++ /dev/null
@@ -1,163 +0,0 @@
-'use client'
-
-import { sendNotification, subscribeUser } from '@/app/actions'
-import { useToast } from '@/components/0_Bruddle/Toast'
-import { urlBase64ToUint8Array } from '@/utils'
-import { captureException } from '@sentry/nextjs'
-import React, { createContext, useContext, useEffect, useState } from 'react'
-import webpush from 'web-push'
-import { useAuth } from './authContext'
-
-interface PushContextType {
- subscribe: () => Promise
- unsubscribe: () => void
- isSupported: boolean
- isSubscribing: boolean
- isSubscribed: boolean
- send: ({ message, title }: { message: string; title: string }) => void
-}
-
-const PushContext = createContext(undefined)
-
-export function PushProvider({ children }: { children: React.ReactNode }) {
- const toast = useToast()
- const { userId } = useAuth()
- const [isSupported, setIsSupported] = useState(false)
- const [isSubscribed, setIsSubscribed] = useState(false)
- const [isSubscribing, setIsSubscribing] = useState(false)
- const [registration, setRegistration] = useState(null)
- const [subscription, setSubscription] = useState(null)
-
- const registerServiceWorker = async () => {
- console.log('[PushProvider] Getting service worker registration')
- try {
- // 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('[PushProvider] Push subscription:', sub)
-
- if (sub) {
- // @ts-ignore
- setSubscription(sub)
- setIsSubscribed(true)
- }
- } catch (error) {
- console.error('[PushProvider] Failed to get SW registration:', error)
- captureException(error)
- // toast.error('Failed to initialize notifications')
- }
- }
-
- useEffect(() => {
- console.log('Checking for service worker and push manager')
- if ('serviceWorker' in navigator && 'PushManager' in window) {
- console.log('Service Worker and Push Manager are supported')
- setIsSupported(true)
- // Wait for service worker to be ready
- navigator.serviceWorker.ready
- .then(() => {
- registerServiceWorker()
- })
- .catch((error) => {
- console.error('Service Worker not ready:', error)
- captureException(error)
- // toast.error('Failed to initialize notifications')
- })
- } else {
- console.log('Service Worker and Push Manager are not supported')
- setIsSupported(false)
- }
- }, [])
-
- const subscribe = async (): Promise => {
- if (!registration) {
- toast.error('Something went wrong while initializing notifications')
- return
- } else if (!userId) {
- toast.error('Something went wrong while initializing notifications')
- return
- }
-
- setIsSubscribing(true)
- try {
- const sub = await registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: urlBase64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!),
- })
-
- // @ts-ignore
- setSubscription(sub)
-
- const plainSub = {
- endpoint: sub.endpoint,
- keys: {
- p256dh: Buffer.from((sub as any).getKey('p256dh')).toString('base64'),
- auth: Buffer.from((sub as any).getKey('auth')).toString('base64'),
- },
- }
-
- await subscribeUser(userId, plainSub)
-
- setIsSubscribed(true)
- } catch (error: unknown) {
- if (error instanceof Error) {
- console.log(error.message)
- if (error.message.includes('permission denied')) {
- toast.error('Please allow notifications in your browser settings')
- } else {
- toast.error('Failed to enable notifications')
- captureException(error)
- }
- console.error('Error subscribing to push notifications:', error.message)
- } else {
- throw error
- }
- }
- setIsSubscribing(false)
- }
- const unsubscribe = async () => {}
-
- const send = async ({ message, title }: { message: string; title: string }) => {
- const plainSub = {
- endpoint: subscription!.endpoint,
- keys: {
- p256dh: Buffer.from((subscription as any).getKey('p256dh')).toString('base64'),
- auth: Buffer.from((subscription as any).getKey('auth')).toString('base64'),
- },
- }
-
- await sendNotification(plainSub, { message, title })
- }
-
- return (
-
- {children}
-
- )
-}
-
-export function usePush() {
- const context = useContext(PushContext)
- if (context === undefined) {
- throw new Error('usePush must be used within a PushProvider')
- }
- return context
-}
diff --git a/src/context/tokenSelector.context.tsx b/src/context/tokenSelector.context.tsx
index 4761676db..c0425cc8f 100644
--- a/src/context/tokenSelector.context.tsx
+++ b/src/context/tokenSelector.context.tsx
@@ -1,5 +1,5 @@
'use client'
-import React, { createContext, useState, useCallback, useMemo, useEffect } from 'react'
+import React, { createContext, useState, useCallback, useEffect } from 'react'
import {
PEANUT_WALLET_CHAIN,
@@ -8,7 +8,7 @@ import {
PEANUT_WALLET_TOKEN_IMG_URL,
PEANUT_WALLET_TOKEN_NAME,
PEANUT_WALLET_TOKEN_SYMBOL,
-} from '@/constants'
+} from '@/constants/zerodev.consts'
import { useWallet } from '@/hooks/wallet/useWallet'
import { useSquidChainsAndTokens } from '@/hooks/useSquidChainsAndTokens'
import { useTokenPrice } from '@/hooks/useTokenPrice'
diff --git a/src/features/payments/flows/contribute-pot/ContributePotFlowContext.tsx b/src/features/payments/flows/contribute-pot/ContributePotFlowContext.tsx
new file mode 100644
index 000000000..8f99a6c1c
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/ContributePotFlowContext.tsx
@@ -0,0 +1,249 @@
+'use client'
+
+/**
+ * context provider for request pot flow
+ *
+ * request pots are group payment requests where multiple people can contribute.
+ * this context manages all state for the contribution flow:
+ * - amount being contributed
+ * - request details and recipient info
+ * - charge/payment results after execution
+ * - derived data like total collected and contributors list
+ *
+ * wraps ContributePotPage and provides state to all child views
+ */
+
+import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from 'react'
+import { type Address, type Hash } from 'viem'
+import { type TRequestResponse, type ChargeEntry, type PaymentCreationResponse } from '@/services/services.types'
+
+// view states for contribute pot flow
+export type ContributePotFlowView = 'INITIAL' | 'STATUS' | 'EXTERNAL_WALLET'
+
+// recipient info derived from request
+export interface PotRecipient {
+ username: string
+ address: Address
+ userId?: string
+ fullName?: string
+}
+
+// contributor info from charges
+export interface PotContributor {
+ uuid: string
+ username?: string
+ address?: string
+ amount: string
+ createdAt: string
+}
+
+// attachment state
+interface ContributePotAttachment {
+ message?: string
+ file?: File
+ fileUrl?: string
+}
+
+// error state
+interface ContributePotError {
+ showError: boolean
+ errorMessage: string
+}
+
+// context value type
+interface ContributePotFlowContextValue {
+ // view state
+ currentView: ContributePotFlowView
+ setCurrentView: (view: ContributePotFlowView) => void
+
+ // request data (fetched from api)
+ request: TRequestResponse | null
+ setRequest: (request: TRequestResponse | null) => void
+
+ // derived recipient
+ recipient: PotRecipient | null
+
+ // amount state
+ amount: string
+ setAmount: (amount: string) => void
+ usdAmount: string
+ setUsdAmount: (amount: string) => void
+
+ // attachment state
+ attachment: ContributePotAttachment
+ setAttachment: (attachment: ContributePotAttachment) => void
+
+ // charge and payment results
+ charge: ChargeEntry | null
+ setCharge: (charge: ChargeEntry | null) => void
+ payment: PaymentCreationResponse | null
+ setPayment: (payment: PaymentCreationResponse | null) => void
+ txHash: Hash | null
+ setTxHash: (hash: Hash | null) => void
+ isExternalWalletPayment: boolean
+ setIsExternalWalletPayment: (isExternalWalletPayment: boolean) => void
+
+ // ui state
+ error: ContributePotError
+ setError: (error: ContributePotError) => void
+ isLoading: boolean
+ setIsLoading: (loading: boolean) => void
+ isSuccess: boolean
+ setIsSuccess: (success: boolean) => void
+
+ // derived data
+ totalAmount: number
+ totalCollected: number
+ contributors: PotContributor[]
+
+ // actions
+ resetContributePotFlow: () => void
+}
+
+const ContributePotFlowContext = createContext(null)
+
+interface ContributePotFlowProviderProps {
+ children: ReactNode
+ initialRequest: TRequestResponse
+}
+
+export function ContributePotFlowProvider({ children, initialRequest }: ContributePotFlowProviderProps) {
+ // view state
+ const [currentView, setCurrentView] = useState('INITIAL')
+
+ // request data
+ const [request, setRequest] = useState(initialRequest)
+
+ // amount state
+ const [amount, setAmount] = useState('')
+ const [usdAmount, setUsdAmount] = useState('')
+
+ // attachment state
+ const [attachment, setAttachment] = useState({})
+
+ // charge and payment results
+ const [charge, setCharge] = useState(null)
+ const [payment, setPayment] = useState(null)
+ const [txHash, setTxHash] = useState(null)
+ const [isExternalWalletPayment, setIsExternalWalletPayment] = useState(false)
+
+ // ui state
+ const [error, setError] = useState({ showError: false, errorMessage: '' })
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSuccess, setIsSuccess] = useState(false)
+
+ // derive recipient from request
+ const recipient = useMemo(() => {
+ if (!request?.recipientAccount) return null
+ return {
+ username: request.recipientAccount.user?.username || request.recipientAccount.identifier,
+ address: request.recipientAddress as Address,
+ userId: request.recipientAccount.userId,
+ }
+ }, [request])
+
+ // derive total amount and collected
+ const totalAmount = useMemo(() => {
+ return request?.tokenAmount ? parseFloat(request.tokenAmount) : 0
+ }, [request?.tokenAmount])
+
+ const totalCollected = useMemo(() => {
+ return request?.totalCollectedAmount ?? 0
+ }, [request?.totalCollectedAmount])
+
+ // derive contributors from charges
+ const contributors = useMemo(() => {
+ if (!request?.charges) return []
+ return request.charges
+ .filter((c) => c.fulfillmentPayment?.status === 'SUCCESSFUL')
+ .map((c) => ({
+ uuid: c.uuid,
+ username: c.fulfillmentPayment?.payerAccount?.user?.username,
+ address: c.fulfillmentPayment?.payerAddress ?? undefined,
+ amount: c.tokenAmount,
+ createdAt: c.createdAt,
+ }))
+ }, [request?.charges])
+
+ // reset flow
+ const resetContributePotFlow = useCallback(() => {
+ setCurrentView('INITIAL')
+ setAmount('')
+ setUsdAmount('')
+ setAttachment({})
+ setCharge(null)
+ setPayment(null)
+ setTxHash(null)
+ setError({ showError: false, errorMessage: '' })
+ setIsLoading(false)
+ setIsSuccess(false)
+ setIsExternalWalletPayment(false)
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ currentView,
+ setCurrentView,
+ request,
+ setRequest,
+ recipient,
+ amount,
+ setAmount,
+ usdAmount,
+ setUsdAmount,
+ attachment,
+ setAttachment,
+ charge,
+ setCharge,
+ payment,
+ setPayment,
+ txHash,
+ setTxHash,
+ error,
+ setError,
+ isLoading,
+ setIsLoading,
+ isSuccess,
+ setIsSuccess,
+ totalAmount,
+ totalCollected,
+ contributors,
+ resetContributePotFlow,
+ isExternalWalletPayment,
+ setIsExternalWalletPayment,
+ }),
+ [
+ currentView,
+ request,
+ recipient,
+ amount,
+ usdAmount,
+ attachment,
+ charge,
+ payment,
+ txHash,
+ error,
+ isLoading,
+ isSuccess,
+ totalAmount,
+ totalCollected,
+ contributors,
+ resetContributePotFlow,
+ isExternalWalletPayment,
+ setIsExternalWalletPayment,
+ ]
+ )
+
+ return {children}
+}
+
+export function useContributePotFlowContext() {
+ const context = useContext(ContributePotFlowContext)
+ if (!context) {
+ throw new Error('useContributePotFlowContext must be used within ContributePotFlowProvider')
+ }
+ return context
+}
+
+// re-export types for convenience
+export type { ContributePotAttachment, ContributePotError }
diff --git a/src/features/payments/flows/contribute-pot/ContributePotPage.tsx b/src/features/payments/flows/contribute-pot/ContributePotPage.tsx
new file mode 100644
index 000000000..40d60a223
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/ContributePotPage.tsx
@@ -0,0 +1,46 @@
+'use client'
+
+/**
+ * main entry point for contribute pot flow
+ *
+ * wraps content with context provider and renders the correct view:
+ * - INITIAL: amount input with slider
+ * - STATUS: success view after payment
+ *
+ * receives pre-fetched request data from wrapper
+ */
+
+import { ContributePotFlowProvider, useContributePotFlowContext } from './ContributePotFlowContext'
+import { ContributePotInputView } from './views/ContributePotInputView'
+import { ContributePotSuccessView } from './views/ContributePotSuccessView'
+import { type TRequestResponse } from '@/services/services.types'
+import ExternalWalletPaymentView from './views/ExternalWalletPaymentView'
+
+// internal component that switches views
+function ContributePotFlowContent() {
+ const { currentView } = useContributePotFlowContext()
+
+ switch (currentView) {
+ case 'STATUS':
+ return
+ case 'EXTERNAL_WALLET':
+ return
+ case 'INITIAL':
+ default:
+ return
+ }
+}
+
+// props for the page
+interface ContributePotPageProps {
+ request: TRequestResponse
+}
+
+// exported page component with provider
+export function ContributePotPage({ request }: ContributePotPageProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/features/payments/flows/contribute-pot/ContributePotPageWrapper.tsx b/src/features/payments/flows/contribute-pot/ContributePotPageWrapper.tsx
new file mode 100644
index 000000000..9f7c22bc5
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/ContributePotPageWrapper.tsx
@@ -0,0 +1,79 @@
+'use client'
+
+/**
+ * wrapper component for ContributePotPage
+ *
+ * handles async request fetching before rendering the actual flow.
+ * shows loading/error states while fetching.
+ *
+ * used by: /[...recipient]?id=xyz route when id param is a request pot uuid
+ */
+
+import { ContributePotPage } from './ContributePotPage'
+import { requestsApi } from '@/services/requests'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import NavHeader from '@/components/Global/NavHeader'
+import { useRouter } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { type TRequestResponse } from '@/services/services.types'
+
+interface ContributePotPageWrapperProps {
+ requestId: string
+}
+
+export function ContributePotPageWrapper({ requestId }: ContributePotPageWrapperProps) {
+ const router = useRouter()
+ const [request, setRequest] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // fetch request details
+ useEffect(() => {
+ if (!requestId) {
+ setError('no request id provided')
+ setIsLoading(false)
+ return
+ }
+
+ setIsLoading(true)
+ setError(null)
+
+ requestsApi
+ .get(requestId)
+ .then((data) => {
+ setRequest(data)
+ })
+ .catch((err) => {
+ console.error('failed to fetch request:', err)
+ setError('failed to load request. it may not exist or has been deleted.')
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }, [requestId])
+
+ // loading state
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ // error state
+ if (error || !request) {
+ return (
+
+ router.back()} />
+
+
+ )
+ }
+
+ return
+}
diff --git a/src/features/payments/flows/contribute-pot/components/ContributorsDrawer.tsx b/src/features/payments/flows/contribute-pot/components/ContributorsDrawer.tsx
new file mode 100644
index 000000000..6eb39894e
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/components/ContributorsDrawer.tsx
@@ -0,0 +1,70 @@
+'use client'
+
+/**
+ * drawer component to show all contributors for a request pot
+ *
+ * displays a scrollable list of people who have contributed with:
+ * - their username or address
+ * - amount contributed
+ *
+ * hidden when there are no contributors yet
+ */
+
+import { Drawer, DrawerContent, DrawerHeader, DrawerTitle, DrawerTrigger } from '@/components/Global/Drawer'
+import ContributorCard, { type Contributor } from '@/components/Global/Contributors/ContributorCard'
+import { getCardPosition } from '@/components/Global/Card'
+import { Button } from '@/components/0_Bruddle/Button'
+import { type PotContributor } from '../ContributePotFlowContext'
+import { useMemo } from 'react'
+import Groups2OutlinedIcon from '@mui/icons-material/Groups2Outlined'
+
+interface ContributorsDrawerProps {
+ contributors: PotContributor[]
+}
+
+export function ContributorsDrawer({ contributors }: ContributorsDrawerProps) {
+ // map to ContributorCard format
+ const contributorCards = useMemo(() => {
+ return contributors.map((c) => ({
+ uuid: c.uuid,
+ payments: [],
+ amount: c.amount,
+ username: c.username || c.address,
+ fulfillmentPayment: null,
+ isUserVerified: false,
+ isPeanutUser: !!c.username,
+ }))
+ }, [contributors])
+
+ if (contributors.length === 0) {
+ return null
+ }
+
+ return (
+
+
+ }
+ variant="transparent"
+ className="h-5 w-fit self-start p-0 text-xs font-normal underline underline-offset-2 active:translate-x-0"
+ >
+ See all contributors
+
+
+
+
+ Contributors ({contributors.length})
+
+
+ {contributorCards.map((contributor, index) => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx b/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx
new file mode 100644
index 000000000..40baea704
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/components/RequestPotActionList.tsx
@@ -0,0 +1,249 @@
+'use client'
+
+/**
+ * payment options for request pot flow
+ *
+ * shows payment methods for contributing to a request pot:
+ * - pay with peanut (primary, uses wallet balance)
+ * - bank/mercadopago/pix (redirects to add-money)
+ *
+ * includes smart "use your peanut balance" modal - if user has
+ * enough balance but clicks on bank, suggests using peanut instead
+ *
+ * validates minimum amounts for bank transfers
+ */
+
+import { useMemo, useState } from 'react'
+import { useRouter } from 'next/navigation'
+import Divider from '@/components/0_Bruddle/Divider'
+import StatusBadge from '@/components/Global/Badges/StatusBadge'
+import IconStack from '@/components/Global/IconStack'
+import Loading from '@/components/Global/Loading'
+import ActionModal from '@/components/Global/ActionModal'
+import { ActionListCard } from '@/components/ActionListCard'
+import { useAuth } from '@/context/authContext'
+import { useWallet } from '@/hooks/wallet/useWallet'
+import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions'
+import useKycStatus from '@/hooks/useKycStatus'
+import { BankRequestType, useDetermineBankRequestType } from '@/hooks/useDetermineBankRequestType'
+import { ACTION_METHODS, type PaymentMethod } from '@/constants/actionlist.consts'
+import { MIN_BANK_TRANSFER_AMOUNT, validateMinimumAmount } from '@/constants/payment.consts'
+import { saveRedirectUrl, saveToLocalStorage } from '@/utils/general.utils'
+import SendWithPeanutCta from '@/features/payments/shared/components/SendWithPeanutCta'
+
+interface RequestPotActionListProps {
+ isAmountEntered: boolean
+ usdAmount: string
+ recipientUserId?: string
+ onPayWithPeanut: () => void
+ isPaymentLoading?: boolean
+ onPayWithExternalWallet: () => void
+}
+
+export function RequestPotActionList({
+ isAmountEntered,
+ usdAmount,
+ recipientUserId,
+ onPayWithPeanut,
+ isPaymentLoading = false,
+ onPayWithExternalWallet,
+}: RequestPotActionListProps) {
+ const router = useRouter()
+ const { user } = useAuth()
+ const { hasSufficientBalance, isFetchingBalance } = useWallet()
+ const { isUserMantecaKycApproved } = useKycStatus()
+ const { requestType } = useDetermineBankRequestType(recipientUserId ?? '')
+
+ const [showMinAmountError, setShowMinAmountError] = useState(false)
+ const [showUsePeanutBalanceModal, setShowUsePeanutBalanceModal] = useState(false)
+ const [isUsePeanutBalanceModalShown, setIsUsePeanutBalanceModalShown] = useState(false)
+ const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null)
+
+ const isLoggedIn = !!user?.user?.userId
+
+ // check if verification is required for bank
+ const requiresVerification = useMemo(() => {
+ return requestType === BankRequestType.GuestKycNeeded || requestType === BankRequestType.PayerKycNeeded
+ }, [requestType])
+
+ // check if user has enough peanut balance for the entered amount
+ // only show insufficient balance after balance has loaded to avoid flash
+ const userHasSufficientPeanutBalance = useMemo(() => {
+ if (!user || !usdAmount) return false
+ if (isFetchingBalance) return true // assume sufficient while loading to avoid flash
+ return hasSufficientBalance(usdAmount)
+ }, [user, usdAmount, hasSufficientBalance, isFetchingBalance])
+
+ // filter and sort payment methods
+ const { filteredMethods: sortedMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({
+ sortUnavailable: true,
+ isMethodUnavailable: (method) =>
+ method.soon ||
+ (method.id === 'bank' && requiresVerification) ||
+ (['mercadopago', 'pix'].includes(method.id) && !isUserMantecaKycApproved),
+ methods: ACTION_METHODS,
+ })
+
+ // handle clicking on a payment method
+ const handleMethodClick = (method: PaymentMethod, bypassBalanceModal = false) => {
+ const requestAmountValue = parseFloat(usdAmount || '0')
+
+ // validate minimum amount for bank/mercadopago/pix against user-entered amount
+ if (
+ ['bank', 'mercadopago', 'pix'].includes(method.id) &&
+ !validateMinimumAmount(requestAmountValue, method.id)
+ ) {
+ setShowMinAmountError(true)
+ return
+ }
+
+ // if user has sufficient peanut balance and hasn't dismissed the modal, suggest using peanut
+ if (!bypassBalanceModal && !isUsePeanutBalanceModalShown && userHasSufficientPeanutBalance) {
+ setSelectedPaymentMethod(method)
+ setShowUsePeanutBalanceModal(true)
+ return
+ }
+
+ if (method.id === 'exchange-or-wallet') {
+ onPayWithExternalWallet()
+ return
+ }
+
+ // redirect to add-money flow for bank/mercadopago/pix
+ switch (method.id) {
+ case 'bank':
+ case 'mercadopago':
+ case 'pix':
+ if (isLoggedIn) {
+ // save current url so back button works properly
+ saveRedirectUrl()
+ // flag that we're coming from request fulfillment
+ saveToLocalStorage('fromRequestFulfillment', 'true')
+ router.push('/add-money')
+ } else {
+ const redirectUri = encodeURIComponent('/add-money')
+ router.push(`/setup?redirect_uri=${redirectUri}`)
+ }
+ break
+ }
+ }
+
+ // handle continue with peanut (for non-logged in users)
+ const handleContinueWithPeanut = () => {
+ if (!isLoggedIn) {
+ saveRedirectUrl()
+ router.push('/setup')
+ } else {
+ onPayWithPeanut()
+ }
+ }
+
+ if (isGeoLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {/* pay with peanut button */}
+
+
+
+
+ {/* payment methods */}
+
+ {sortedMethods.map((method) => {
+ let methodRequiresVerification = method.id === 'bank' && requiresVerification
+ if (!isUserMantecaKycApproved && ['mercadopago', 'pix'].includes(method.id)) {
+ methodRequiresVerification = true
+ }
+
+ return (
+
+ {method.title}
+ {(method.soon || methodRequiresVerification) && (
+
+ )}
+
+ }
+ onClick={() => handleMethodClick(method)}
+ isDisabled={method.soon || !isAmountEntered}
+ rightContent={
}
+ />
+ )
+ })}
+
+
+ {/* minimum amount error modal */}
+ setShowMinAmountError(false)}
+ title="Minimum Amount"
+ description={`The minimum amount for this payment method is $${MIN_BANK_TRANSFER_AMOUNT}. Please enter a higher amount or try a different method.`}
+ icon="alert"
+ ctas={[{ text: 'Close', shadowSize: '4', onClick: () => setShowMinAmountError(false) }]}
+ iconContainerClassName="bg-yellow-400"
+ preventClose={false}
+ modalPanelClassName="max-w-md mx-8"
+ />
+
+ {/* use peanut balance modal - only shown when user has enough balance */}
+ {
+ setShowUsePeanutBalanceModal(false)
+ setIsUsePeanutBalanceModalShown(true)
+ setSelectedPaymentMethod(null)
+ }}
+ title="Use your Peanut balance instead"
+ description="You already have enough funds in your Peanut account. Using this method is instant and avoids delays."
+ icon="user-plus"
+ ctas={[
+ {
+ text: 'Pay with Peanut',
+ shadowSize: '4',
+ onClick: () => {
+ setShowUsePeanutBalanceModal(false)
+ setIsUsePeanutBalanceModalShown(true)
+ setSelectedPaymentMethod(null)
+ onPayWithPeanut()
+ },
+ },
+ {
+ text: 'Continue',
+ shadowSize: '4',
+ variant: 'stroke',
+ onClick: () => {
+ setShowUsePeanutBalanceModal(false)
+ setIsUsePeanutBalanceModalShown(true)
+ if (selectedPaymentMethod) {
+ handleMethodClick(selectedPaymentMethod, true)
+ }
+ setSelectedPaymentMethod(null)
+ },
+ },
+ ]}
+ iconContainerClassName="bg-primary-1"
+ preventClose={false}
+ modalPanelClassName="max-w-md mx-8"
+ />
+
+ )
+}
diff --git a/src/features/payments/flows/contribute-pot/useContributePotFlow.ts b/src/features/payments/flows/contribute-pot/useContributePotFlow.ts
new file mode 100644
index 000000000..997a98759
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/useContributePotFlow.ts
@@ -0,0 +1,282 @@
+'use client'
+
+/**
+ * hook for contribute pot flow
+ *
+ * handles the full payment lifecycle for request pot contributions:
+ * 1. validates amount and checks balance
+ * 2. creates a charge in backend
+ * 3. sends payment via peanut wallet
+ * 4. records the payment to backend
+ *
+ * also provides smart defaults for the contribution slider
+ * based on existing contributions (e.g. suggests 1/3 for split bills)
+ */
+
+import { useCallback, useMemo } from 'react'
+import { type Address, type Hash } from 'viem'
+import { useContributePotFlowContext } from './ContributePotFlowContext'
+import { useChargeManager } from '@/features/payments/shared/hooks/useChargeManager'
+import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder'
+import { useWallet } from '@/hooks/wallet/useWallet'
+import { useAuth } from '@/context/authContext'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
+import { ErrorHandler } from '@/utils/sdkErrorHandler.utils'
+
+export function useContributePotFlow() {
+ const {
+ amount,
+ setAmount,
+ usdAmount,
+ setUsdAmount,
+ currentView,
+ setCurrentView,
+ request,
+ recipient,
+ attachment,
+ setAttachment,
+ charge,
+ setCharge,
+ payment,
+ setPayment,
+ txHash,
+ setTxHash,
+ error,
+ setError,
+ isLoading,
+ setIsLoading,
+ isSuccess,
+ setIsSuccess,
+ totalAmount,
+ totalCollected,
+ contributors,
+ resetContributePotFlow,
+ isExternalWalletPayment,
+ setIsExternalWalletPayment,
+ } = useContributePotFlowContext()
+
+ const { user } = useAuth()
+ const { createCharge, isCreating: isCreatingCharge } = useChargeManager()
+ const { recordPayment, isRecording } = usePaymentRecorder()
+ const {
+ isConnected,
+ address: walletAddress,
+ sendMoney,
+ formattedBalance,
+ hasSufficientBalance,
+ isFetchingBalance,
+ } = useWallet()
+
+ const isLoggedIn = !!user?.user?.userId
+
+ // set amount (for peanut wallet, amount is always in usd)
+ const handleSetAmount = useCallback(
+ (value: string) => {
+ setAmount(value)
+ setUsdAmount(value)
+ },
+ [setAmount, setUsdAmount]
+ )
+
+ // clear error
+ const clearError = useCallback(() => {
+ setError({ showError: false, errorMessage: '' })
+ }, [setError])
+
+ // check if can proceed
+ const canProceed = useMemo(() => {
+ if (!amount || !recipient || !request) return false
+ const amountNum = parseFloat(amount)
+ if (isNaN(amountNum) || amountNum <= 0) return false
+ return true
+ }, [amount, recipient, request])
+
+ // check if has sufficient balance for current amount
+ const hasEnoughBalance = useMemo(() => {
+ if (!amount) return false
+ return hasSufficientBalance(amount)
+ }, [amount, hasSufficientBalance])
+
+ // check if should show insufficient balance error
+ // only show after balance has loaded to avoid flash of error on initial render
+ const isInsufficientBalance = useMemo(() => {
+ return (
+ isLoggedIn &&
+ !!amount &&
+ !hasEnoughBalance &&
+ !isLoading &&
+ !isCreatingCharge &&
+ !isRecording &&
+ !isFetchingBalance
+ )
+ }, [isLoggedIn, amount, hasEnoughBalance, isLoading, isCreatingCharge, isRecording, isFetchingBalance])
+
+ // calculate default slider value and suggested amount
+ const sliderDefaults = useMemo(() => {
+ if (totalAmount <= 0) return { percentage: 0, suggestedAmount: 0 }
+
+ // no contributions yet - suggest 100% (full pot)
+ if (contributors.length === 0) {
+ return { percentage: 100, suggestedAmount: totalAmount }
+ }
+
+ // calculate based on existing contributions
+ const contributionAmounts = contributors.map((c) => parseFloat(c.amount)).filter((a) => !isNaN(a) && a > 0)
+
+ if (contributionAmounts.length === 0) return { percentage: 0, suggestedAmount: 0 }
+
+ // check if this is an equal-split pattern
+ const collectedPercentage = (totalCollected / totalAmount) * 100
+ const isOneThirdCollected = Math.abs(collectedPercentage - 100 / 3) < 2
+ const isTwoThirdsCollected = Math.abs(collectedPercentage - 200 / 3) < 2
+
+ if (isOneThirdCollected || isTwoThirdsCollected) {
+ const exactThird = 100 / 3
+ return { percentage: exactThird, suggestedAmount: totalAmount * (exactThird / 100) }
+ }
+
+ // suggest median contribution
+ const sortedAmounts = [...contributionAmounts].sort((a, b) => a - b)
+ const midIndex = Math.floor(sortedAmounts.length / 2)
+ const suggestedAmount =
+ sortedAmounts.length % 2 === 0
+ ? (sortedAmounts[midIndex - 1] + sortedAmounts[midIndex]) / 2
+ : sortedAmounts[midIndex]
+
+ const percentage = Math.min((suggestedAmount / totalAmount) * 100, 100)
+ return { percentage, suggestedAmount }
+ }, [totalAmount, totalCollected, contributors])
+
+ // execute the contribution
+ const executeContribution = useCallback(
+ async (
+ shouldReturnAfterCreatingCharge: boolean = false,
+ bypassLoginCheck: boolean = false
+ ): Promise<{ success: boolean }> => {
+ if (!recipient || !amount || !request) {
+ setError({ showError: true, errorMessage: 'missing required data' })
+ return { success: false }
+ }
+
+ if (!bypassLoginCheck && !walletAddress) {
+ setError({ showError: true, errorMessage: 'Please login to continue' })
+ return { success: false }
+ }
+
+ setIsLoading(true)
+ clearError()
+
+ try {
+ // step 1: create charge for this contribution
+ const chargeResult = await createCharge({
+ tokenAmount: amount,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ tokenSymbol: 'USDC',
+ tokenDecimals: PEANUT_WALLET_TOKEN_DECIMALS,
+ recipientAddress: recipient.address,
+ transactionType: 'REQUEST',
+ requestId: request.uuid,
+ reference: attachment.message,
+ attachment: attachment.file,
+ currencyAmount: usdAmount,
+ currencyCode: 'USD',
+ })
+
+ setCharge(chargeResult)
+
+ // Return early after creating charge if requested, used in external wallet flow.
+ if (shouldReturnAfterCreatingCharge) {
+ setIsLoading(false)
+ return { success: true }
+ }
+
+ // step 2: send money via peanut wallet
+ const txResult = await sendMoney(recipient.address, amount)
+ const hash = (txResult.receipt?.transactionHash ?? txResult.userOpHash) as Hash
+
+ setTxHash(hash)
+
+ // step 3: record payment to backend
+ const paymentResult = await recordPayment({
+ chargeId: chargeResult.uuid,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ txHash: hash,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ payerAddress: walletAddress as Address,
+ })
+
+ setPayment(paymentResult)
+ setIsSuccess(true)
+ setCurrentView('STATUS')
+ setIsLoading(false)
+ return { success: true }
+ } catch (err) {
+ const errorMessage = ErrorHandler(err)
+ setError({ showError: true, errorMessage })
+ setIsLoading(false)
+ return { success: false }
+ }
+ },
+ [
+ recipient,
+ amount,
+ usdAmount,
+ attachment,
+ walletAddress,
+ request,
+ createCharge,
+ sendMoney,
+ recordPayment,
+ setCharge,
+ setTxHash,
+ setPayment,
+ setIsSuccess,
+ setCurrentView,
+ setError,
+ setIsLoading,
+ clearError,
+ ]
+ )
+
+ return {
+ // state
+ amount,
+ usdAmount,
+ currentView,
+ request,
+ recipient,
+ attachment,
+ charge,
+ payment,
+ txHash,
+ error,
+ isLoading: isLoading || isCreatingCharge || isRecording,
+ isSuccess,
+ isExternalWalletPayment,
+
+ // derived data
+ totalAmount,
+ totalCollected,
+ contributors,
+ sliderDefaults,
+
+ // computed
+ canProceed,
+ hasSufficientBalance: hasEnoughBalance,
+ isInsufficientBalance,
+ isLoggedIn,
+ isConnected,
+ walletAddress,
+ formattedBalance,
+
+ // actions
+ setAmount: handleSetAmount,
+ setAttachment,
+ clearError,
+ executeContribution,
+ resetContributePotFlow,
+ setCurrentView,
+ setIsExternalWalletPayment,
+ }
+}
diff --git a/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx b/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx
new file mode 100644
index 000000000..1c58697e8
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/views/ContributePotInputView.tsx
@@ -0,0 +1,135 @@
+'use client'
+
+/**
+ * input view for contribute pot flow
+ *
+ * displays:
+ * - recipient card with pot progress (amount collected / total)
+ * - amount input with slider (defaults to smart suggestion)
+ * - payment method options
+ * - contributors drawer (see who else paid)
+ *
+ * executes payment directly on submit
+ */
+
+import NavHeader from '@/components/Global/NavHeader'
+import AmountInput from '@/components/Global/AmountInput'
+import UserCard from '@/components/User/UserCard'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import SupportCTA from '@/components/Global/SupportCTA'
+import { useContributePotFlow } from '../useContributePotFlow'
+import { useRouter } from 'next/navigation'
+import { useAuth } from '@/context/authContext'
+import { RequestPotActionList } from '../components/RequestPotActionList'
+
+export function ContributePotInputView() {
+ const router = useRouter()
+ const { isFetchingUser } = useAuth()
+ const {
+ amount,
+ request,
+ recipient,
+ error,
+ formattedBalance,
+ canProceed,
+ hasSufficientBalance,
+ isInsufficientBalance,
+ isLoggedIn,
+ isLoading,
+ totalAmount,
+ totalCollected,
+ contributors,
+ sliderDefaults,
+ setAmount,
+ executeContribution,
+ setCurrentView,
+ } = useContributePotFlow()
+
+ // handle submit - directly execute contribution
+ const handlePayWithPeanut = () => {
+ if (canProceed && hasSufficientBalance && !isLoading) {
+ executeContribution()
+ }
+ }
+
+ // handle back navigation
+ const handleGoBack = () => {
+ if (window.history.length > 1) {
+ router.back()
+ } else {
+ router.push('/')
+ }
+ }
+
+ // handle External Wallet click
+ const handleOpenExternalWalletFlow = async () => {
+ if (canProceed && !isLoading) {
+ const res = await executeContribution(true, true) // return after creating charge
+ // Proceed only if charge is created successfully
+ if (res && res.success) {
+ setCurrentView('EXTERNAL_WALLET')
+ }
+ }
+ }
+
+ // determine button state
+ const isAmountEntered = !!amount && parseFloat(amount) > 0
+
+ return (
+
+
+
+
+ {/* recipient card with pot info */}
+ {recipient && (
+
+ )}
+
+ {/* amount input with slider */}
+
0}
+ maxAmount={totalAmount}
+ amountCollected={totalCollected}
+ defaultSliderValue={sliderDefaults.percentage}
+ defaultSliderSuggestedAmount={sliderDefaults.suggestedAmount}
+ />
+
+ {/* error display */}
+ {isInsufficientBalance && (
+
+ )}
+ {error.showError && }
+
+ {/* payment options */}
+
+
+
+ {/* support cta */}
+ {!isFetchingUser &&
}
+
+ )
+}
diff --git a/src/features/payments/flows/contribute-pot/views/ContributePotSuccessView.tsx b/src/features/payments/flows/contribute-pot/views/ContributePotSuccessView.tsx
new file mode 100644
index 000000000..0ac78fa9f
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/views/ContributePotSuccessView.tsx
@@ -0,0 +1,45 @@
+'use client'
+
+/**
+ * success view for contribute pot flow
+ *
+ * thin wrapper around PaymentSuccessView that:
+ * - pulls data from contribute pot flow context
+ * - calculates points earned for the contribution
+ * - provides reset callback on completion
+ */
+
+import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView'
+import { useContributePotFlow } from '../useContributePotFlow'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
+import { PointsAction } from '@/services/services.types'
+
+export function ContributePotSuccessView() {
+ const { usdAmount, recipient, attachment, charge, payment, resetContributePotFlow, isExternalWalletPayment } =
+ useContributePotFlow()
+
+ // calculate points for the contribution
+ const { pointsData } = usePointsCalculation(
+ PointsAction.P2P_REQUEST_PAYMENT,
+ usdAmount,
+ !!payment || isExternalWalletPayment, // For external wallet payments, we dont't have payment info on the FE, its handled by webooks on BE
+ payment?.uuid,
+ recipient?.userId
+ )
+
+ return (
+
+ )
+}
diff --git a/src/features/payments/flows/contribute-pot/views/ExternalWalletPaymentView.tsx b/src/features/payments/flows/contribute-pot/views/ExternalWalletPaymentView.tsx
new file mode 100644
index 000000000..c87abf24a
--- /dev/null
+++ b/src/features/payments/flows/contribute-pot/views/ExternalWalletPaymentView.tsx
@@ -0,0 +1,58 @@
+'use client'
+
+import RhinoDepositView from '@/components/AddMoney/views/RhinoDeposit.view'
+import { useContributePotFlow } from '../useContributePotFlow'
+import { useCallback, useState } from 'react'
+import type { RhinoChainType } from '@/services/services.types'
+import { useQuery } from '@tanstack/react-query'
+import { rhinoApi } from '@/services/rhino'
+import { useWallet } from '@/hooks/wallet/useWallet'
+
+const ExternalWalletPaymentView = () => {
+ const { charge, setCurrentView, setIsExternalWalletPayment, amount, request } = useContributePotFlow()
+ const [chainType, setChainType] = useState('EVM')
+ const { address: peanutWalletAddress } = useWallet()
+
+ const { data: depositAddressData, isLoading } = useQuery({
+ queryKey: ['rhino-deposit-address', charge?.uuid, chainType],
+ queryFn: () => {
+ if (!charge?.requestLink.uuid) {
+ throw new Error('Request ID is required')
+ }
+ if (!charge.uuid) {
+ throw new Error('Charge ID is required')
+ }
+ return rhinoApi.createRequestFulfilmentAddress(chainType, charge?.uuid as string, peanutWalletAddress)
+ },
+ enabled: !!charge,
+ staleTime: 1000 * 60 * 60 * 24, // 24 hours
+ })
+
+ const onSuccess = useCallback((_: number) => {
+ setIsExternalWalletPayment(true)
+ setCurrentView('STATUS')
+ }, [])
+
+ return (
+
+ setCurrentView('INITIAL')}
+ identifier={
+ request?.recipientAccount.type === 'peanut-wallet'
+ ? request?.recipientAccount.user.username
+ : request?.recipientAccount.identifier
+ }
+ amount={Number(amount)}
+ />
+
+ )
+}
+
+export default ExternalWalletPaymentView
diff --git a/src/features/payments/flows/direct-send/DirectSendFlowContext.tsx b/src/features/payments/flows/direct-send/DirectSendFlowContext.tsx
new file mode 100644
index 000000000..e2e03d613
--- /dev/null
+++ b/src/features/payments/flows/direct-send/DirectSendFlowContext.tsx
@@ -0,0 +1,155 @@
+'use client'
+
+/**
+ * context for send flow state management
+ *
+ * direct send flow for paying another peanut user by username.
+ * payments are always usdc on arbitrum (peanut wallet).
+ *
+ * route: /send/username
+ *
+ */
+
+import React, { createContext, useContext, useMemo, useState, useCallback, type ReactNode } from 'react'
+import { type Address, type Hash } from 'viem'
+import { type TRequestChargeResponse, type PaymentCreationResponse } from '@/services/services.types'
+
+// view states
+export type DirectSendFlowView = 'INITIAL' | 'STATUS'
+
+// recipient info
+export interface DirectSendRecipient {
+ username: string
+ address: Address
+ userId?: string
+ fullName?: string
+}
+
+// attachment options
+export interface DirectSendAttachment {
+ message?: string
+ file?: File
+ fileUrl?: string
+}
+
+// error state for input view
+export interface DirectSendFlowErrorState {
+ showError: boolean
+ errorMessage: string
+}
+
+// context type
+interface DirectSendFlowContextType {
+ // state
+ amount: string
+ setAmount: (amount: string) => void
+ usdAmount: string
+ setUsdAmount: (amount: string) => void
+ currentView: DirectSendFlowView
+ setCurrentView: (view: DirectSendFlowView) => void
+ recipient: DirectSendRecipient | null
+ setRecipient: (recipient: DirectSendRecipient | null) => void
+ attachment: DirectSendAttachment
+ setAttachment: (attachment: DirectSendAttachment) => void
+ charge: TRequestChargeResponse | null
+ setCharge: (charge: TRequestChargeResponse | null) => void
+ payment: PaymentCreationResponse | null
+ setPayment: (payment: PaymentCreationResponse | null) => void
+ txHash: Hash | null
+ setTxHash: (hash: Hash | null) => void
+ error: DirectSendFlowErrorState
+ setError: (error: DirectSendFlowErrorState) => void
+ isLoading: boolean
+ setIsLoading: (loading: boolean) => void
+ isSuccess: boolean
+ setIsSuccess: (success: boolean) => void
+
+ // actions
+ resetSendFlow: () => void
+}
+
+const DirectSendFlowContext = createContext(undefined)
+
+interface SendFlowProviderProps {
+ children: ReactNode
+ initialRecipient?: DirectSendRecipient
+}
+
+export const DirectSendFlowProvider: React.FC = ({ children, initialRecipient }) => {
+ const [amount, setAmount] = useState('')
+ const [usdAmount, setUsdAmount] = useState('')
+ const [currentView, setCurrentView] = useState('INITIAL')
+ const [recipient, setRecipient] = useState(initialRecipient ?? null)
+ const [attachment, setAttachment] = useState({})
+ const [charge, setCharge] = useState(null)
+ const [payment, setPayment] = useState(null)
+ const [txHash, setTxHash] = useState(null)
+ const [error, setError] = useState({ showError: false, errorMessage: '' })
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSuccess, setIsSuccess] = useState(false)
+
+ const resetSendFlow = useCallback(() => {
+ setAmount('')
+ setUsdAmount('')
+ setCurrentView('INITIAL')
+ setAttachment({})
+ setCharge(null)
+ setPayment(null)
+ setTxHash(null)
+ setError({ showError: false, errorMessage: '' })
+ setIsLoading(false)
+ setIsSuccess(false)
+ }, [])
+
+ const value = useMemo(
+ () => ({
+ amount,
+ setAmount,
+ usdAmount,
+ setUsdAmount,
+ currentView,
+ setCurrentView,
+ recipient,
+ setRecipient,
+ attachment,
+ setAttachment,
+ charge,
+ setCharge,
+ payment,
+ setPayment,
+ txHash,
+ setTxHash,
+ error,
+ setError,
+ isLoading,
+ setIsLoading,
+ isSuccess,
+ setIsSuccess,
+ resetSendFlow,
+ }),
+ [
+ amount,
+ usdAmount,
+ currentView,
+ recipient,
+ attachment,
+ charge,
+ payment,
+ txHash,
+ error,
+ isLoading,
+ isSuccess,
+ resetSendFlow,
+ ]
+ )
+
+ return {children}
+}
+
+export const useDirectSendFlowContext = (): DirectSendFlowContextType => {
+ const context = useContext(DirectSendFlowContext)
+ if (context === undefined) {
+ throw new Error('useDirectSendFlowContext must be used within DirectSendFlowProvider')
+ }
+ return context
+}
diff --git a/src/features/payments/flows/direct-send/DirectSendPage.tsx b/src/features/payments/flows/direct-send/DirectSendPage.tsx
new file mode 100644
index 000000000..2c37055f1
--- /dev/null
+++ b/src/features/payments/flows/direct-send/DirectSendPage.tsx
@@ -0,0 +1,42 @@
+'use client'
+
+/**
+ * main entry point for direct send flow
+ *
+ * wraps content with context provider and renders the correct view:
+ * - INITIAL: amount input with optional message
+ * - STATUS: success view after payment
+ *
+ * receives pre-resolved recipient data from wrapper
+ */
+
+import { DirectSendFlowProvider, useDirectSendFlowContext, type DirectSendRecipient } from './DirectSendFlowContext'
+import { SendInputView } from './views/SendInputView'
+import { SendSuccessView } from './views/SendSuccessView'
+
+// internal component that switches views
+function SendFlowContent() {
+ const { currentView } = useDirectSendFlowContext()
+
+ switch (currentView) {
+ case 'STATUS':
+ return
+ case 'INITIAL':
+ default:
+ return
+ }
+}
+
+// props for the page
+interface DirectSendPageProps {
+ recipient: DirectSendRecipient
+}
+
+// exported page component with provider
+export function DirectSendPage({ recipient }: DirectSendPageProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/features/payments/flows/direct-send/DirectSendPageWrapper.tsx b/src/features/payments/flows/direct-send/DirectSendPageWrapper.tsx
new file mode 100644
index 000000000..06136fc82
--- /dev/null
+++ b/src/features/payments/flows/direct-send/DirectSendPageWrapper.tsx
@@ -0,0 +1,72 @@
+'use client'
+
+/**
+ * wrapper component for DirectSendPage
+ *
+ * handles async username resolution before rendering the actual flow.
+ * finds the user's peanut wallet address from their username.
+ *
+ * shows loading/error states while resolving
+ *
+ * used by: /send/[...username] route
+ */
+
+import { useUserByUsername } from '@/hooks/useUserByUsername'
+import { AccountType } from '@/interfaces'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import NavHeader from '@/components/Global/NavHeader'
+import { useRouter } from 'next/navigation'
+import { useMemo } from 'react'
+import { type Address } from 'viem'
+import type { DirectSendRecipient } from './DirectSendFlowContext'
+import { DirectSendPage } from './DirectSendPage'
+
+interface DirectSendPageWrapperProps {
+ username: string
+}
+
+export function DirectSendPageWrapper({ username }: DirectSendPageWrapperProps) {
+ const router = useRouter()
+ const { user, isLoading, error } = useUserByUsername(username)
+
+ // resolve user to recipient
+ const recipient = useMemo(() => {
+ if (!user) return null
+
+ // find peanut wallet address
+ const walletAccount = user.accounts.find((acc) => acc.type === AccountType.PEANUT_WALLET)
+ if (!walletAccount) return null
+
+ return {
+ username: user.username,
+ address: walletAccount.identifier as Address,
+ userId: user.userId,
+ fullName: user.fullName,
+ }
+ }, [user])
+
+ // loading state
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ // error state
+ if (error || !recipient) {
+ return (
+
+ router.back()} />
+
+
+ )
+ }
+
+ return
+}
diff --git a/src/features/payments/flows/direct-send/useDirectSendFlow.ts b/src/features/payments/flows/direct-send/useDirectSendFlow.ts
new file mode 100644
index 000000000..81cf02930
--- /dev/null
+++ b/src/features/payments/flows/direct-send/useDirectSendFlow.ts
@@ -0,0 +1,193 @@
+'use client'
+
+/**
+ * hook for direct send flow
+ *
+ * handles the full payment lifecycle for direct sends to peanut users:
+ * 1. validates amount and checks balance
+ * 2. creates a charge in backend
+ * 3. sends usdc via peanut wallet
+ * 4. records the payment to backend
+ *
+ * note: no cross-chain, always usdc on arbitrum
+ */
+
+import { useCallback, useMemo } from 'react'
+import { type Address, type Hash } from 'viem'
+import { useDirectSendFlowContext } from './DirectSendFlowContext'
+import { useChargeManager } from '@/features/payments/shared/hooks/useChargeManager'
+import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder'
+import { useWallet } from '@/hooks/wallet/useWallet'
+import { useAuth } from '@/context/authContext'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
+import { ErrorHandler } from '@/utils/sdkErrorHandler.utils'
+
+export function useDirectSendFlow() {
+ const {
+ amount,
+ setAmount,
+ usdAmount,
+ setUsdAmount,
+ currentView,
+ setCurrentView,
+ recipient,
+ attachment,
+ setAttachment,
+ charge,
+ setCharge,
+ payment,
+ setPayment,
+ txHash,
+ setTxHash,
+ error,
+ setError,
+ isLoading,
+ setIsLoading,
+ isSuccess,
+ setIsSuccess,
+ resetSendFlow,
+ } = useDirectSendFlowContext()
+
+ const { user } = useAuth()
+ const { createCharge, isCreating: isCreatingCharge } = useChargeManager()
+ const { recordPayment, isRecording } = usePaymentRecorder()
+ const { isConnected, address: walletAddress, sendMoney, formattedBalance, hasSufficientBalance } = useWallet()
+
+ const isLoggedIn = !!user?.user?.userId
+
+ // set amount (for peanut wallet, amount is always in usd)
+ const handleSetAmount = useCallback(
+ (value: string) => {
+ setAmount(value)
+ setUsdAmount(value)
+ },
+ [setAmount, setUsdAmount]
+ )
+
+ // clear error
+ const clearError = useCallback(() => {
+ setError({ showError: false, errorMessage: '' })
+ }, [setError])
+
+ // check if can proceed
+ const canProceed = useMemo(() => {
+ if (!amount || !recipient) return false
+ const amountNum = parseFloat(amount)
+ if (isNaN(amountNum) || amountNum <= 0) return false
+ return true
+ }, [amount, recipient])
+
+ // check if has sufficient balance for current amount
+ const hasEnoughBalance = useMemo(() => {
+ if (!amount) return false
+ return hasSufficientBalance(amount)
+ }, [amount, hasSufficientBalance])
+
+ // check if should show insufficient balance error
+ const isInsufficientBalance = useMemo(() => {
+ return isLoggedIn && !!amount && !hasEnoughBalance && !isLoading && !isCreatingCharge && !isRecording
+ }, [isLoggedIn, amount, hasEnoughBalance, isLoading, isCreatingCharge, isRecording])
+
+ // execute the payment (called from input view)
+ const executePayment = useCallback(async () => {
+ if (!recipient || !amount || !walletAddress) {
+ setError({ showError: true, errorMessage: 'missing required data' })
+ return
+ }
+
+ setIsLoading(true)
+ clearError()
+
+ try {
+ // step 1: create charge
+ const chargeResult = await createCharge({
+ tokenAmount: amount,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ tokenSymbol: 'USDC',
+ tokenDecimals: PEANUT_WALLET_TOKEN_DECIMALS,
+ recipientAddress: recipient.address,
+ transactionType: 'DIRECT_SEND',
+ reference: attachment.message,
+ attachment: attachment.file,
+ currencyAmount: usdAmount,
+ currencyCode: 'USD',
+ })
+
+ setCharge(chargeResult)
+
+ // step 2: send money via peanut wallet
+ const txResult = await sendMoney(recipient.address, amount)
+ const hash = (txResult.receipt?.transactionHash ?? txResult.userOpHash) as Hash
+
+ setTxHash(hash)
+
+ // step 3: record payment to backend
+ const paymentResult = await recordPayment({
+ chargeId: chargeResult.uuid,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ txHash: hash,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ payerAddress: walletAddress as Address,
+ })
+
+ setPayment(paymentResult)
+ setIsSuccess(true)
+ setCurrentView('STATUS')
+ } catch (err) {
+ const errorMessage = ErrorHandler(err)
+ setError({ showError: true, errorMessage })
+ } finally {
+ setIsLoading(false)
+ }
+ }, [
+ recipient,
+ amount,
+ usdAmount,
+ attachment,
+ walletAddress,
+ createCharge,
+ sendMoney,
+ recordPayment,
+ setCharge,
+ setTxHash,
+ setPayment,
+ setIsSuccess,
+ setCurrentView,
+ setError,
+ setIsLoading,
+ clearError,
+ ])
+
+ return {
+ // state
+ amount,
+ usdAmount,
+ currentView,
+ recipient,
+ attachment,
+ charge,
+ payment,
+ txHash,
+ error,
+ isLoading: isLoading || isCreatingCharge || isRecording,
+ isSuccess,
+
+ // computed
+ canProceed,
+ hasSufficientBalance: hasEnoughBalance,
+ isInsufficientBalance,
+ isLoggedIn,
+ isConnected,
+ walletAddress,
+ formattedBalance,
+
+ // actions
+ setAmount: handleSetAmount,
+ setAttachment,
+ clearError,
+ executePayment,
+ resetSendFlow,
+ setCurrentView,
+ }
+}
diff --git a/src/features/payments/flows/direct-send/views/SendInputView.tsx b/src/features/payments/flows/direct-send/views/SendInputView.tsx
new file mode 100644
index 000000000..d46614f89
--- /dev/null
+++ b/src/features/payments/flows/direct-send/views/SendInputView.tsx
@@ -0,0 +1,132 @@
+'use client'
+
+/**
+ * input view for send flow
+ *
+ * displays:
+ * - recipient card (peanut username)
+ * - amount input
+ * - optional message/file attachment
+ * - payment method options
+ *
+ * executes payment directly on submit (no confirm step)
+ */
+
+import NavHeader from '@/components/Global/NavHeader'
+import AmountInput from '@/components/Global/AmountInput'
+import UserCard from '@/components/User/UserCard'
+import FileUploadInput from '@/components/Global/FileUploadInput'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import SupportCTA from '@/components/Global/SupportCTA'
+import { useDirectSendFlow } from '../useDirectSendFlow'
+import { useRouter } from 'next/navigation'
+import { useAuth } from '@/context/authContext'
+import SendWithPeanutCta from '@/features/payments/shared/components/SendWithPeanutCta'
+import { PaymentMethodActionList } from '@/features/payments/shared/components/PaymentMethodActionList'
+
+export function SendInputView() {
+ const router = useRouter()
+ const { isFetchingUser } = useAuth()
+ const {
+ amount,
+ recipient,
+ attachment,
+ error,
+ formattedBalance,
+ canProceed,
+ hasSufficientBalance,
+ isInsufficientBalance,
+ isLoggedIn,
+ isLoading,
+ setAmount,
+ setAttachment,
+ executePayment,
+ } = useDirectSendFlow()
+
+ // handle submit - directly execute payment
+ const handleSubmit = () => {
+ if (canProceed && hasSufficientBalance && !isLoading) {
+ executePayment()
+ }
+ }
+
+ // handle back navigation
+ const handleGoBack = () => {
+ if (window.history.length > 1) {
+ router.back()
+ } else {
+ router.push('/')
+ }
+ }
+
+ // determine button text and state
+ const isButtonDisabled = !canProceed || (isLoggedIn && !hasSufficientBalance) || isLoading
+ const isAmountEntered = !!amount && parseFloat(amount) > 0
+
+ return (
+
+
+
+
+ {/* recipient card */}
+ {recipient && (
+
+ )}
+
+ {/* amount input */}
+
+
+ {/* message input */}
+
+ setAttachment({
+ message: opts.message,
+ file: opts.rawFile,
+ fileUrl: opts.fileUrl,
+ })
+ }
+ className="h-11"
+ />
+
+ {/* button and error */}
+
+
+ {isInsufficientBalance && (
+
+ )}
+ {error.showError && }
+
+
+ {/* action list for non-logged in users */}
+ {!isLoggedIn && !isFetchingUser && }
+
+
+ {/* support cta for guest users */}
+ {!isLoggedIn && !isFetchingUser &&
}
+
+ )
+}
diff --git a/src/features/payments/flows/direct-send/views/SendSuccessView.tsx b/src/features/payments/flows/direct-send/views/SendSuccessView.tsx
new file mode 100644
index 000000000..c12d54b6c
--- /dev/null
+++ b/src/features/payments/flows/direct-send/views/SendSuccessView.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+/**
+ * success view for send flow
+ *
+ * thin wrapper around PaymentSuccessView that:
+ * - pulls data from send flow context
+ * - calculates points earned for the send
+ * - provides reset callback on completion
+ */
+
+import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView'
+import { useDirectSendFlow } from '../useDirectSendFlow'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
+import { PointsAction } from '@/services/services.types'
+
+export function SendSuccessView() {
+ const { usdAmount, recipient, attachment, charge, payment, resetSendFlow } = useDirectSendFlow()
+
+ // calculate points for the transaction
+ const { pointsData } = usePointsCalculation(
+ PointsAction.P2P_SEND_LINK,
+ usdAmount,
+ !!payment,
+ payment?.uuid,
+ recipient?.userId
+ )
+
+ return (
+
+ )
+}
diff --git a/src/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx b/src/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx
new file mode 100644
index 000000000..682f18c2e
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/SemanticRequestFlowContext.tsx
@@ -0,0 +1,237 @@
+'use client'
+
+/**
+ * context provider for semantic request flow state
+ *
+ * handles payments via semantic urls like:
+ * - /username (peanut user)
+ * - /0x1234... (address)
+ * - /vitalik.eth (ens)
+ * - /username/10/usdc/arbitrum (with amount/token/chain)
+ *
+ * supports cross-chain payments - user pays in usdc on arbitrum,
+ * recipient can receive on different chain/token
+ *
+ * note: token/chain selection uses tokenSelectorContext
+ */
+
+import { createContext, useContext, useState, useMemo, useCallback, type ReactNode } from 'react'
+import { type Address, type Hash } from 'viem'
+import { type TRequestChargeResponse, type PaymentCreationResponse } from '@/services/services.types'
+import { type ParsedURL, type RecipientType } from '@/lib/url-parser/types/payment'
+
+// view states for semantic request flow
+export type SemanticRequestFlowView = 'INITIAL' | 'CONFIRM' | 'STATUS' | 'RECEIPT' | 'EXTERNAL_WALLET'
+
+// recipient info from parsed url
+export interface SemanticRequestRecipient {
+ identifier: string
+ recipientType: RecipientType
+ resolvedAddress: Address
+}
+
+// attachment state
+interface SemanticRequestAttachment {
+ message?: string
+ file?: File
+ fileUrl?: string
+}
+
+// error state
+interface SemanticRequestError {
+ showError: boolean
+ errorMessage: string
+}
+
+// context value type
+interface SemanticRequestFlowContextValue {
+ // view state
+ currentView: SemanticRequestFlowView
+ setCurrentView: (view: SemanticRequestFlowView) => void
+
+ // parsed url data
+ parsedUrl: ParsedURL | null
+ setParsedUrl: (data: ParsedURL | null) => void
+
+ // recipient (from parsed url)
+ recipient: SemanticRequestRecipient | null
+
+ // charge id from url (for direct confirm view access)
+ chargeIdFromUrl: string | undefined
+
+ // amount state (can be preset from url or entered)
+ amount: string
+ setAmount: (amount: string) => void
+ usdAmount: string
+ setUsdAmount: (amount: string) => void
+ isAmountFromUrl: boolean
+ isTokenFromUrl: boolean
+ isChainFromUrl: boolean
+
+ // attachment state
+ attachment: SemanticRequestAttachment
+ setAttachment: (attachment: SemanticRequestAttachment) => void
+
+ // charge and payment results
+ charge: TRequestChargeResponse | null
+ setCharge: (charge: TRequestChargeResponse | null) => void
+ payment: PaymentCreationResponse | null
+ setPayment: (payment: PaymentCreationResponse | null) => void
+ txHash: Hash | null
+ setTxHash: (hash: Hash | null) => void
+
+ // ui state
+ error: SemanticRequestError
+ setError: (error: SemanticRequestError) => void
+ isLoading: boolean
+ setIsLoading: (loading: boolean) => void
+ isSuccess: boolean
+ setIsSuccess: (success: boolean) => void
+ isExternalWalletPayment: boolean
+ setIsExternalWalletPayment: (isExternalWalletPayment: boolean) => void
+
+ // actions
+ resetSemanticRequestFlow: () => void
+}
+
+const SemanticRequestFlowContext = createContext(null)
+
+interface SemanticRequestFlowProviderProps {
+ children: ReactNode
+ initialParsedUrl: ParsedURL
+ initialChargeId?: string
+}
+
+export function SemanticRequestFlowProvider({
+ children,
+ initialParsedUrl,
+ initialChargeId,
+}: SemanticRequestFlowProviderProps) {
+ // view state - determine initial view based on chargeId and recipient type
+ // for usernames with chargeId: start at INITIAL (direct request flow)
+ // for address/ens with chargeId: start at CONFIRM (semantic request payment)
+ const [currentView, setCurrentView] = useState(() => {
+ if (!initialChargeId) return 'INITIAL'
+ const isUsernameRecipient = initialParsedUrl.recipient?.recipientType === 'USERNAME'
+ return isUsernameRecipient ? 'INITIAL' : 'CONFIRM'
+ })
+
+ // store the initial charge id for fetching
+ const [chargeIdFromUrl] = useState(initialChargeId)
+
+ // parsed url data
+ const [parsedUrl, setParsedUrl] = useState(initialParsedUrl)
+
+ // track what came from url
+ const isAmountFromUrl = !!initialParsedUrl.amount
+ const isTokenFromUrl = !!initialParsedUrl.token
+ const isChainFromUrl = !!initialParsedUrl.chain
+
+ // amount state
+ const [amount, setAmount] = useState(initialParsedUrl.amount || '')
+ const [usdAmount, setUsdAmount] = useState(initialParsedUrl.amount || '')
+
+ // attachment state
+ const [attachment, setAttachment] = useState({})
+
+ // charge and payment results
+ const [charge, setCharge] = useState(null)
+ const [payment, setPayment] = useState(null)
+ const [txHash, setTxHash] = useState(null)
+
+ // ui state
+ const [error, setError] = useState({ showError: false, errorMessage: '' })
+ const [isLoading, setIsLoading] = useState(false)
+ const [isSuccess, setIsSuccess] = useState(false)
+ const [isExternalWalletPayment, setIsExternalWalletPayment] = useState(false)
+
+ // derive recipient from parsed url
+ const recipient = useMemo(() => {
+ if (!parsedUrl?.recipient) return null
+ return {
+ identifier: parsedUrl.recipient.identifier,
+ recipientType: parsedUrl.recipient.recipientType,
+ resolvedAddress: parsedUrl.recipient.resolvedAddress as Address,
+ }
+ }, [parsedUrl])
+
+ // reset flow
+ const resetSemanticRequestFlow = useCallback(() => {
+ setCurrentView('INITIAL')
+ setAmount(initialParsedUrl.amount || '')
+ setUsdAmount(initialParsedUrl.amount || '')
+ setAttachment({})
+ setCharge(null)
+ setPayment(null)
+ setTxHash(null)
+ setError({ showError: false, errorMessage: '' })
+ setIsLoading(false)
+ setIsSuccess(false)
+ setIsExternalWalletPayment(false)
+ }, [initialParsedUrl.amount])
+
+ const value = useMemo(
+ () => ({
+ currentView,
+ setCurrentView,
+ parsedUrl,
+ setParsedUrl,
+ recipient,
+ chargeIdFromUrl,
+ amount,
+ setAmount,
+ usdAmount,
+ setUsdAmount,
+ isAmountFromUrl,
+ isTokenFromUrl,
+ isChainFromUrl,
+ attachment,
+ setAttachment,
+ charge,
+ setCharge,
+ payment,
+ setPayment,
+ txHash,
+ setTxHash,
+ error,
+ setError,
+ isLoading,
+ setIsLoading,
+ isSuccess,
+ setIsSuccess,
+ resetSemanticRequestFlow,
+ isExternalWalletPayment,
+ setIsExternalWalletPayment,
+ }),
+ [
+ currentView,
+ parsedUrl,
+ recipient,
+ chargeIdFromUrl,
+ amount,
+ usdAmount,
+ isAmountFromUrl,
+ isTokenFromUrl,
+ isChainFromUrl,
+ attachment,
+ charge,
+ payment,
+ txHash,
+ error,
+ isLoading,
+ isSuccess,
+ resetSemanticRequestFlow,
+ isExternalWalletPayment,
+ ]
+ )
+
+ return {children}
+}
+
+export function useSemanticRequestFlowContext() {
+ const context = useContext(SemanticRequestFlowContext)
+ if (!context) {
+ throw new Error('useSemanticRequestFlowContext must be used within SemanticRequestFlowProvider')
+ }
+ return context
+}
diff --git a/src/features/payments/flows/semantic-request/SemanticRequestPage.tsx b/src/features/payments/flows/semantic-request/SemanticRequestPage.tsx
new file mode 100644
index 000000000..af8ab5bc7
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/SemanticRequestPage.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+/**
+ * main entry point for semantic request flow
+ *
+ * wraps content with context provider and renders the correct view:
+ * - INITIAL: amount/token input
+ * - CONFIRM: review cross-chain payment details
+ * - STATUS: success view after payment
+ * - RECEIPT: shows receipt for already-paid charges
+ *
+ * receives pre-parsed url data from wrapper
+ */
+
+import { SemanticRequestFlowProvider, useSemanticRequestFlowContext } from './SemanticRequestFlowContext'
+import { SemanticRequestInputView } from './views/SemanticRequestInputView'
+import { SemanticRequestConfirmView } from './views/SemanticRequestConfirmView'
+import { SemanticRequestSuccessView } from './views/SemanticRequestSuccessView'
+import { SemanticRequestReceiptView } from './views/SemanticRequestReceiptView'
+import { type ParsedURL } from '@/lib/url-parser/types/payment'
+import SemanticRequestExternalWalletView from './views/SemanticRequestExternalWalletView'
+
+// internal component that switches views
+function SemanticRequestFlowContent() {
+ const { currentView } = useSemanticRequestFlowContext()
+
+ switch (currentView) {
+ case 'CONFIRM':
+ return
+ case 'STATUS':
+ return
+ case 'RECEIPT':
+ return
+ case 'EXTERNAL_WALLET':
+ return
+ case 'INITIAL':
+ default:
+ return
+ }
+}
+
+// props for the page
+interface SemanticRequestPageProps {
+ parsedUrl: ParsedURL
+ initialChargeId?: string
+}
+
+// exported page component with provider
+export function SemanticRequestPage({ parsedUrl, initialChargeId }: SemanticRequestPageProps) {
+ return (
+
+
+
+ )
+}
diff --git a/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx b/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx
new file mode 100644
index 000000000..09ac39904
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/SemanticRequestPageWrapper.tsx
@@ -0,0 +1,94 @@
+'use client'
+
+/**
+ * wrapper component for SemanticRequestPage
+ *
+ * handles async url parsing before rendering the actual flow.
+ * parses semantic urls like /username, /0x1234..., /vitalik.eth
+ * also supports amount/token/chain in url path
+ *
+ * shows loading/error states while parsing
+ *
+ * used by: /[...recipient] route for address/ens/username payments
+ */
+
+import { SemanticRequestPage } from './SemanticRequestPage'
+import { parsePaymentURL, type ParseUrlError } from '@/lib/url-parser/parser'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import NavHeader from '@/components/Global/NavHeader'
+import { useRouter, useSearchParams } from 'next/navigation'
+import { useEffect, useState } from 'react'
+import { type ParsedURL } from '@/lib/url-parser/types/payment'
+import { formatAmount } from '@/utils/general.utils'
+
+interface SemanticRequestPageWrapperProps {
+ recipient: string[]
+}
+
+export function SemanticRequestPageWrapper({ recipient }: SemanticRequestPageWrapperProps) {
+ const router = useRouter()
+ const searchParams = useSearchParams()
+ const chargeIdFromUrl = searchParams.get('chargeId')
+
+ const [parsedUrl, setParsedUrl] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // parse the url segments
+ useEffect(() => {
+ if (!recipient || recipient.length === 0) {
+ setError({ message: 'Invalid URL format' } as ParseUrlError)
+ setIsLoading(false)
+ return
+ }
+
+ setIsLoading(true)
+ setError(null)
+
+ parsePaymentURL(recipient)
+ .then((result) => {
+ if (result.error) {
+ setError(result.error)
+ } else if (result.parsedUrl) {
+ // format amount if present
+ const formatted = {
+ ...result.parsedUrl,
+ amount: result.parsedUrl.amount ? formatAmount(result.parsedUrl.amount) : undefined,
+ }
+ setParsedUrl(formatted)
+ }
+ })
+ .catch((err) => {
+ console.error('failed to parse url:', err)
+ setError({ message: 'Invalid URL format' } as ParseUrlError)
+ })
+ .finally(() => {
+ setIsLoading(false)
+ })
+ }, [recipient])
+
+ // loading state
+ if (isLoading) {
+ return (
+
+ )
+ }
+
+ // error state
+ if (error || !parsedUrl) {
+ return (
+
+ router.back()} />
+
+
+ )
+ }
+
+ return
+}
diff --git a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
new file mode 100644
index 000000000..aebfdbad8
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts
@@ -0,0 +1,606 @@
+'use client'
+
+/**
+ * hook for semantic request flow
+ *
+ * handles the full payment lifecycle for semantic url payments:
+ * 1. creates a charge with recipient/amount details
+ * 2. for same-chain usdc: sends directly via peanut wallet
+ * 3. for cross-chain/different token: calculates route, shows confirm view
+ * 4. executes swap/bridge transaction
+ * 5. records payment to backend
+ *
+ * supports route expiry handling - auto-refreshes routes when expired
+ */
+
+import { useCallback, useMemo, useEffect, useContext, useState } from 'react'
+import { type Address, type Hash } from 'viem'
+import { useSemanticRequestFlowContext } from './SemanticRequestFlowContext'
+import { useChargeManager } from '@/features/payments/shared/hooks/useChargeManager'
+import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder'
+import { useRouteCalculation } from '@/features/payments/shared/hooks/useRouteCalculation'
+import { useWallet } from '@/hooks/wallet/useWallet'
+import { useAuth } from '@/context/authContext'
+import { tokenSelectorContext } from '@/context'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
+import { ErrorHandler } from '@/utils/sdkErrorHandler.utils'
+import { areEvmAddressesEqual } from '@/utils/general.utils'
+import { useQueryClient } from '@tanstack/react-query'
+import { TRANSACTIONS } from '@/constants/query.consts'
+
+export function useSemanticRequestFlow() {
+ const {
+ amount,
+ setAmount,
+ usdAmount,
+ setUsdAmount,
+ currentView,
+ setCurrentView,
+ parsedUrl,
+ recipient,
+ chargeIdFromUrl,
+ isAmountFromUrl,
+ isTokenFromUrl,
+ isChainFromUrl,
+ attachment,
+ setAttachment,
+ charge,
+ setCharge,
+ payment,
+ setPayment,
+ txHash,
+ setTxHash,
+ error,
+ setError,
+ isLoading,
+ setIsLoading,
+ isSuccess,
+ setIsSuccess,
+ resetSemanticRequestFlow,
+ isExternalWalletPayment,
+ setIsExternalWalletPayment,
+ } = useSemanticRequestFlowContext()
+
+ const { user } = useAuth()
+ const queryClient = useQueryClient()
+ const { createCharge, fetchCharge, isCreating: isCreatingCharge, isFetching: isFetchingCharge } = useChargeManager()
+ const { recordPayment, isRecording } = usePaymentRecorder()
+ const {
+ route: calculatedRoute,
+ transactions: routeTransactions,
+ estimatedGasCostUsd: calculatedGasCost,
+ calculateRoute,
+ isCalculating: isCalculatingRoute,
+ isFeeEstimationError,
+ error: routeError,
+ reset: resetRoute,
+ } = useRouteCalculation()
+ const {
+ isConnected,
+ address: walletAddress,
+ sendMoney,
+ sendTransactions,
+ formattedBalance,
+ hasSufficientBalance,
+ } = useWallet()
+
+ // use token selector context for ui integration
+ const { selectedChainID, selectedTokenAddress, selectedTokenData, setSelectedChainID, setSelectedTokenAddress } =
+ useContext(tokenSelectorContext)
+
+ // route expiry state
+ const [isRouteExpired, setIsRouteExpired] = useState(false)
+
+ const isLoggedIn = !!user?.user?.userId
+
+ // set amount (for peanut wallet, amount is always in usd)
+ const handleSetAmount = useCallback(
+ (value: string) => {
+ setAmount(value)
+ setUsdAmount(value)
+ },
+ [setAmount, setUsdAmount]
+ )
+
+ // clear error
+ const clearError = useCallback(() => {
+ setError({ showError: false, errorMessage: '' })
+ }, [setError])
+
+ // check if payment is to peanut wallet token on peanut wallet chain (USDC on Arbitrum)
+ const isSameChainSameToken = useMemo(() => {
+ if (!selectedChainID || !selectedTokenAddress) return false
+ return (
+ selectedChainID === PEANUT_WALLET_CHAIN.id.toString() &&
+ areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)
+ )
+ }, [selectedChainID, selectedTokenAddress])
+
+ // check if this is cross-chain or different token
+ const isXChain = useMemo(() => {
+ if (!selectedChainID) return false
+ return selectedChainID !== PEANUT_WALLET_CHAIN.id.toString()
+ }, [selectedChainID])
+
+ const isDiffToken = useMemo(() => {
+ if (!selectedTokenAddress) return false
+ return !areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)
+ }, [selectedTokenAddress])
+
+ // check if needs route (cross-chain or different token)
+ const needsRoute = isXChain || isDiffToken
+
+ // check if can proceed to confirm/payment
+ const canProceed = useMemo(() => {
+ if (!amount || !recipient) return false
+ const amountNum = parseFloat(amount)
+ if (isNaN(amountNum) || amountNum <= 0) return false
+ if (!selectedTokenAddress || !selectedChainID) return false
+ return true
+ }, [amount, recipient, selectedTokenAddress, selectedChainID])
+
+ // check if has sufficient balance for current amount
+ const hasEnoughBalance = useMemo(() => {
+ if (!amount) return false
+ return hasSufficientBalance(amount)
+ }, [amount, hasSufficientBalance])
+
+ // check if should show insufficient balance error
+ const isInsufficientBalance = useMemo(() => {
+ return (
+ isLoggedIn &&
+ !!amount &&
+ !hasEnoughBalance &&
+ !isLoading &&
+ !isCreatingCharge &&
+ !isFetchingCharge &&
+ !isRecording &&
+ !isCalculatingRoute
+ )
+ }, [
+ isLoggedIn,
+ amount,
+ hasEnoughBalance,
+ isLoading,
+ isCreatingCharge,
+ isFetchingCharge,
+ isRecording,
+ isCalculatingRoute,
+ ])
+
+ // validate username recipient can only receive on arbitrum
+ const validateUsernameRecipient = useCallback((): string | null => {
+ if (recipient?.recipientType === 'USERNAME') {
+ // for username recipients, only arbitrum is allowed
+ if (selectedChainID && selectedChainID !== PEANUT_WALLET_CHAIN.id.toString()) {
+ return 'Payments to Peanut usernames can only be made on Arbitrum'
+ }
+ }
+ return null
+ }, [recipient?.recipientType, selectedChainID])
+
+ // update url with chargeId (shallow update - no re-render)
+ const updateUrlWithChargeId = useCallback((chargeId: string) => {
+ const currentUrl = new URL(window.location.href)
+ if (currentUrl.searchParams.get('chargeId') !== chargeId) {
+ currentUrl.searchParams.set('chargeId', chargeId)
+ window.history.replaceState({}, '', currentUrl.pathname + currentUrl.search)
+ }
+ }, [])
+
+ // remove chargeId from url (shallow update - no re-render)
+ const removeChargeIdFromUrl = useCallback(() => {
+ const currentUrl = new URL(window.location.href)
+ if (currentUrl.searchParams.has('chargeId')) {
+ currentUrl.searchParams.delete('chargeId')
+ window.history.replaceState({}, '', currentUrl.pathname + currentUrl.search)
+ }
+ }, [])
+
+ // handle payment button click - decides whether to skip confirm or not
+ // - if logged in + peanut wallet + same chain/token โ create charge and pay directly
+ // - if logged in + peanut wallet + cross-chain/diff token โ go to confirm view
+ // - if not logged in โ action list handles it
+ const handlePayment = useCallback(
+ async (
+ shouldReturnAfterCreatingCharge: boolean = false,
+ bypassLoginCheck: boolean = false
+ ): Promise<{ success: boolean }> => {
+ if (!recipient || !amount || !selectedTokenAddress || !selectedChainID || !selectedTokenData) {
+ setError({ showError: true, errorMessage: 'missing required data' })
+ return { success: false }
+ }
+
+ // validate username recipient
+ const validationError = validateUsernameRecipient()
+ if (validationError) {
+ setError({ showError: true, errorMessage: validationError })
+ return { success: false }
+ }
+
+ // if not logged in, don't proceed (action list handles this)
+ if (!bypassLoginCheck && (!isLoggedIn || !walletAddress)) {
+ setError({ showError: true, errorMessage: 'please log in to continue' })
+ return { success: false }
+ }
+
+ setIsLoading(true)
+ clearError()
+
+ try {
+ // step 1: use existing charge if available (from url), otherwise create new one
+ let chargeResult = charge // use existing charge if loaded from chargeIdFromUrl
+
+ if (!chargeResult) {
+ // only create new charge if we don't have one already
+ chargeResult = await createCharge({
+ tokenAmount: amount,
+ tokenAddress: selectedTokenAddress as Address,
+ chainId: selectedChainID,
+ tokenSymbol: selectedTokenData.symbol,
+ tokenDecimals: selectedTokenData.decimals,
+ recipientAddress: recipient.resolvedAddress,
+ transactionType: 'REQUEST',
+ reference: attachment.message,
+ attachment: attachment.file,
+ currencyAmount: usdAmount,
+ currencyCode: 'USD',
+ })
+ setCharge(chargeResult)
+ }
+
+ if (shouldReturnAfterCreatingCharge) {
+ setIsLoading(false)
+ return { success: true }
+ }
+
+ // step 2: decide flow based on token/chain
+ // if same chain and same token (USDC on Arb) โ pay directly (skip confirm)
+ // if cross-chain or different token โ go to confirm view
+ if (isSameChainSameToken) {
+ // direct payment - same as old flow when isPeanutWallet && same token/chain
+ const txResult = await sendMoney(recipient.resolvedAddress, amount)
+ const hash = (txResult.receipt?.transactionHash ?? txResult.userOpHash) as Hash
+ setTxHash(hash)
+
+ // record payment
+ const paymentResult = await recordPayment({
+ chargeId: chargeResult.uuid,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ txHash: hash,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ payerAddress: walletAddress as Address,
+ })
+
+ setPayment(paymentResult)
+ setIsSuccess(true)
+ setCurrentView('STATUS')
+
+ // refetch history and balance to immediately show updated status
+ // invalidate first to mark as stale, then refetch to force immediate update
+ queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
+ queryClient.refetchQueries({
+ queryKey: [TRANSACTIONS],
+ type: 'active', // force refetch even if data is fresh
+ })
+ queryClient.invalidateQueries({ queryKey: ['balance'] })
+ } else {
+ // cross-chain or different token โ go to confirm view
+ // update url with chargeId
+ updateUrlWithChargeId(chargeResult.uuid)
+ setCurrentView('CONFIRM')
+ }
+ setIsLoading(false)
+ return { success: true }
+ } catch (err) {
+ const errorMessage = ErrorHandler(err)
+ setError({ showError: true, errorMessage })
+ setIsLoading(false)
+ return { success: false }
+ }
+ },
+ [
+ recipient,
+ amount,
+ usdAmount,
+ attachment,
+ walletAddress,
+ selectedTokenAddress,
+ selectedChainID,
+ selectedTokenData,
+ charge,
+ isLoggedIn,
+ isSameChainSameToken,
+ validateUsernameRecipient,
+ createCharge,
+ sendMoney,
+ recordPayment,
+ queryClient,
+ updateUrlWithChargeId,
+ setCharge,
+ setTxHash,
+ setPayment,
+ setIsSuccess,
+ setCurrentView,
+ setError,
+ setIsLoading,
+ clearError,
+ ]
+ )
+
+ // prepare route when entering confirm view
+ const prepareRoute = useCallback(async () => {
+ if (!charge || !walletAddress || !selectedTokenData || !selectedChainID) return
+
+ setIsRouteExpired(false)
+
+ // check if charge is for same chain and same token (no route needed)
+ const isChargeSameChainToken =
+ charge.chainId === PEANUT_WALLET_CHAIN.id.toString() &&
+ areEvmAddressesEqual(charge.tokenAddress, PEANUT_WALLET_TOKEN)
+
+ // only calculate route if cross-chain or different token
+ if (needsRoute && !isChargeSameChainToken) {
+ await calculateRoute({
+ source: {
+ address: walletAddress as Address,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ },
+ destination: {
+ recipientAddress: charge.requestLink.recipientAddress as Address,
+ tokenAddress: charge.tokenAddress as Address,
+ tokenAmount: charge.tokenAmount,
+ tokenDecimals: charge.tokenDecimals,
+ tokenType: 1, // ERC20
+ chainId: charge.chainId,
+ },
+ usdAmount: usdAmount || amount,
+ })
+ }
+ }, [charge, walletAddress, selectedTokenData, selectedChainID, needsRoute, calculateRoute, usdAmount, amount])
+
+ // fetch charge from url if chargeIdFromUrl is present but charge is not loaded
+ useEffect(() => {
+ if (
+ chargeIdFromUrl &&
+ !charge &&
+ (currentView === 'INITIAL' || currentView === 'CONFIRM' || currentView === 'RECEIPT') &&
+ !isFetchingCharge
+ ) {
+ fetchCharge(chargeIdFromUrl)
+ .then((fetchedCharge) => {
+ setCharge(fetchedCharge)
+
+ // check if charge is already paid - if so, switch to receipt view
+ const isPaid = fetchedCharge.fulfillmentPayment?.status === 'SUCCESSFUL'
+ if (isPaid && (currentView === 'CONFIRM' || currentView === 'INITIAL')) {
+ setCurrentView('RECEIPT')
+ return
+ }
+
+ // set amount from charge if not already set
+ if (!amount && fetchedCharge.tokenAmount) {
+ setAmount(fetchedCharge.tokenAmount)
+ setUsdAmount(fetchedCharge.currencyAmount || fetchedCharge.tokenAmount)
+ }
+ // set token/chain from charge for token selector context
+ if (fetchedCharge.chainId) {
+ setSelectedChainID(fetchedCharge.chainId)
+ }
+ if (fetchedCharge.tokenAddress) {
+ setSelectedTokenAddress(fetchedCharge.tokenAddress)
+ }
+ })
+ .catch((err) => {
+ console.error('failed to fetch charge:', err)
+ setError({ showError: true, errorMessage: 'failed to load payment details' })
+ })
+ }
+ }, [
+ chargeIdFromUrl,
+ charge,
+ currentView,
+ isFetchingCharge,
+ fetchCharge,
+ setCharge,
+ amount,
+ setAmount,
+ setUsdAmount,
+ setError,
+ setSelectedChainID,
+ setSelectedTokenAddress,
+ setCurrentView,
+ ])
+
+ // call prepareRoute when entering confirm view and charge is ready
+ useEffect(() => {
+ if (currentView === 'CONFIRM' && charge) {
+ prepareRoute()
+ }
+ }, [currentView, charge, prepareRoute])
+
+ // handle route expiry - sets state, useEffect will trigger refetch
+ const handleRouteExpired = useCallback(() => {
+ setIsRouteExpired(true)
+ }, [])
+
+ // auto-refetch route when expired
+ useEffect(() => {
+ if (isRouteExpired && currentView === 'CONFIRM' && !isLoading && !isCalculatingRoute) {
+ prepareRoute()
+ }
+ }, [isRouteExpired, currentView, isLoading, isCalculatingRoute, prepareRoute])
+
+ // handle route near expiry - refetch immediately
+ const handleRouteNearExpiry = useCallback(() => {
+ if (!isLoading && !isCalculatingRoute) {
+ prepareRoute()
+ }
+ }, [isLoading, isCalculatingRoute, prepareRoute])
+
+ // execute payment from confirm view (handles both same-chain and cross-chain)
+ const executePayment = useCallback(async () => {
+ if (!recipient || !amount || !walletAddress || !charge) {
+ setError({ showError: true, errorMessage: 'missing required data' })
+ return
+ }
+
+ setIsLoading(true)
+ clearError()
+
+ try {
+ let hash: Hash
+
+ // check if charge is for same chain and same token (usdc on arbitrum)
+ const isChargeSameChainToken =
+ charge.chainId === PEANUT_WALLET_CHAIN.id.toString() &&
+ areEvmAddressesEqual(charge.tokenAddress, PEANUT_WALLET_TOKEN)
+
+ if (isChargeSameChainToken) {
+ // direct payment for same-chain same-token (e.g. direct requests)
+ const txResult = await sendMoney(charge.requestLink.recipientAddress as Address, charge.tokenAmount)
+ hash = (txResult.receipt?.transactionHash ?? txResult.userOpHash) as Hash
+ } else if (needsRoute && routeTransactions && routeTransactions.length > 0) {
+ // cross-chain or token swap payment via squid route
+ const txResult = await sendTransactions(
+ routeTransactions.map((tx) => ({
+ to: tx.to,
+ data: tx.data,
+ value: tx.value,
+ }))
+ )
+ hash = (txResult.receipt?.transactionHash ?? txResult.userOpHash) as Hash
+ } else {
+ throw new Error('route not ready for cross-chain payment')
+ }
+
+ setTxHash(hash)
+
+ // record payment to backend
+ const paymentResult = await recordPayment({
+ chargeId: charge.uuid,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ txHash: hash,
+ tokenAddress: PEANUT_WALLET_TOKEN as Address,
+ payerAddress: walletAddress as Address,
+ sourceChainId: selectedChainID || undefined,
+ sourceTokenAddress: selectedTokenAddress || undefined,
+ sourceTokenSymbol: selectedTokenData?.symbol,
+ })
+
+ setPayment(paymentResult)
+ setIsSuccess(true)
+ setCurrentView('STATUS')
+
+ // refetch history and balance to immediately show updated status
+ // invalidate first to mark as stale, then refetch to force immediate update
+ queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
+ queryClient.refetchQueries({
+ queryKey: [TRANSACTIONS],
+ type: 'active', // force refetch even if data is fresh
+ })
+ queryClient.invalidateQueries({ queryKey: ['balance'] })
+ } catch (err) {
+ const errorMessage = ErrorHandler(err)
+ setError({ showError: true, errorMessage })
+ } finally {
+ setIsLoading(false)
+ }
+ }, [
+ recipient,
+ amount,
+ walletAddress,
+ charge,
+ needsRoute,
+ routeTransactions,
+ selectedChainID,
+ selectedTokenAddress,
+ selectedTokenData,
+ sendMoney,
+ sendTransactions,
+ recordPayment,
+ queryClient,
+ setTxHash,
+ setPayment,
+ setIsSuccess,
+ setCurrentView,
+ setError,
+ setIsLoading,
+ clearError,
+ ])
+
+ // go back from confirm to initial
+ const goBackToInitial = useCallback(() => {
+ setCurrentView('INITIAL')
+ setCharge(null)
+ resetRoute()
+ setIsRouteExpired(false)
+ removeChargeIdFromUrl()
+ }, [setCurrentView, setCharge, resetRoute, removeChargeIdFromUrl])
+
+ return {
+ // state
+ amount,
+ usdAmount,
+ currentView,
+ parsedUrl,
+ recipient,
+ chargeIdFromUrl,
+ isAmountFromUrl,
+ isTokenFromUrl,
+ isChainFromUrl,
+ attachment,
+ charge,
+ payment,
+ txHash,
+ error,
+ isLoading: isLoading || isCreatingCharge || isFetchingCharge || isRecording || isCalculatingRoute,
+ isSuccess,
+ isFetchingCharge,
+ isExternalWalletPayment,
+
+ // route calculation state (for confirm view)
+ calculatedRoute,
+ routeTransactions,
+ calculatedGasCost,
+ isCalculatingRoute,
+ isFeeEstimationError,
+ routeError,
+ isRouteExpired,
+
+ // computed
+ canProceed,
+ hasSufficientBalance: hasEnoughBalance,
+ isInsufficientBalance,
+ isConnected,
+ isLoggedIn,
+ walletAddress,
+ formattedBalance,
+ isXChain,
+ isDiffToken,
+ needsRoute,
+ isSameChainSameToken,
+
+ // token selector (from context for ui)
+ selectedChainID,
+ selectedTokenAddress,
+ selectedTokenData,
+ setSelectedChainID,
+ setSelectedTokenAddress,
+
+ // actions
+ setAmount: handleSetAmount,
+ setAttachment,
+ clearError,
+ handlePayment,
+ prepareRoute,
+ executePayment,
+ goBackToInitial,
+ resetSemanticRequestFlow,
+ setCurrentView,
+ handleRouteExpired,
+ handleRouteNearExpiry,
+ setIsExternalWalletPayment,
+ }
+}
diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx
new file mode 100644
index 000000000..b9bac7545
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx
@@ -0,0 +1,311 @@
+'use client'
+
+/**
+ * confirm view for semantic request flow (cross-chain payments only)
+ *
+ * displays:
+ * - recipient and amount being sent
+ * - min received after slippage
+ * - source token (usdc on arb) โ destination token
+ * - network fees (usually sponsored by peanut)
+ * - countdown timer for rfq routes (auto-refreshes before expiry)
+ *
+ * handles route expiry - auto-fetches new quote when current expires
+ */
+
+import { Button } from '@/components/0_Bruddle/Button'
+import Card from '@/components/Global/Card'
+import NavHeader from '@/components/Global/NavHeader'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
+import DisplayIcon from '@/components/Global/DisplayIcon'
+import { useSemanticRequestFlow } from '../useSemanticRequestFlow'
+import { formatAmount, isStableCoin } from '@/utils/general.utils'
+import { useTokenChainIcons } from '@/hooks/useTokenChainIcons'
+import { useMemo } from 'react'
+import { formatUnits } from 'viem'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants/zerodev.consts'
+import PeanutActionDetailsCard, {
+ type PeanutActionDetailsCardRecipientType,
+} from '@/components/Global/PeanutActionDetailsCard'
+
+export function SemanticRequestConfirmView() {
+ const {
+ amount,
+ usdAmount,
+ recipient,
+ charge,
+ attachment,
+ error,
+ calculatedRoute,
+ calculatedGasCost,
+ isCalculatingRoute,
+ isFeeEstimationError,
+ routeError,
+ isXChain,
+ isDiffToken,
+ isLoading,
+ isFetchingCharge,
+ selectedChainID,
+ selectedTokenData,
+ goBackToInitial,
+ executePayment,
+ prepareRoute,
+ handleRouteExpired,
+ handleRouteNearExpiry,
+ } = useSemanticRequestFlow()
+
+ // icons for sending token (peanut wallet usdc)
+ const {
+ tokenIconUrl: sendingTokenIconUrl,
+ chainIconUrl: sendingChainIconUrl,
+ resolvedChainName: sendingResolvedChainName,
+ resolvedTokenSymbol: sendingResolvedTokenSymbol,
+ } = useTokenChainIcons({
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
+ tokenAddress: PEANUT_WALLET_TOKEN,
+ tokenSymbol: PEANUT_WALLET_TOKEN_SYMBOL,
+ })
+
+ // icons for requested/destination token
+ const {
+ tokenIconUrl: requestedTokenIconUrl,
+ chainIconUrl: requestedChainIconUrl,
+ resolvedChainName: requestedResolvedChainName,
+ resolvedTokenSymbol: requestedResolvedTokenSymbol,
+ } = useTokenChainIcons({
+ chainId: charge?.chainId,
+ tokenAddress: charge?.tokenAddress,
+ tokenSymbol: charge?.tokenSymbol,
+ })
+
+ // is cross-chain or different token
+ const isCrossChainPayment = isXChain || isDiffToken
+
+ // format display values
+ const displayAmount = useMemo(() => {
+ return `${formatAmount(usdAmount || amount)}`
+ }, [amount, usdAmount])
+
+ // get network fee display
+ const networkFee = useMemo(() => {
+ if (isFeeEstimationError) return '-'
+ if (calculatedGasCost === undefined) {
+ return 'Sponsored by Peanut!'
+ }
+ if (calculatedGasCost < 0.01) {
+ return 'Sponsored by Peanut!'
+ }
+ return (
+ <>
+ $ {calculatedGasCost.toFixed(2)}
+ {' - '}
+ Sponsored by Peanut!
+ >
+ )
+ }, [calculatedGasCost, isFeeEstimationError])
+
+ // min received amount
+ const minReceived = useMemo(() => {
+ if (!charge?.tokenDecimals || !requestedResolvedTokenSymbol) return null
+ if (!calculatedRoute) {
+ return `$ ${charge?.tokenAmount}`
+ }
+ const amount = formatAmount(
+ formatUnits(BigInt(calculatedRoute.rawResponse.route.estimate.toAmountMin), charge.tokenDecimals)
+ )
+ return isStableCoin(requestedResolvedTokenSymbol) ? `$ ${amount}` : `${amount} ${requestedResolvedTokenSymbol}`
+ }, [calculatedRoute, charge?.tokenDecimals, charge?.tokenAmount, requestedResolvedTokenSymbol])
+
+ // error message (route expiry auto-retries)
+ const errorMessage = useMemo(() => {
+ if (routeError) return routeError
+ if (error.showError) return error.errorMessage
+ return null
+ }, [routeError, error])
+
+ // handle confirm
+ const handleConfirm = () => {
+ if (!isLoading && !isCalculatingRoute) {
+ executePayment()
+ }
+ }
+
+ // handle retry
+ const handleRetry = async () => {
+ if (errorMessage) {
+ // retry route calculation
+ await prepareRoute()
+ } else {
+ await executePayment()
+ }
+ }
+
+ // show loading if we don't have charge details yet or fetching
+ if (!charge || isFetchingCharge) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+ {recipient && recipient.recipientType && (
+
+ )}
+ {/* payment details card */}
+
+
+
+ {isCrossChainPayment && (
+
+ }
+ />
+ )}
+
+
+ }
+ />
+
+
+
+
+
+
+ {/* buttons and error */}
+
+ {errorMessage ? (
+
+ Retry
+
+ ) : (
+
+ Send
+
+ )}
+ {errorMessage && (
+
+
+
+ )}
+
+
+
+ )
+}
+
+// helper component for token/chain display
+interface TokenChainInfoDisplayProps {
+ tokenIconUrl?: string
+ chainIconUrl?: string
+ resolvedTokenSymbol?: string
+ fallbackTokenSymbol: string
+ resolvedChainName?: string
+ fallbackChainName: string
+}
+
+function TokenChainInfoDisplay({
+ tokenIconUrl,
+ chainIconUrl,
+ resolvedTokenSymbol,
+ fallbackTokenSymbol,
+ resolvedChainName,
+ fallbackChainName,
+}: TokenChainInfoDisplayProps) {
+ const tokenSymbol = resolvedTokenSymbol || fallbackTokenSymbol
+ const chainName = resolvedChainName || fallbackChainName
+
+ return (
+
+ {(tokenIconUrl || chainIconUrl) && (
+
+ {tokenIconUrl && (
+
+ )}
+ {chainIconUrl && (
+
+
+
+ )}
+
+ )}
+
+ {tokenSymbol} on {chainName}
+
+
+ )
+}
diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestExternalWalletView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestExternalWalletView.tsx
new file mode 100644
index 000000000..526126b0f
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/views/SemanticRequestExternalWalletView.tsx
@@ -0,0 +1,51 @@
+import { useCallback, useState } from 'react'
+import { useSemanticRequestFlow } from '../useSemanticRequestFlow'
+import { useWallet } from '@/hooks/wallet/useWallet'
+import { useQuery } from '@tanstack/react-query'
+import { rhinoApi } from '@/services/rhino'
+import RhinoDepositView from '@/components/AddMoney/views/RhinoDeposit.view'
+import type { RhinoChainType } from '@/services/services.types'
+
+const SemanticRequestExternalWalletView = () => {
+ const { charge, setCurrentView, setIsExternalWalletPayment, amount } = useSemanticRequestFlow()
+ const [chainType, setChainType] = useState('EVM')
+ const { address: peanutWalletAddress } = useWallet()
+
+ const { data: depositAddressData, isLoading } = useQuery({
+ queryKey: ['rhino-deposit-address', charge?.uuid, chainType],
+ queryFn: () => {
+ if (!charge?.uuid) {
+ throw new Error('Charge ID is required')
+ }
+ return rhinoApi.createRequestFulfilmentAddress(chainType, charge?.uuid as string, peanutWalletAddress)
+ },
+ enabled: !!charge,
+ staleTime: 1000 * 60 * 60 * 24, // 24 hours
+ })
+
+ const onSuccess = useCallback((_: number) => {
+ setIsExternalWalletPayment(true)
+ setCurrentView('STATUS')
+ }, [])
+
+ return (
+ setCurrentView('INITIAL')}
+ showUserCard
+ amount={Number(amount)}
+ identifier={
+ charge?.requestLink.recipientAccount.type === 'peanut-wallet'
+ ? charge?.requestLink.recipientAccount.user.username
+ : charge?.requestLink.recipientAccount.identifier
+ }
+ />
+ )
+}
+
+export default SemanticRequestExternalWalletView
diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx
new file mode 100644
index 000000000..f24646be7
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/views/SemanticRequestInputView.tsx
@@ -0,0 +1,207 @@
+'use client'
+
+/**
+ * input view for semantic request flow
+ *
+ * displays:
+ * - recipient card (address/ens/username)
+ * - amount input
+ * - token selector (for address/ens recipients, not usernames)
+ * - payment method options
+ *
+ * for same-chain usdc: executes payment directly
+ * for cross-chain: navigates to confirm view
+ */
+
+import { useEffect, useContext } from 'react'
+import NavHeader from '@/components/Global/NavHeader'
+import AmountInput from '@/components/Global/AmountInput'
+import UserCard from '@/components/User/UserCard'
+import ErrorAlert from '@/components/Global/ErrorAlert'
+import SupportCTA from '@/components/Global/SupportCTA'
+import TokenSelector from '@/components/Global/TokenSelector/TokenSelector'
+import { useSemanticRequestFlow } from '../useSemanticRequestFlow'
+import { useRouter } from 'next/navigation'
+import SendWithPeanutCta from '@/features/payments/shared/components/SendWithPeanutCta'
+import { PaymentMethodActionList } from '@/features/payments/shared/components/PaymentMethodActionList'
+import { printableAddress, areEvmAddressesEqual } from '@/utils/general.utils'
+import { tokenSelectorContext } from '@/context'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
+
+export function SemanticRequestInputView() {
+ const router = useRouter()
+ const {
+ amount,
+ recipient,
+ parsedUrl,
+ chargeIdFromUrl,
+ isAmountFromUrl,
+ error,
+ formattedBalance,
+ canProceed,
+ isInsufficientBalance,
+ isLoading,
+ isLoggedIn,
+ isConnected,
+ setAmount,
+ handlePayment,
+ setCurrentView,
+ } = useSemanticRequestFlow()
+
+ // token selector context for setting initial values from url
+ const {
+ setSelectedChainID,
+ setSelectedTokenAddress,
+ selectedChainID,
+ selectedTokenAddress,
+ supportedSquidChainsAndTokens,
+ selectedTokenData,
+ } = useContext(tokenSelectorContext)
+
+ // initialize token/chain from parsed url
+ useEffect(() => {
+ if (!parsedUrl) return
+
+ // set chain from url if available
+ if (parsedUrl.chain?.chainId) {
+ setSelectedChainID(parsedUrl.chain.chainId)
+ } else {
+ // default to arbitrum for external recipients
+ setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString())
+ }
+
+ // set token from url if available
+ if (parsedUrl.token?.address) {
+ setSelectedTokenAddress(parsedUrl.token.address)
+ } else if (parsedUrl.chain?.chainId) {
+ // default to usdc on the selected chain
+ const chainData = supportedSquidChainsAndTokens[parsedUrl.chain.chainId]
+ const defaultToken = chainData?.tokens.find((t) => t.symbol.toLowerCase() === 'usdc')
+ if (defaultToken) {
+ setSelectedTokenAddress(defaultToken.address)
+ }
+ } else {
+ // default to peanut wallet usdc
+ setSelectedTokenAddress(PEANUT_WALLET_TOKEN)
+ }
+ }, [parsedUrl, setSelectedChainID, setSelectedTokenAddress, supportedSquidChainsAndTokens])
+
+ // handle submit
+ const handleSubmit = () => {
+ if (canProceed && !isLoading) {
+ handlePayment()
+ }
+ }
+
+ const handleOpenExternalWalletFlow = async () => {
+ if (canProceed && !isLoading) {
+ const res = await handlePayment(true, true) // return after creating charge
+ // Proceed only if charge is created successfully
+ if (res && res.success) {
+ setCurrentView('EXTERNAL_WALLET')
+ }
+ }
+ }
+
+ // handle back navigation
+ const handleGoBack = () => {
+ if (window.history.length > 1) {
+ router.back()
+ } else {
+ router.push('/')
+ }
+ }
+
+ // determine button state
+ const isButtonDisabled = !canProceed || isLoading
+ const isAmountEntered = !!amount && parseFloat(amount) > 0
+
+ // get display name for recipient
+ const recipientDisplayName =
+ recipient?.recipientType === 'ADDRESS'
+ ? printableAddress(recipient.resolvedAddress)
+ : recipient?.identifier || ''
+
+ // check if using peanut wallet default (usdc on arb)
+ const isUsingPeanutDefault =
+ selectedChainID === PEANUT_WALLET_CHAIN.id.toString() &&
+ areEvmAddressesEqual(selectedTokenAddress, PEANUT_WALLET_TOKEN)
+
+ // determine if we should show token selector
+ // only show when chain is NOT specified in url AND recipient is ADDRESS or ENS
+ const showTokenSelector =
+ !parsedUrl?.chain?.chainId &&
+ (recipient?.recipientType === 'ADDRESS' || recipient?.recipientType === 'ENS') &&
+ isConnected
+
+ return (
+
+
+
+
+ {/* recipient card */}
+ {recipient && (
+
+ )}
+
+ {/* amount input */}
+
+
+ {/* token selector for chain/token selection (not for USERNAME) */}
+ {showTokenSelector &&
}
+
+ {/* hint for free transactions */}
+ {showTokenSelector && selectedTokenAddress && selectedChainID && !isUsingPeanutDefault && (
+
+ Use USDC on Arbitrum for free transactions!
+
+ )}
+
+ {/* button and error */}
+
+
+ {isInsufficientBalance && (
+
+ )}
+ {error.showError && }
+
+
+ {/* action list for non-logged in users */}
+
+
+
+ {/* support cta */}
+ {!isLoggedIn &&
}
+
+ )
+}
diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestReceiptView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestReceiptView.tsx
new file mode 100644
index 000000000..e1b1f91c3
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/views/SemanticRequestReceiptView.tsx
@@ -0,0 +1,131 @@
+'use client'
+
+/**
+ * receipt view for semantic request flow
+ *
+ * displays transaction receipt when visiting a charge url that's already been paid
+ * uses TransactionDetailsReceipt to show full payment details
+ */
+
+import { TransactionDetailsReceipt } from '@/components/TransactionDetails/TransactionDetailsReceipt'
+import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
+import { useSemanticRequestFlow } from '../useSemanticRequestFlow'
+import { useMemo } from 'react'
+import { type StatusPillType } from '@/components/Global/StatusPill'
+import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory'
+import { getInitialsFromName } from '@/utils/general.utils'
+import { BASE_URL } from '@/constants/general.consts'
+import { useTokenChainIcons } from '@/hooks/useTokenChainIcons'
+import PeanutLoading from '@/components/Global/PeanutLoading'
+import NavHeader from '@/components/Global/NavHeader'
+import { useRouter } from 'next/navigation'
+
+export function SemanticRequestReceiptView() {
+ const router = useRouter()
+ const { charge, recipient, parsedUrl, isFetchingCharge } = useSemanticRequestFlow()
+
+ const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({
+ chainId: charge?.chainId,
+ tokenSymbol: charge?.tokenSymbol,
+ tokenAddress: charge?.tokenAddress,
+ })
+
+ // construct transaction details for receipt
+ const transactionForReceipt: TransactionDetails | null = useMemo(() => {
+ if (!charge) return null
+
+ // check if charge has been fulfilled
+ const isPaid = charge.fulfillmentPayment?.status === 'SUCCESSFUL'
+ if (!isPaid) return null
+
+ // get the successful payment for payer details
+ const successfulPayment = charge.payments?.find((p) => p.status === 'SUCCESSFUL')
+ if (!successfulPayment) return null
+
+ const recipientIdentifier = recipient?.identifier || parsedUrl?.recipient?.identifier
+ const receiptLink = recipientIdentifier
+ ? `${BASE_URL}/${recipientIdentifier}?chargeId=${charge.uuid}`
+ : undefined
+
+ const networkFeeDisplayValue = '$ 0.00' // fee is zero for peanut wallet txns
+ const peanutFeeDisplayValue = '$ 0.00' // peanut doesn't charge fees yet
+
+ // determine who paid (payer name for display)
+ const payerName = successfulPayment.payerAccount?.user?.username || successfulPayment.payerAddress || 'Unknown'
+
+ const details: Partial = {
+ id: successfulPayment.payerTransactionHash || charge.uuid,
+ txHash: successfulPayment.payerTransactionHash,
+ status: 'completed' as StatusPillType,
+ amount: parseFloat(charge.tokenAmount),
+ createdAt: new Date(charge.createdAt),
+ completedAt: new Date(successfulPayment.createdAt),
+ tokenSymbol: charge.tokenSymbol,
+ direction: 'receive', // showing receipt from recipient's perspective
+ initials: getInitialsFromName(payerName),
+ extraDataForDrawer: {
+ isLinkTransaction: false,
+ originalType: EHistoryEntryType.REQUEST,
+ originalUserRole: EHistoryUserRole.RECIPIENT,
+ link: receiptLink,
+ },
+ userName: payerName,
+ sourceView: 'status',
+ memo: charge.requestLink?.reference || undefined,
+ attachmentUrl: charge.requestLink?.attachmentUrl || undefined,
+ tokenDisplayDetails: {
+ tokenSymbol: resolvedTokenSymbol || charge.tokenSymbol,
+ chainName: resolvedChainName,
+ tokenIconUrl: tokenIconUrl,
+ chainIconUrl: chainIconUrl,
+ },
+ networkFeeDetails: {
+ amountDisplay: networkFeeDisplayValue,
+ moreInfoText: 'This transaction may face slippage due to token conversion or cross-chain bridging.',
+ },
+ peanutFeeDetails: {
+ amountDisplay: peanutFeeDisplayValue,
+ },
+ currency: charge.currencyAmount ? { amount: charge.currencyAmount, code: 'USD' } : undefined,
+ }
+
+ return details as TransactionDetails
+ }, [charge, recipient, parsedUrl, tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol])
+
+ // show loading if fetching charge
+ if (isFetchingCharge || !charge) {
+ return (
+
+ )
+ }
+
+ // show receipt if we have transaction details
+ if (!transactionForReceipt) {
+ return (
+
+
router.back()} />
+
+
Unable to load receipt details
+
+
+ )
+ }
+
+ return (
+
+
router.back()} />
+
+
+
+
+ )
+}
diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestSuccessView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestSuccessView.tsx
new file mode 100644
index 000000000..bbd41286f
--- /dev/null
+++ b/src/features/payments/flows/semantic-request/views/SemanticRequestSuccessView.tsx
@@ -0,0 +1,55 @@
+'use client'
+
+/**
+ * success view for semantic request flow
+ *
+ * thin wrapper around PaymentSuccessView that:
+ * - pulls data from semantic request flow context
+ * - calculates points earned for the payment
+ * - provides reset callback on completion
+ */
+
+import PaymentSuccessView from '@/features/payments/shared/components/PaymentSuccessView'
+import { useSemanticRequestFlow } from '../useSemanticRequestFlow'
+import { usePointsCalculation } from '@/hooks/usePointsCalculation'
+import { PointsAction } from '@/services/services.types'
+
+export function SemanticRequestSuccessView() {
+ const {
+ usdAmount,
+ recipient,
+ parsedUrl,
+ attachment,
+ charge,
+ payment,
+ resetSemanticRequestFlow,
+ isExternalWalletPayment,
+ } = useSemanticRequestFlow()
+
+ // determine recipient type from parsed url
+ const recipientType = recipient?.recipientType || 'ADDRESS'
+
+ // calculate points for the payment (request fulfillment)
+ const { pointsData } = usePointsCalculation(
+ PointsAction.P2P_REQUEST_PAYMENT,
+ usdAmount,
+ !!payment || isExternalWalletPayment, // For external wallet payments, we dont't have payment info on the FE, its handled by webooks on BE
+ payment?.uuid
+ )
+
+ return (
+
+ )
+}
diff --git a/src/features/payments/shared/components/PaymentMethodActionList.tsx b/src/features/payments/shared/components/PaymentMethodActionList.tsx
new file mode 100644
index 000000000..ba9a7f3cd
--- /dev/null
+++ b/src/features/payments/shared/components/PaymentMethodActionList.tsx
@@ -0,0 +1,120 @@
+'use client'
+
+/**
+ * payment method action list for payment flows
+ *
+ * shows alternative payment methods (bank, mercadopago, pix)
+ * for users who don't have peanut wallet balance.
+ *
+ * redirects to add-money flow after login/signup
+ *
+ * used by: send, semantic-request input views
+ */
+
+import { useRouter } from 'next/navigation'
+import Divider from '@/components/0_Bruddle/Divider'
+import { ActionListCard } from '@/components/ActionListCard'
+import IconStack from '@/components/Global/IconStack'
+import StatusBadge from '@/components/Global/Badges/StatusBadge'
+import { ACTION_METHODS, type PaymentMethod } from '@/constants/actionlist.consts'
+import { useGeoFilteredPaymentOptions } from '@/hooks/useGeoFilteredPaymentOptions'
+import Loading from '@/components/Global/Loading'
+import useKycStatus from '@/hooks/useKycStatus'
+import { saveRedirectUrl } from '@/utils/general.utils'
+
+interface PaymentMethodActionListProps {
+ isAmountEntered: boolean
+ showDivider?: boolean
+ onPayWithExternalWallet?: () => void
+}
+
+/**
+ * generic payment method action list for both direct send and semantic request flows
+ * shows bank/mercadopago/pix options
+ * redirects to setup with add-money as final destination after login/signup if user is not logged in
+ * @param isAmountEntered - whether the amount is entered
+ * @param showDivider - whether to show the divider
+ * @returns the payment options list component
+ */
+
+export function PaymentMethodActionList({
+ isAmountEntered,
+ showDivider = true,
+ onPayWithExternalWallet,
+}: PaymentMethodActionListProps) {
+ const router = useRouter()
+ const { isUserMantecaKycApproved, isUserBridgeKycApproved } = useKycStatus()
+
+ // use geo filtering hook to sort methods based on user location
+ // note: we don't mark verification-required methods as unavailable - they're still clickable
+ const { filteredMethods: sortedMethods, isLoading: isGeoLoading } = useGeoFilteredPaymentOptions({
+ sortUnavailable: true,
+ isMethodUnavailable: (method) => method.soon,
+ methods: ACTION_METHODS,
+ })
+
+ const handleMethodClick = (method: PaymentMethod) => {
+ // for all methods, save current url and redirect to setup with add-money as final destination
+ // verification will be handled in the add-money flow after login
+
+ if (method.id === 'exchange-or-wallet' && onPayWithExternalWallet) {
+ onPayWithExternalWallet()
+ return
+ }
+
+ if (['bank', 'mercadopago', 'pix'].includes(method.id)) {
+ saveRedirectUrl()
+ const redirectUri = encodeURIComponent('/add-money')
+ router.push(`/setup?redirect_uri=${redirectUri}`)
+ }
+ }
+
+ if (isGeoLoading) {
+ return (
+
+
+
+ )
+ }
+
+ return (
+
+ {showDivider &&
}
+
+ {sortedMethods.map((method) => {
+ // check if method requires verification (for badge display only)
+ const methodRequiresMantecaVerification =
+ ['mercadopago', 'pix'].includes(method.id) && !isUserMantecaKycApproved
+ const methodRequiresBridgeVerification = method.id === 'bank' && !isUserBridgeKycApproved
+ const methodRequiresVerification =
+ methodRequiresMantecaVerification || methodRequiresBridgeVerification
+ return (
+
+ {method.title}
+ {(method.soon || methodRequiresVerification) && (
+
+ )}
+
+ }
+ onClick={() => handleMethodClick(method)}
+ isDisabled={method.soon || !isAmountEntered}
+ rightContent={
}
+ />
+ )
+ })}
+
+
+ )
+}
+
+// re-export for backward compatibility
+export { PaymentMethodActionList as SendActionList }
diff --git a/src/components/Payment/Views/Status.payment.view.tsx b/src/features/payments/shared/components/PaymentSuccessView.tsx
similarity index 87%
rename from src/components/Payment/Views/Status.payment.view.tsx
rename to src/features/payments/shared/components/PaymentSuccessView.tsx
index c8b916d61..8789657d1 100644
--- a/src/components/Payment/Views/Status.payment.view.tsx
+++ b/src/features/payments/shared/components/PaymentSuccessView.tsx
@@ -1,5 +1,19 @@
'use client'
-import { Button } from '@/components/0_Bruddle'
+
+/**
+ * shared success view for all payment flows
+ *
+ * displays:
+ * - success animation with peanut mascot
+ * - amount sent and recipient name
+ * - optional message/attachment
+ * - points earned (with confetti)
+ * - receipt drawer for transaction details
+ *
+ * used by: send, contribute-pot, semantic-request, withdraw flows
+ */
+
+import { Button } from '@/components/0_Bruddle/Button'
import AddressLink from '@/components/Global/AddressLink'
import Card from '@/components/Global/Card'
import CreateAccountButton from '@/components/Global/CreateAccountButton'
@@ -9,32 +23,37 @@ import { SoundPlayer } from '@/components/Global/SoundPlayer'
import { type StatusPillType } from '@/components/Global/StatusPill'
import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
-import { TRANSACTIONS, BASE_URL } from '@/constants'
import { useTokenChainIcons } from '@/hooks/useTokenChainIcons'
import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer'
import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory'
import { type RecipientType } from '@/lib/url-parser/types/payment'
-import { usePaymentStore, useUserStore } from '@/redux/hooks'
-import { paymentActions } from '@/redux/slices/payment-slice'
-import { type ApiUser } from '@/services/users'
-import { formatAmount, getInitialsFromName, printableAddress } from '@/utils'
+import { useUserStore } from '@/redux/hooks'
+import type { TRequestChargeResponse, PaymentCreationResponse, ChargeEntry } from '@/services/services.types'
+import { formatAmount, getInitialsFromName, printableAddress } from '@/utils/general.utils'
import { useQueryClient } from '@tanstack/react-query'
import Image from 'next/image'
import { useRouter } from 'next/navigation'
import { type ReactNode, useEffect, useMemo, useRef } from 'react'
-import { useDispatch } from 'react-redux'
-import STAR_STRAIGHT_ICON from '@/assets/icons/starStraight.svg'
import { usePointsConfetti } from '@/hooks/usePointsConfetti'
import chillPeanutAnim from '@/animations/GIF_ALPHA_BACKGORUND/512X512_ALPHA_GIF_konradurban_01.gif'
import { useHaptic } from 'use-haptic'
import PointsCard from '@/components/Common/PointsCard'
+import { BASE_URL } from '@/constants/general.consts'
+import { TRANSACTIONS } from '@/constants/query.consts'
+import type { ParsedURL } from '@/lib/url-parser/types/payment'
+
+// minimal user info needed for display
+type UserDisplayInfo = {
+ username?: string
+ fullName?: string
+}
type DirectSuccessViewProps = {
- user?: ApiUser
+ user?: UserDisplayInfo
amount?: string
message?: string | ReactNode
recipientType?: RecipientType
- type?: 'SEND' | 'REQUEST'
+ type?: 'SEND' | 'REQUEST' | 'DEPOSIT'
headerTitle?: string
currencyAmount?: string
isExternalWalletFlow?: boolean
@@ -42,9 +61,14 @@ type DirectSuccessViewProps = {
redirectTo?: string
onComplete?: () => void
points?: number
+ // props to receive data directly instead of from redux
+ chargeDetails?: TRequestChargeResponse | ChargeEntry | null
+ paymentDetails?: PaymentCreationResponse | null
+ parsedPaymentData?: ParsedURL | null
+ usdAmount?: string
}
-const DirectSuccessView = ({
+const PaymentSuccessView = ({
user,
amount,
message,
@@ -57,10 +81,12 @@ const DirectSuccessView = ({
redirectTo = '/home',
onComplete,
points,
+ chargeDetails,
+ paymentDetails,
+ parsedPaymentData,
+ usdAmount,
}: DirectSuccessViewProps) => {
const router = useRouter()
- const { chargeDetails, parsedPaymentData, usdAmount, paymentDetails } = usePaymentStore()
- const dispatch = useDispatch()
const { isDrawerOpen, selectedTransaction, openTransactionDetails, closeTransactionDetails } =
useTransactionDetailsDrawer()
const { user: authUser } = useUserStore()
@@ -174,14 +200,14 @@ const DirectSuccessView = ({
}, [queryClient])
const handleDone = () => {
- onComplete?.()
+ // Navigate first, then call onComplete - otherwise onComplete may reset state
+ // causing this component to unmount before router.push executes
if (!!authUser?.user.userId) {
- // reset payment state when done
router.push('/home')
- dispatch(paymentActions.resetPaymentState())
} else {
router.push('/setup')
}
+ onComplete?.()
}
const getTitle = () => {
@@ -189,6 +215,7 @@ const DirectSuccessView = ({
if (isWithdrawFlow) return 'You just withdrew'
if (type === 'SEND') return 'You sent '
if (type === 'REQUEST') return 'You requested '
+ if (type === 'DEPOSIT') return 'You added '
}
useEffect(() => {
@@ -289,4 +316,4 @@ const DirectSuccessView = ({
)
}
-export default DirectSuccessView
+export default PaymentSuccessView
diff --git a/src/features/payments/shared/components/SendWithPeanutCta.tsx b/src/features/payments/shared/components/SendWithPeanutCta.tsx
new file mode 100644
index 000000000..644937bc8
--- /dev/null
+++ b/src/features/payments/shared/components/SendWithPeanutCta.tsx
@@ -0,0 +1,115 @@
+'use client'
+
+/**
+ * primary cta button for peanut wallet payments
+ *
+ * shows different states:
+ * - not logged in: "continue with peanut" + redirects to signup, then redirects to the current page
+ * - logged in: "send with peanut" + executes payment
+ */
+
+import { PEANUT_LOGO_BLACK, PEANUTMAN_LOGO } from '@/assets'
+import { Button, type ButtonProps } from '@/components/0_Bruddle/Button'
+import type { IconName } from '@/components/Global/Icons/Icon'
+import { useAuth } from '@/context/authContext'
+import { saveRedirectUrl, saveToLocalStorage } from '@/utils/general.utils'
+import Image from 'next/image'
+import { useRouter } from 'next/navigation'
+import { useMemo } from 'react'
+
+interface SendWithPeanutCtaProps extends ButtonProps {
+ title?: string
+ // when true, will redirect to login if user is not logged in
+ requiresAuth?: boolean
+ insufficientBalance?: boolean
+}
+
+/**
+ * Button to continue with Peanut or login to continue with peanut icon
+ * @param title - The title of the button (optional)
+ * @param requiresAuth - Whether the button requires authentication
+ * @param onClick - The onClick handler
+ * @param props - The props for the button
+ * @returns The button component
+ */
+
+export default function SendWithPeanutCta({
+ title,
+ requiresAuth = true,
+ onClick,
+ insufficientBalance = false,
+ ...props
+}: SendWithPeanutCtaProps) {
+ const router = useRouter()
+ const { user, isFetchingUser } = useAuth()
+
+ const isLoggedIn = !!user?.user?.userId
+
+ const handleClick = (e: React.MouseEvent
) => {
+ // if auth is required and user is not logged in, redirect to login
+ if (requiresAuth && !user?.user?.userId && !isFetchingUser) {
+ saveRedirectUrl()
+ router.push('/setup')
+ return
+ }
+
+ if (isLoggedIn && insufficientBalance) {
+ // save current url so back button works properly
+ saveRedirectUrl()
+ saveToLocalStorage('fromRequestFulfillment', 'true')
+ router.push('/add-money')
+ return
+ }
+
+ // otherwise call the provided onClick handler
+ onClick?.(e)
+ }
+
+ const icon = useMemo((): IconName | undefined => {
+ if (!isLoggedIn) {
+ return undefined
+ }
+ if (insufficientBalance) {
+ return 'arrow-down'
+ }
+ return 'arrow-up-right'
+ }, [isLoggedIn, insufficientBalance])
+
+ const peanutLogo = useMemo((): React.ReactNode => {
+ return (
+
+
+
+
+ )
+ }, [])
+
+ return (
+
+ {!isLoggedIn ? (
+
+ ) : insufficientBalance ? (
+
+
Add funds to
+ {peanutLogo}
+
+ ) : (
+
+
{title || 'Send with '}
+ {peanutLogo}
+
+ )}
+
+ )
+}
diff --git a/src/features/payments/shared/hooks/useChargeManager.ts b/src/features/payments/shared/hooks/useChargeManager.ts
new file mode 100644
index 000000000..4d698db9f
--- /dev/null
+++ b/src/features/payments/shared/hooks/useChargeManager.ts
@@ -0,0 +1,194 @@
+'use client'
+
+/**
+ * hook for managing charge lifecycle (create, fetch, cache)
+ *
+ * charges are payment requests stored in our backend. they track:
+ * - what token/chain the recipient wants
+ * - the amount requested
+ * - optional attachments/messages
+ *
+ * used by all payment flows before executing transactions
+ *
+ * @example
+ * const { createCharge, fetchCharge, charge } = useChargeManager()
+ * const newCharge = await createCharge({ tokenAmount: '10', ... })
+ */
+
+import { useState, useCallback } from 'react'
+import { chargesApi } from '@/services/charges'
+import { requestsApi } from '@/services/requests'
+import { type TRequestChargeResponse, type TCharge, type TChargeTransactionType } from '@/services/services.types'
+import { isNativeCurrency } from '@/utils/general.utils'
+import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
+import { type Address } from 'viem'
+
+// params for creating a new charge
+export interface CreateChargeParams {
+ tokenAmount: string
+ tokenAddress: Address
+ chainId: string
+ tokenSymbol: string
+ tokenDecimals: number
+ recipientAddress: Address
+ transactionType?: TChargeTransactionType
+ requestId?: string
+ reference?: string
+ attachment?: File
+ currencyAmount?: string
+ currencyCode?: string
+}
+
+// return type for the hook
+export interface UseChargeManagerReturn {
+ charge: TRequestChargeResponse | null
+ isCreating: boolean
+ isFetching: boolean
+ error: string | null
+ createCharge: (params: CreateChargeParams) => Promise
+ fetchCharge: (chargeId: string) => Promise
+ setCharge: (charge: TRequestChargeResponse | null) => void
+ reset: () => void
+}
+
+export const useChargeManager = (): UseChargeManagerReturn => {
+ const [charge, setCharge] = useState(null)
+ const [isCreating, setIsCreating] = useState(false)
+ const [isFetching, setIsFetching] = useState(false)
+ const [error, setError] = useState(null)
+
+ // fetch existing charge by id
+ const fetchCharge = useCallback(async (chargeId: string): Promise => {
+ setIsFetching(true)
+ setError(null)
+
+ try {
+ const chargeDetails = await chargesApi.get(chargeId)
+ setCharge(chargeDetails)
+ return chargeDetails
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'failed to fetch charge'
+ setError(message)
+ throw err
+ } finally {
+ setIsFetching(false)
+ }
+ }, [])
+
+ // create a new charge
+ const createCharge = useCallback(async (params: CreateChargeParams): Promise => {
+ setIsCreating(true)
+ setError(null)
+
+ try {
+ // if requestId provided, validate it exists
+ let validRequestId = params.requestId
+ if (params.requestId) {
+ try {
+ const request = await requestsApi.get(params.requestId)
+ validRequestId = request.uuid
+ } catch {
+ throw new Error('invalid request id')
+ }
+ }
+
+ // build the create charge payload
+ const localPrice =
+ params.currencyAmount && params.currencyCode
+ ? { amount: params.currencyAmount, currency: params.currencyCode }
+ : { amount: params.tokenAmount, currency: 'USD' }
+
+ const createPayload: {
+ pricing_type: 'fixed_price'
+ local_price: { amount: string; currency: string }
+ baseUrl: string
+ requestId?: string
+ requestProps: {
+ chainId: string
+ tokenAmount: string
+ tokenAddress: Address
+ tokenType: peanutInterfaces.EPeanutLinkType
+ tokenSymbol: string
+ tokenDecimals: number
+ recipientAddress: Address
+ }
+ transactionType?: TChargeTransactionType
+ attachment?: File
+ reference?: string
+ mimeType?: string
+ filename?: string
+ } = {
+ pricing_type: 'fixed_price',
+ local_price: localPrice,
+ baseUrl: typeof window !== 'undefined' ? window.location.origin : '',
+ requestProps: {
+ chainId: params.chainId,
+ tokenAmount: params.tokenAmount,
+ tokenAddress: params.tokenAddress,
+ tokenType: isNativeCurrency(params.tokenAddress)
+ ? peanutInterfaces.EPeanutLinkType.native
+ : peanutInterfaces.EPeanutLinkType.erc20,
+ tokenSymbol: params.tokenSymbol,
+ tokenDecimals: params.tokenDecimals,
+ recipientAddress: params.recipientAddress,
+ },
+ transactionType: params.transactionType,
+ }
+
+ // add request id if provided
+ if (validRequestId) {
+ createPayload.requestId = validRequestId
+ }
+
+ // add attachment if present
+ if (params.attachment) {
+ createPayload.attachment = params.attachment
+ createPayload.filename = params.attachment.name
+ createPayload.mimeType = params.attachment.type
+ }
+
+ // add reference/message if present
+ if (params.reference) {
+ createPayload.reference = params.reference
+ }
+
+ // create the charge
+ const chargeResponse: TCharge = await chargesApi.create(createPayload)
+
+ if (!chargeResponse.data.id) {
+ throw new Error('charge created but missing uuid')
+ }
+
+ // fetch full charge details
+ const chargeDetails = await chargesApi.get(chargeResponse.data.id)
+ setCharge(chargeDetails)
+
+ return chargeDetails
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'failed to create charge'
+ setError(message)
+ throw err
+ } finally {
+ setIsCreating(false)
+ }
+ }, [])
+
+ // reset all state
+ const reset = useCallback(() => {
+ setCharge(null)
+ setIsCreating(false)
+ setIsFetching(false)
+ setError(null)
+ }, [])
+
+ return {
+ charge,
+ isCreating,
+ isFetching,
+ error,
+ createCharge,
+ fetchCharge,
+ setCharge,
+ reset,
+ }
+}
diff --git a/src/features/payments/shared/hooks/usePaymentRecorder.ts b/src/features/payments/shared/hooks/usePaymentRecorder.ts
new file mode 100644
index 000000000..487fd6c1f
--- /dev/null
+++ b/src/features/payments/shared/hooks/usePaymentRecorder.ts
@@ -0,0 +1,91 @@
+'use client'
+
+/**
+ * hook for recording payments to the backend after transaction execution
+ *
+ * after a blockchain transaction is confirmed, this hook notifies our backend
+ * so we can:
+ * - mark the charge as paid
+ * - update the recipient's balance/history
+ * - track cross-chain payment sources
+ *
+ * @example
+ * const { recordPayment } = usePaymentRecorder()
+ * await recordPayment({ chargeId, chainId, txHash, tokenAddress, payerAddress })
+ */
+
+import { useState, useCallback } from 'react'
+import { chargesApi } from '@/services/charges'
+import { type PaymentCreationResponse } from '@/services/services.types'
+import { type Address } from 'viem'
+
+// params for recording a payment
+export interface RecordPaymentParams {
+ chargeId: string
+ chainId: string
+ txHash: string
+ tokenAddress: Address
+ payerAddress: Address
+ // optional cross-chain source info
+ sourceChainId?: string
+ sourceTokenAddress?: string
+ sourceTokenSymbol?: string
+}
+
+// return type for the hook
+export interface UsePaymentRecorderReturn {
+ payment: PaymentCreationResponse | null
+ isRecording: boolean
+ error: string | null
+ recordPayment: (params: RecordPaymentParams) => Promise
+ reset: () => void
+}
+
+export const usePaymentRecorder = (): UsePaymentRecorderReturn => {
+ const [payment, setPayment] = useState(null)
+ const [isRecording, setIsRecording] = useState(false)
+ const [error, setError] = useState(null)
+
+ // record payment to backend
+ const recordPayment = useCallback(async (params: RecordPaymentParams): Promise => {
+ setIsRecording(true)
+ setError(null)
+
+ try {
+ const paymentResponse = await chargesApi.createPayment({
+ chargeId: params.chargeId,
+ chainId: params.chainId,
+ hash: params.txHash,
+ tokenAddress: params.tokenAddress,
+ payerAddress: params.payerAddress,
+ sourceChainId: params.sourceChainId,
+ sourceTokenAddress: params.sourceTokenAddress,
+ sourceTokenSymbol: params.sourceTokenSymbol,
+ })
+
+ setPayment(paymentResponse)
+ return paymentResponse
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'failed to record payment'
+ setError(message)
+ throw err
+ } finally {
+ setIsRecording(false)
+ }
+ }, [])
+
+ // reset state
+ const reset = useCallback(() => {
+ setPayment(null)
+ setIsRecording(false)
+ setError(null)
+ }, [])
+
+ return {
+ payment,
+ isRecording,
+ error,
+ recordPayment,
+ reset,
+ }
+}
diff --git a/src/features/payments/shared/hooks/useRouteCalculation.ts b/src/features/payments/shared/hooks/useRouteCalculation.ts
new file mode 100644
index 000000000..2f9612671
--- /dev/null
+++ b/src/features/payments/shared/hooks/useRouteCalculation.ts
@@ -0,0 +1,233 @@
+'use client'
+
+/**
+ * hook for calculating cross-chain routes and preparing transactions
+ *
+ * handles two scenarios:
+ * 1. same chain + same token: prepares a simple transfer
+ * 2. cross-chain or different token: uses squid router to find best route
+ * todo: @dev squid to be updated in deposit v2
+ *
+ * returns unsigned transactions ready to be sent via wallet
+ *
+ * @example
+ * const { calculateRoute, transactions, estimatedGasCostUsd } = useRouteCalculation()
+ * await calculateRoute({ source: { ... }, destination: { ... } })
+ */
+
+import { useState, useCallback } from 'react'
+import { parseUnits } from 'viem'
+import { type Address, type Hex } from 'viem'
+import { peanut, interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
+import { getRoute, type PeanutCrossChainRoute } from '@/services/swap'
+import { estimateTransactionCostUsd } from '@/app/actions/tokens'
+import { areEvmAddressesEqual } from '@/utils/general.utils'
+import { captureException } from '@sentry/nextjs'
+
+// source token info for route calculation
+export interface RouteSourceInfo {
+ address: Address
+ tokenAddress: Address
+ chainId: string
+}
+
+// destination charge info for route calculation
+export interface RouteDestinationInfo {
+ recipientAddress: Address
+ tokenAddress: Address
+ tokenAmount: string
+ tokenDecimals: number
+ tokenType: number
+ chainId: string
+}
+
+// unsigned transaction ready for execution
+export interface PreparedTransaction {
+ to: Address
+ data?: Hex
+ value?: bigint
+}
+
+// return type for the hook
+export interface UseRouteCalculationReturn {
+ route: PeanutCrossChainRoute | null
+ transactions: PreparedTransaction[] | null
+ estimatedGasCostUsd: number | undefined
+ estimatedFromValue: string
+ slippagePercentage: number | undefined
+ isXChain: boolean
+ isDiffToken: boolean
+ isCalculating: boolean
+ isFeeEstimationError: boolean
+ error: string | null
+ calculateRoute: (params: {
+ source: RouteSourceInfo
+ destination: RouteDestinationInfo
+ usdAmount?: string
+ disableCoral?: boolean
+ skipGasEstimate?: boolean
+ }) => Promise
+ reset: () => void
+}
+
+export const useRouteCalculation = (): UseRouteCalculationReturn => {
+ const [route, setRoute] = useState(null)
+ const [transactions, setTransactions] = useState(null)
+ const [estimatedGasCostUsd, setEstimatedGasCostUsd] = useState(undefined)
+ const [estimatedFromValue, setEstimatedFromValue] = useState('0')
+ const [slippagePercentage, setSlippagePercentage] = useState(undefined)
+ const [isCalculating, setIsCalculating] = useState(false)
+ const [isFeeEstimationError, setIsFeeEstimationError] = useState(false)
+ const [error, setError] = useState(null)
+
+ // computed values
+ const [isXChain, setIsXChain] = useState(false)
+ const [isDiffToken, setIsDiffToken] = useState(false)
+
+ // calculate route for cross-chain or same-chain swap
+ const calculateRoute = useCallback(
+ async ({
+ source,
+ destination,
+ usdAmount,
+ disableCoral = false,
+ skipGasEstimate = false,
+ }: {
+ source: RouteSourceInfo
+ destination: RouteDestinationInfo
+ usdAmount?: string
+ disableCoral?: boolean
+ skipGasEstimate?: boolean
+ }) => {
+ setIsCalculating(true)
+ setError(null)
+ setIsFeeEstimationError(false)
+ setRoute(null)
+ setTransactions(null)
+ setEstimatedGasCostUsd(undefined)
+
+ try {
+ const _isXChain = source.chainId !== destination.chainId
+ const _isDiffToken = !areEvmAddressesEqual(source.tokenAddress, destination.tokenAddress)
+
+ setIsXChain(_isXChain)
+ setIsDiffToken(_isDiffToken)
+
+ if (_isXChain || _isDiffToken) {
+ // cross-chain or token swap needed
+ const amount = usdAmount
+ ? { fromUsd: usdAmount }
+ : { toAmount: parseUnits(destination.tokenAmount, destination.tokenDecimals) }
+
+ const xChainRoute = await getRoute(
+ {
+ from: source,
+ to: {
+ address: destination.recipientAddress,
+ tokenAddress: destination.tokenAddress,
+ chainId: destination.chainId,
+ },
+ ...amount,
+ },
+ { disableCoral }
+ )
+
+ if (xChainRoute.error) {
+ throw new Error(xChainRoute.error)
+ }
+
+ const slippage = Number(xChainRoute.fromAmount) / Number(destination.tokenAmount) - 1
+
+ setRoute(xChainRoute)
+ setTransactions(
+ xChainRoute.transactions.map((tx) => ({
+ to: tx.to,
+ data: tx.data,
+ value: BigInt(tx.value),
+ }))
+ )
+ setEstimatedGasCostUsd(xChainRoute.feeCostsUsd)
+ setEstimatedFromValue(xChainRoute.fromAmount)
+ setSlippagePercentage(slippage)
+ } else {
+ // same chain, same token - prepare simple transfer
+ const tx = peanut.prepareRequestLinkFulfillmentTransaction({
+ recipientAddress: destination.recipientAddress,
+ tokenAddress: destination.tokenAddress,
+ tokenAmount: destination.tokenAmount,
+ tokenDecimals: destination.tokenDecimals,
+ tokenType: destination.tokenType as peanutInterfaces.EPeanutLinkType,
+ })
+
+ if (!tx?.unsignedTx) {
+ throw new Error('failed to prepare transaction')
+ }
+
+ const preparedTx: PreparedTransaction = {
+ to: tx.unsignedTx.to as Address,
+ data: tx.unsignedTx.data as Hex | undefined,
+ value: tx.unsignedTx.value ? BigInt(tx.unsignedTx.value.toString()) : undefined,
+ }
+
+ setTransactions([preparedTx])
+ setEstimatedFromValue(destination.tokenAmount)
+ setSlippagePercentage(undefined)
+
+ // estimate gas for external wallets
+ if (!skipGasEstimate && tx.unsignedTx.from && tx.unsignedTx.to && tx.unsignedTx.data) {
+ try {
+ const gasCost = await estimateTransactionCostUsd(
+ tx.unsignedTx.from as Address,
+ tx.unsignedTx.to as Address,
+ tx.unsignedTx.data as Hex,
+ destination.chainId
+ )
+ setEstimatedGasCostUsd(gasCost)
+ } catch (gasError) {
+ captureException(gasError)
+ setIsFeeEstimationError(true)
+ }
+ } else {
+ setEstimatedGasCostUsd(0)
+ }
+ }
+ } catch (err) {
+ const message = err instanceof Error ? err.message : 'failed to calculate route'
+ setError(message)
+ setIsFeeEstimationError(true)
+ } finally {
+ setIsCalculating(false)
+ }
+ },
+ []
+ )
+
+ // reset all state
+ const reset = useCallback(() => {
+ setRoute(null)
+ setTransactions(null)
+ setEstimatedGasCostUsd(undefined)
+ setEstimatedFromValue('0')
+ setSlippagePercentage(undefined)
+ setIsXChain(false)
+ setIsDiffToken(false)
+ setIsCalculating(false)
+ setIsFeeEstimationError(false)
+ setError(null)
+ }, [])
+
+ return {
+ route,
+ transactions,
+ estimatedGasCostUsd,
+ estimatedFromValue,
+ slippagePercentage,
+ isXChain,
+ isDiffToken,
+ isCalculating,
+ isFeeEstimationError,
+ error,
+ calculateRoute,
+ reset,
+ }
+}
diff --git a/src/hooks/query/user.ts b/src/hooks/query/user.ts
index 9f08f765c..70553493e 100644
--- a/src/hooks/query/user.ts
+++ b/src/hooks/query/user.ts
@@ -1,12 +1,12 @@
-import { USER } from '@/constants'
import { type IUserProfile } from '@/interfaces'
import { useAppDispatch, useUserStore } from '@/redux/hooks'
import { userActions } from '@/redux/slices/user-slice'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import { hitUserMetric } from '@/utils/metrics.utils'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { usePWAStatus } from '../usePWAStatus'
import { useDeviceType } from '../useGetDeviceType'
+import { USER } from '@/constants/query.consts'
export const useUserQuery = (dependsOn: boolean = true) => {
const isPwa = usePWAStatus()
diff --git a/src/hooks/useAccountSetup.ts b/src/hooks/useAccountSetup.ts
index a086cc1de..c68e11af6 100644
--- a/src/hooks/useAccountSetup.ts
+++ b/src/hooks/useAccountSetup.ts
@@ -3,7 +3,8 @@ import { useRouter, useSearchParams } from 'next/navigation'
import * as Sentry from '@sentry/nextjs'
import { useAuth } from '@/context/authContext'
import { WalletProviderType } from '@/interfaces'
-import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl, clearAuthState } from '@/utils'
+import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils/general.utils'
+import { clearAuthState } from '@/utils/auth.utils'
import { POST_SIGNUP_ACTIONS } from '@/components/Global/PostSignupActionManager/post-signup-action.consts'
import { useSetupStore } from '@/redux/hooks'
@@ -20,6 +21,33 @@ export const useAccountSetup = () => {
const [error, setError] = useState(null)
const [isProcessing, setIsProcessing] = useState(false)
+ const handleRedirect = () => {
+ const redirect_uri = searchParams.get('redirect_uri')
+ if (redirect_uri) {
+ const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home')
+ console.log('[useAccountSetup] Redirecting to redirect_uri:', validRedirectUrl)
+ router.push(validRedirectUrl)
+ return true
+ }
+
+ const localStorageRedirect = getRedirectUrl()
+ if (localStorageRedirect) {
+ const matchedAction = POST_SIGNUP_ACTIONS.find((action) => action.pathPattern.test(localStorageRedirect))
+ if (matchedAction) {
+ console.log('[useAccountSetup] Matched post-signup action, redirecting to /home')
+ router.push('/home')
+ } else {
+ clearRedirectUrl()
+ const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home')
+ console.log('[useAccountSetup] Redirecting to localStorage redirect:', validRedirectUrl)
+ router.push(validRedirectUrl)
+ }
+ } else {
+ console.log('[useAccountSetup] No redirect found, going to /home')
+ router.push('/home')
+ }
+ }
+
/**
* finalize account setup by adding account to db and navigating
*/
@@ -76,32 +104,7 @@ export const useAccountSetup = () => {
}
}
- const redirect_uri = searchParams.get('redirect_uri')
- if (redirect_uri) {
- const validRedirectUrl = getValidRedirectUrl(redirect_uri, '/home')
- console.log('[useAccountSetup] Redirecting to redirect_uri:', validRedirectUrl)
- router.push(validRedirectUrl)
- return true
- }
-
- const localStorageRedirect = getRedirectUrl()
- if (localStorageRedirect) {
- const matchedAction = POST_SIGNUP_ACTIONS.find((action) =>
- action.pathPattern.test(localStorageRedirect)
- )
- if (matchedAction) {
- console.log('[useAccountSetup] Matched post-signup action, redirecting to /home')
- router.push('/home')
- } else {
- clearRedirectUrl()
- const validRedirectUrl = getValidRedirectUrl(localStorageRedirect, '/home')
- console.log('[useAccountSetup] Redirecting to localStorage redirect:', validRedirectUrl)
- router.push(validRedirectUrl)
- }
- } else {
- console.log('[useAccountSetup] No redirect found, going to /home')
- router.push('/home')
- }
+ handleRedirect()
return true
} catch (e) {
@@ -122,5 +125,6 @@ export const useAccountSetup = () => {
isProcessing,
error,
setError,
+ handleRedirect,
}
}
diff --git a/src/hooks/useAutoTruncatedAddress.ts b/src/hooks/useAutoTruncatedAddress.ts
new file mode 100644
index 000000000..0c9156732
--- /dev/null
+++ b/src/hooks/useAutoTruncatedAddress.ts
@@ -0,0 +1,199 @@
+import { useEffect, useState, useRef, useCallback } from 'react'
+
+/** Safety margin multiplier to account for font rendering differences between canvas and DOM */
+const SAFETY_MARGIN = 0.9
+
+/**
+ * Calculates the optimal truncation for an address to fit within a container width.
+ * Returns the formatted address like "0xF3...3he7e" with dynamic character counts.
+ *
+ * Shows as many characters as fit in the container, never causing overflow.
+ *
+ * @param address - The full address to truncate
+ * @param containerWidth - Available width in pixels
+ * @param charWidth - Estimated width per character in pixels (default: 8)
+ * @returns Formatted address string
+ */
+export const formatAddressToFitWidth = (address: string, containerWidth: number, charWidth: number = 8): string => {
+ if (!address || containerWidth <= 0) return address || ''
+
+ // Apply safety margin to container width
+ const safeWidth = containerWidth * SAFETY_MARGIN
+
+ // "..." is 3 characters
+ const ellipsisWidth = charWidth * 3
+
+ // Calculate max characters that can fit (excluding ellipsis)
+ const availableWidth = safeWidth - ellipsisWidth
+ const maxChars = Math.floor(availableWidth / charWidth)
+
+ // If full address fits (with margin), return it
+ if (address.length * charWidth <= safeWidth) return address
+
+ // Calculate chars for each side (half on each side)
+ const fitsPerSide = Math.floor(maxChars / 2)
+ const charsPerSide = Math.max(1, fitsPerSide)
+
+ // Edge case: container too narrow for even minimal truncation (need at least 1+3+1=5 chars width)
+ if (maxChars < 2) {
+ return address.substring(0, Math.max(1, maxChars)) + 'โฆ'
+ }
+
+ const firstBit = address.substring(0, charsPerSide)
+ const lastBit = address.substring(address.length - charsPerSide)
+
+ return `${firstBit}...${lastBit}`
+}
+
+// Cache for measured char widths to avoid repeated DOM operations
+const charWidthCache = new WeakMap()
+
+/**
+ * Measures the approximate character width for a given element's font.
+ * Results are cached per element to avoid layout thrashing.
+ */
+const measureCharWidth = (element: HTMLElement): number => {
+ const computedStyle = window.getComputedStyle(element)
+ const fontKey = `${computedStyle.fontSize}-${computedStyle.fontFamily}-${computedStyle.fontWeight}`
+
+ // Check cache
+ const cached = charWidthCache.get(element)
+ if (cached && cached.fontKey === fontKey) {
+ return cached.width
+ }
+
+ // Measure using canvas (no DOM manipulation, much faster)
+ const canvas = document.createElement('canvas')
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return 8 // Fallback
+
+ ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`
+ const testString = '0123456789abcdefx'
+ const width = ctx.measureText(testString).width / testString.length
+
+ // Cache result
+ charWidthCache.set(element, { width, fontKey })
+
+ return width
+}
+
+interface UseAutoTruncatedAddressOptions {
+ /** @deprecated No longer used - kept for API compatibility */
+ minChars?: number
+ /** Extra padding to subtract from container width (default: 0) */
+ padding?: number
+}
+
+/**
+ * React hook that automatically truncates an address to fit within a container.
+ * Uses ResizeObserver to adapt to container size changes.
+ *
+ * @example
+ * ```tsx
+ * const { containerRef, truncatedAddress } = useAutoTruncatedAddress(depositAddress)
+ *
+ * return (
+ *
+ * {truncatedAddress}
+ *
+ * )
+ * ```
+ */
+export const useAutoTruncatedAddress = (
+ address: string,
+ options: UseAutoTruncatedAddressOptions = {}
+): {
+ containerRef: (node: T | null) => void
+ truncatedAddress: string
+} => {
+ const { padding = 0 } = options
+ const elementRef = useRef(null)
+ const [truncatedAddress, setTruncatedAddress] = useState(address)
+ const resizeObserverRef = useRef(null)
+ const rafIdRef = useRef(null)
+ const lastWidthRef = useRef(0)
+
+ // Store options in refs to avoid recreating callbacks
+ const optionsRef = useRef({ padding, address })
+ optionsRef.current = { padding, address }
+
+ const updateTruncation = useCallback(() => {
+ const container = elementRef.current
+ const { address: addr, padding: pad } = optionsRef.current
+
+ if (!container || !addr) {
+ setTruncatedAddress(addr || '')
+ return
+ }
+
+ const containerWidth = container.offsetWidth - pad
+
+ // Skip if width hasn't changed (avoid unnecessary work)
+ if (containerWidth === lastWidthRef.current && truncatedAddress !== addr) {
+ return
+ }
+ lastWidthRef.current = containerWidth
+
+ const charWidth = measureCharWidth(container)
+ const formatted = formatAddressToFitWidth(addr, containerWidth, charWidth)
+
+ setTruncatedAddress(formatted)
+ }, []) // Empty deps - uses refs for values
+
+ // Debounced update using requestAnimationFrame
+ const scheduleUpdate = useCallback(() => {
+ if (rafIdRef.current) {
+ cancelAnimationFrame(rafIdRef.current)
+ }
+ rafIdRef.current = requestAnimationFrame(() => {
+ updateTruncation()
+ rafIdRef.current = null
+ })
+ }, [updateTruncation])
+
+ // Stable callback ref
+ const containerRef = useCallback(
+ (node: T | null) => {
+ // Cleanup previous observer
+ if (resizeObserverRef.current) {
+ resizeObserverRef.current.disconnect()
+ resizeObserverRef.current = null
+ }
+
+ elementRef.current = node
+
+ if (node) {
+ // Initial calculation (immediate, no debounce)
+ updateTruncation()
+
+ // Watch for container size changes (debounced)
+ resizeObserverRef.current = new ResizeObserver(scheduleUpdate)
+ resizeObserverRef.current.observe(node)
+ }
+ },
+ [updateTruncation, scheduleUpdate]
+ )
+
+ // Update when address or padding changes
+ useEffect(() => {
+ lastWidthRef.current = 0 // Reset to force recalculation
+ updateTruncation()
+ }, [address, padding, updateTruncation])
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (resizeObserverRef.current) {
+ resizeObserverRef.current.disconnect()
+ }
+ if (rafIdRef.current) {
+ cancelAnimationFrame(rafIdRef.current)
+ }
+ }
+ }, [])
+
+ return {
+ containerRef,
+ truncatedAddress,
+ }
+}
diff --git a/src/hooks/useBravePWAInstallState.ts b/src/hooks/useBravePWAInstallState.ts
new file mode 100644
index 000000000..72ce6a4ce
--- /dev/null
+++ b/src/hooks/useBravePWAInstallState.ts
@@ -0,0 +1,67 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { usePWAStatus } from './usePWAStatus'
+import { BrowserType, useGetBrowserType } from './useGetBrowserType'
+
+type InstalledRelatedApp = { platform: string; url?: string; id?: string; version?: string }
+
+/**
+ * tracks whether the user is on Brave and has the Peanut PWA installed.
+ *
+ * combines:
+ * - current PWA display mode (standalone/web)
+ * - `navigator.getInstalledRelatedApps` (when available)
+ * - `appinstalled` event
+ */
+export const useBravePWAInstallState = () => {
+ const isStandalonePWA = usePWAStatus()
+ const { browserType } = useGetBrowserType()
+ const [hasInstalledRelatedApp, setHasInstalledRelatedApp] = useState(false)
+
+ useEffect(() => {
+ if (typeof window === 'undefined') return
+
+ const _navigator = window.navigator as Navigator & {
+ getInstalledRelatedApps?: () => Promise
+ }
+
+ if (typeof _navigator.getInstalledRelatedApps !== 'function') return
+
+ let cancelled = false
+
+ const checkInstallation = async () => {
+ try {
+ const installedApps = await _navigator.getInstalledRelatedApps!()
+ if (!cancelled) {
+ setHasInstalledRelatedApp(installedApps.length > 0)
+ }
+ } catch {
+ if (!cancelled) {
+ setHasInstalledRelatedApp(false)
+ }
+ }
+ }
+
+ void checkInstallation()
+
+ const handleAppInstalled = () => {
+ if (!cancelled) {
+ setHasInstalledRelatedApp(true)
+ }
+ }
+
+ window.addEventListener('appinstalled', handleAppInstalled)
+
+ return () => {
+ cancelled = true
+ window.removeEventListener('appinstalled', handleAppInstalled)
+ }
+ }, [])
+
+ const isPWAInstalled = isStandalonePWA || hasInstalledRelatedApp
+ const isBrave = browserType === BrowserType.BRAVE
+ const isBravePWAInstalled = isBrave && isPWAInstalled
+
+ return { isBrave, isPWAInstalled, isBravePWAInstalled }
+}
diff --git a/src/hooks/useBridgeKycFlow.ts b/src/hooks/useBridgeKycFlow.ts
index adbb746cc..2cde6dead 100644
--- a/src/hooks/useBridgeKycFlow.ts
+++ b/src/hooks/useBridgeKycFlow.ts
@@ -3,7 +3,7 @@ import { useRouter } from 'next/navigation'
import { type IFrameWrapperProps } from '@/components/Global/IframeWrapper'
import { useWebSocket } from '@/hooks/useWebSocket'
import { useUserStore } from '@/redux/hooks'
-import { type BridgeKycStatus, convertPersonaUrl } from '@/utils'
+import { type BridgeKycStatus, convertPersonaUrl } from '@/utils/bridge-accounts.utils'
import { type InitiateKycResponse } from '@/app/actions/types/users.types'
import { getKycDetails, updateUserById } from '@/app/actions/users'
import { type IUserKycVerification } from '@/interfaces'
diff --git a/src/hooks/useContacts.ts b/src/hooks/useContacts.ts
index 84ee7074a..9f8b70b55 100644
--- a/src/hooks/useContacts.ts
+++ b/src/hooks/useContacts.ts
@@ -8,21 +8,24 @@ export type { Contact }
interface UseContactsOptions {
limit?: number
+ search?: string
}
/**
- * hook to fetch all contacts for the current user with infinite scroll
+ * hook to fetch all contacts for the current user with infinite scroll and optional search
* includes: inviter, invitees, and all transaction counterparties (sent/received money, request pots)
+ * when search is provided, filters contacts by username or full name on the server
*/
export function useContacts(options: UseContactsOptions = {}) {
- const { limit = 50 } = options
+ const { limit = 50, search } = options
const { data, isLoading, error, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = useInfiniteQuery({
- queryKey: [CONTACTS, limit],
+ queryKey: [CONTACTS, limit, search],
queryFn: async ({ pageParam = 0 }): Promise => {
const result = await getContacts({
limit,
offset: pageParam * limit,
+ search,
})
if (result.error) {
diff --git a/src/hooks/useGetBrowserType.ts b/src/hooks/useGetBrowserType.ts
index 7b2e29c8f..2eadba972 100644
--- a/src/hooks/useGetBrowserType.ts
+++ b/src/hooks/useGetBrowserType.ts
@@ -53,17 +53,17 @@ export const useGetBrowserType = () => {
return BrowserType.SAMSUNG
}
+ // Check for Brave browser BEFORE Chrome (Brave uses Chrome UA so must check first)
+ if ((navigator as any).brave && (await (navigator as any).brave.isBrave?.()) === true) {
+ return BrowserType.BRAVE
+ }
+
// Check for Chrome (desktop and mobile)
// CriOS = Chrome on iOS
if (userAgent.includes('chrome') || userAgent.includes('crios')) {
return BrowserType.CHROME
}
- // Check for Brave browser (uses Chrome UA)
- if ((navigator as any).brave && (await (navigator as any).brave.isBrave?.()) === true) {
- return BrowserType.BRAVE
- }
-
// Check for Safari (desktop and mobile - must be last since all iOS browsers include "safari" in UA)
if (userAgent.includes('safari')) {
return BrowserType.SAFARI
diff --git a/src/hooks/useHoldToClaim.ts b/src/hooks/useHoldToClaim.ts
index 4618547ef..27acdb961 100644
--- a/src/hooks/useHoldToClaim.ts
+++ b/src/hooks/useHoldToClaim.ts
@@ -1,5 +1,5 @@
+import { PERK_HOLD_DURATION_MS } from '@/constants/general.consts'
import { useCallback, useEffect, useRef, useState } from 'react'
-import { PERK_HOLD_DURATION_MS } from '@/constants'
export type ShakeIntensity = 'none' | 'weak' | 'medium' | 'strong' | 'intense'
diff --git a/src/hooks/useHomeCarouselCTAs.tsx b/src/hooks/useHomeCarouselCTAs.tsx
index 5cc35372b..70a43822c 100644
--- a/src/hooks/useHomeCarouselCTAs.tsx
+++ b/src/hooks/useHomeCarouselCTAs.tsx
@@ -7,14 +7,11 @@ import { useNotifications } from './useNotifications'
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, STAR_STRAIGHT_ICON } from '@/assets'
-import { DEVCONNECT_INTENT_EXPIRY_MS } from '@/constants'
+import { useModalsContext } from '@/context/ModalsContext'
import { DeviceType, useDeviceType } from './useGetDeviceType'
import { usePWAStatus } from './usePWAStatus'
-import { useModalsContext } from '@/context/ModalsContext'
import { useGeoLocation } from './useGeoLocation'
+import { STAR_STRAIGHT_ICON } from '@/assets'
export type CarouselCTA = {
id: string
@@ -42,54 +39,9 @@ export const useHomeCarouselCTAs = () => {
const isPwa = usePWAStatus()
const { setIsIosPwaInstallModalOpen } = useModalsContext()
- const { setIsQRScannerOpen } = useQrCodeContext()
+ const { setIsQRScannerOpen } = useModalsContext()
const { countryCode: userCountryCode } = useGeoLocation()
- // --------------------------------------------------------------------------------------------------
- /**
- * 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[] = []
@@ -111,7 +63,6 @@ export const useHomeCarouselCTAs = () => {
},
})
}
-
// show notification cta only in pwa when notifications are not granted
// clicking it triggers native prompt (or shows reinstall modal if denied)
if (!isPermissionGranted && isPwa) {
@@ -190,34 +141,6 @@ export const useHomeCarouselCTAs = () => {
iconSize: 16,
})
}
- // ------------------------------------------------------------------------------------------------
-
- // ------------------------------------------------------------------------------------------------
- // 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: [],
- })
- }
- },
- })
- }
- // --------------------------------------------------------------------------------------------------
if (!hasKycApproval && !isUserBridgeKycUnderReview) {
_carouselCTAs.push({
@@ -242,7 +165,6 @@ export const useHomeCarouselCTAs = () => {
setCarouselCTAs(_carouselCTAs)
}, [
- pendingDevConnectIntent,
user?.user?.userId,
isPermissionGranted,
isPermissionDenied,
diff --git a/src/hooks/useLogin.tsx b/src/hooks/useLogin.tsx
index 1c4c66183..57f96abc5 100644
--- a/src/hooks/useLogin.tsx
+++ b/src/hooks/useLogin.tsx
@@ -1,7 +1,7 @@
import { useAuth } from '@/context/authContext'
import { useZeroDev } from './useZeroDev'
import { useEffect, useState } from 'react'
-import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils'
+import { getRedirectUrl, getValidRedirectUrl, clearRedirectUrl } from '@/utils/general.utils'
import { useRouter, useSearchParams } from 'next/navigation'
/**
diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts
index ff57a2c82..b0f0864ab 100644
--- a/src/hooks/useMantecaKycFlow.ts
+++ b/src/hooks/useMantecaKycFlow.ts
@@ -3,9 +3,9 @@ import type { IFrameWrapperProps } from '@/components/Global/IframeWrapper'
import { mantecaApi } from '@/services/manteca'
import { useAuth } from '@/context/authContext'
import { type CountryData, MantecaSupportedExchanges } from '@/components/AddMoney/consts'
-import { BASE_URL } from '@/constants'
import { MantecaKycStatus } from '@/interfaces'
import { useWebSocket } from './useWebSocket'
+import { BASE_URL } from '@/constants/general.consts'
type UseMantecaKycFlowOptions = {
onClose?: () => void
diff --git a/src/hooks/useNonEurSepaRedirect.ts b/src/hooks/useNonEurSepaRedirect.ts
deleted file mode 100644
index cf4d13395..000000000
--- a/src/hooks/useNonEurSepaRedirect.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { useEffect, useMemo } from 'react'
-import { useRouter } from 'next/navigation'
-import {
- countryData,
- NON_EUR_SEPA_ALPHA2,
- ALL_COUNTRIES_ALPHA3_TO_ALPHA2,
- type CountryData,
-} from '@/components/AddMoney/consts'
-import countryCurrencyMappings from '@/constants/countryCurrencyMapping'
-
-interface UseNonEurSepaRedirectOptions {
- countryIdentifier?: string // the country path or code (e.g., 'united-kingdom' or 'GB')
- redirectPath?: string // redirect path on failure (e.g., '/add-money' or '/withdraw')
- shouldRedirect?: boolean // whether to actually redirect or just return the status
-}
-
-interface UseNonEurSepaRedirectResult {
- isBlocked: boolean // true if the country is a non-eur sepa country and bank operations should be blocked
- country: CountryData | null // the detected country object
-}
-
-/**
- * hook to check if a country is a non-eur sepa country and optionally redirect
- * non-eur sepa countries are those that use sepa but don't have eur as their currency
- * (e.g., poland with pln, hungary with huf, etc.)
- */
-export function useNonEurSepaRedirect({
- countryIdentifier,
- redirectPath,
- shouldRedirect = true,
-}: UseNonEurSepaRedirectOptions = {}): UseNonEurSepaRedirectResult {
- const router = useRouter()
-
- // find the country from the identifier (could be path, iso2, or iso3)
- const country = useMemo(() => {
- if (!countryIdentifier) return null
-
- // try to find by path first
- let found = countryData.find((c) => c.type === 'country' && c.path === countryIdentifier.toLowerCase())
-
- // if not found, try by iso2
- if (!found) {
- found = countryData.find(
- (c) => c.type === 'country' && c.iso2?.toLowerCase() === countryIdentifier.toLowerCase()
- )
- }
-
- // if not found, try by iso3
- if (!found) {
- found = countryData.find(
- (c) => c.type === 'country' && c.iso3?.toLowerCase() === countryIdentifier.toLowerCase()
- )
- }
-
- return found || null
- }, [countryIdentifier])
-
- // determine if the country should be blocked for bank operations
- const isBlocked = useMemo(() => {
- if (!country || country.type !== 'country') return false
-
- // get the 2-letter country code for the check
- let countryCode: string | undefined
-
- // try to get from iso2 first
- if (country.iso2) {
- countryCode = country.iso2
- }
- // otherwise try to map from iso3
- else if (country.iso3 && ALL_COUNTRIES_ALPHA3_TO_ALPHA2[country.iso3]) {
- countryCode = ALL_COUNTRIES_ALPHA3_TO_ALPHA2[country.iso3]
- }
- // fallback to id
- else {
- countryCode = country.id
- }
-
- if (!countryCode) return false
-
- // check if it's in the non-eur sepa set
- if (NON_EUR_SEPA_ALPHA2.has(countryCode)) {
- return true
- }
-
- // additional check using currency mappings
- // this catches countries where currency is not usd/eur/mxn
- const currencyMapping = countryCurrencyMappings.find(
- (currency) =>
- countryCode?.toLowerCase() === currency.country.toLowerCase() ||
- currency.path?.toLowerCase() === country.path?.toLowerCase()
- )
-
- const isNonStandardCurrency = !!(
- currencyMapping &&
- currencyMapping.currencyCode &&
- currencyMapping.currencyCode !== 'EUR' &&
- currencyMapping.currencyCode !== 'USD' &&
- currencyMapping.currencyCode !== 'MXN'
- )
-
- return isNonStandardCurrency
- }, [country])
-
- // redirect if needed
- useEffect(() => {
- if (isBlocked && shouldRedirect && redirectPath) {
- router.replace(redirectPath)
- }
- }, [isBlocked, shouldRedirect, redirectPath, router])
-
- return {
- isBlocked,
- country,
- }
-}
diff --git a/src/hooks/useNotifications.ts b/src/hooks/useNotifications.ts
index 3fdd84bbd..6388138b0 100644
--- a/src/hooks/useNotifications.ts
+++ b/src/hooks/useNotifications.ts
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import OneSignal from 'react-onesignal'
-import { getUserPreferences, updateUserPreferences } from '@/utils'
+import { getUserPreferences, updateUserPreferences } from '@/utils/general.utils'
import { useUserStore } from '@/redux/hooks'
export function useNotifications() {
diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts
deleted file mode 100644
index 528494636..000000000
--- a/src/hooks/usePaymentInitiator.ts
+++ /dev/null
@@ -1,809 +0,0 @@
-import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants'
-import { tokenSelectorContext } from '@/context'
-import { useWallet } from '@/hooks/wallet/useWallet'
-import { type ParsedURL } from '@/lib/url-parser/types/payment'
-import { useAppDispatch, usePaymentStore } from '@/redux/hooks'
-import { paymentActions } from '@/redux/slices/payment-slice'
-import { type IAttachmentOptions } from '@/redux/types/send-flow.types'
-import { chargesApi } from '@/services/charges'
-import { requestsApi } from '@/services/requests'
-import {
- type CreateChargeRequest,
- type PaymentCreationResponse,
- type TCharge,
- type TChargeTransactionType,
- type TRequestChargeResponse,
-} from '@/services/services.types'
-import { areEvmAddressesEqual, ErrorHandler, isNativeCurrency, isTxReverted } from '@/utils'
-import { useAppKitAccount } from '@reown/appkit/react'
-import { peanut, interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
-import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
-import { parseUnits } from 'viem'
-import type { TransactionReceipt, Address, Hex, Hash } from 'viem'
-import { useConfig, useSendTransaction, useSwitchChain, useAccount as useWagmiAccount } from 'wagmi'
-import { waitForTransactionReceipt } from 'wagmi/actions'
-import { getRoute, type PeanutCrossChainRoute } from '@/services/swap'
-import { estimateTransactionCostUsd } from '@/app/actions/tokens'
-import { captureException } from '@sentry/nextjs'
-import { useRouter } from 'next/navigation'
-
-enum ELoadingStep {
- IDLE = 'Idle',
- PREPARING_TRANSACTION = 'Preparing Transaction',
- SENDING_TRANSACTION = 'Sending Transaction',
- CONFIRMING_TRANSACTION = 'Confirming Transaction',
- UPDATING_PAYMENT_STATUS = 'Updating Payment Status',
- CHARGE_CREATED = 'Charge Created',
- ERROR = 'Error',
- SUCCESS = 'Success',
- FETCHING_CHARGE_DETAILS = 'Fetching Charge Details',
- CREATING_CHARGE = 'Creating Charge',
- SWITCHING_NETWORK = 'Switching Network',
-}
-
-type LoadingStep = `${ELoadingStep}`
-
-export interface InitiatePaymentPayload {
- recipient: ParsedURL['recipient']
- tokenAmount: string
- chargeId?: string
- skipChargeCreation?: boolean
- requestId?: string // optional request ID from URL
- currency?: {
- code: string
- symbol: string
- price: number
- }
- currencyAmount?: string
- isExternalWalletFlow?: boolean
- transactionType?: TChargeTransactionType
- attachmentOptions?: IAttachmentOptions
- returnAfterChargeCreation?: boolean
-}
-
-interface InitiationResult {
- status: string
- charge?: TRequestChargeResponse | null
- payment?: PaymentCreationResponse | null
- error?: string | null
- txHash?: string | null
- success?: boolean
-}
-
-// hook for handling payment initiation and processing
-export const usePaymentInitiator = () => {
- const dispatch = useAppDispatch()
- const { requestDetails, chargeDetails: chargeDetailsFromStore, currentView } = usePaymentStore()
- const { selectedTokenData, selectedChainID, selectedTokenAddress, setIsXChain } = useContext(tokenSelectorContext)
- const { isConnected: isPeanutWallet, address: peanutWalletAddress, sendTransactions, sendMoney } = useWallet()
- const { switchChainAsync } = useSwitchChain()
- const { address: wagmiAddress } = useAppKitAccount()
- const { sendTransactionAsync } = useSendTransaction()
- const router = useRouter()
- const config = useConfig()
- const { chain: connectedWalletChain } = useWagmiAccount()
-
- const [slippagePercentage, setSlippagePercentage] = useState(undefined)
- const [unsignedTx, setUnsignedTx] = useState(null)
- const [xChainUnsignedTxs, setXChainUnsignedTxs] = useState(
- null
- )
- const [isFeeEstimationError, setIsFeeEstimationError] = useState(false)
-
- const [isCalculatingFees, setIsCalculatingFees] = useState(false)
- const [isPreparingTx, setIsPreparingTx] = useState(false)
-
- const [estimatedGasCostUsd, setEstimatedGasCostUsd] = useState(undefined)
- const [estimatedFromValue, setEstimatedFromValue] = useState('0')
- const [loadingStep, setLoadingStep] = useState('Idle')
- const [error, setError] = useState(null)
- const [createdChargeDetails, setCreatedChargeDetails] = useState(null)
- const [transactionHash, setTransactionHash] = useState(null)
- const [paymentDetails, setPaymentDetails] = useState(null)
- const [isEstimatingGas, setIsEstimatingGas] = useState(false)
- const [xChainRoute, setXChainRoute] = useState(undefined)
-
- // use chargeDetails from the store primarily, fallback to createdChargeDetails
- const activeChargeDetails = useMemo(
- () => chargeDetailsFromStore ?? createdChargeDetails,
- [chargeDetailsFromStore, createdChargeDetails]
- )
-
- const isXChain = useMemo(() => {
- if (!activeChargeDetails || !selectedChainID) return false
- return selectedChainID !== activeChargeDetails.chainId
- }, [activeChargeDetails, selectedChainID])
-
- const diffTokens = useMemo(() => {
- if (!selectedTokenData || !activeChargeDetails) return false
- return !areEvmAddressesEqual(selectedTokenData.address, activeChargeDetails.tokenAddress)
- }, [selectedTokenData, activeChargeDetails])
-
- const isProcessing = useMemo(
- () =>
- loadingStep !== 'Idle' &&
- loadingStep !== 'Success' &&
- loadingStep !== 'Error' &&
- loadingStep !== 'Charge Created',
- [loadingStep]
- )
- const reset = useCallback(() => {
- setError(null)
- setLoadingStep('Idle')
- setIsFeeEstimationError(false)
- setIsCalculatingFees(false)
- setIsPreparingTx(false)
- setIsEstimatingGas(false)
- setUnsignedTx(null)
- setXChainUnsignedTxs(null)
- setXChainRoute(undefined)
- setEstimatedFromValue('0')
- setSlippagePercentage(undefined)
- setEstimatedGasCostUsd(undefined)
- setTransactionHash(null)
- setPaymentDetails(null)
- setCreatedChargeDetails(null)
- }, [])
-
- // reset state
- useEffect(() => {
- reset()
- }, [selectedChainID, selectedTokenAddress, requestDetails, reset])
-
- const handleError = useCallback(
- (err: unknown, step: string): InitiationResult => {
- console.error(`Error during ${step}:`, err)
- const errorMessage = ErrorHandler(err)
- setError(errorMessage)
- setLoadingStep('Error')
- if (activeChargeDetails && step !== 'Creating Charge') {
- const currentUrl = new URL(window.location.href)
- if (currentUrl.searchParams.get('chargeId') === activeChargeDetails.uuid) {
- const newUrl = new URL(window.location.href)
- newUrl.searchParams.delete('chargeId')
- // Use router.push (not window.history.replaceState) so that
- // the components using the search params will be updated
- router.push(newUrl.pathname + newUrl.search)
- }
- }
- return {
- status: 'Error',
- error: errorMessage,
- charge: activeChargeDetails,
- success: false,
- }
- },
- [activeChargeDetails, router]
- )
-
- // prepare transaction details (called from Confirm view)
- const prepareTransactionDetails = useCallback(
- async ({
- chargeDetails,
- from,
- usdAmount,
- disableCoral = false,
- }: {
- chargeDetails: TRequestChargeResponse
- from: {
- address: Address
- tokenAddress: Address
- chainId: string
- }
- usdAmount?: string
- disableCoral?: boolean
- }) => {
- setError(null)
- setIsFeeEstimationError(false)
- setUnsignedTx(null)
- setXChainUnsignedTxs(null)
- setXChainRoute(undefined)
-
- setEstimatedGasCostUsd(undefined)
-
- setIsPreparingTx(true)
-
- try {
- const _isXChain = from.chainId !== chargeDetails.chainId
- const _diffTokens = !areEvmAddressesEqual(from.tokenAddress, chargeDetails.tokenAddress)
- setIsXChain(_isXChain)
-
- if (_isXChain || _diffTokens) {
- setLoadingStep('Preparing Transaction')
- setIsCalculatingFees(true)
- const amount = usdAmount
- ? {
- fromUsd: usdAmount,
- }
- : {
- toAmount: parseUnits(chargeDetails.tokenAmount, chargeDetails.tokenDecimals),
- }
- const xChainRoute = await getRoute(
- {
- from,
- to: {
- address: chargeDetails.requestLink.recipientAddress as Address,
- tokenAddress: chargeDetails.tokenAddress as Address,
- chainId: chargeDetails.chainId,
- },
- ...amount,
- },
- { disableCoral }
- )
-
- if (xChainRoute.error) throw new Error(xChainRoute.error)
-
- const slippagePercentage = Number(xChainRoute.fromAmount) / Number(chargeDetails.tokenAmount) - 1
- setXChainRoute(xChainRoute)
- setXChainUnsignedTxs(
- xChainRoute.transactions.map((tx) => ({
- to: tx.to,
- data: tx.data,
- value: BigInt(tx.value),
- }))
- )
- setIsCalculatingFees(false)
- setEstimatedGasCostUsd(xChainRoute.feeCostsUsd)
- setEstimatedFromValue(xChainRoute.fromAmount)
- setSlippagePercentage(slippagePercentage)
- } else {
- setLoadingStep('Preparing Transaction')
- const tx = peanut.prepareRequestLinkFulfillmentTransaction({
- recipientAddress: chargeDetails.requestLink.recipientAddress,
- tokenAddress: chargeDetails.tokenAddress,
- tokenAmount: chargeDetails.tokenAmount,
- tokenDecimals: chargeDetails.tokenDecimals,
- tokenType: Number(chargeDetails.tokenType) as peanutInterfaces.EPeanutLinkType,
- })
-
- if (!tx?.unsignedTx) {
- throw new Error('Failed to prepare transaction')
- }
-
- setIsCalculatingFees(true)
- let gasCost = 0
- if (!isPeanutWallet) {
- try {
- gasCost = await estimateTransactionCostUsd(
- tx.unsignedTx.from! as Address,
- tx.unsignedTx.to! as Address,
- tx.unsignedTx.data! as Hex,
- chargeDetails.chainId
- )
- } catch (error) {
- captureException(error)
- setIsFeeEstimationError(true)
- }
- }
- setEstimatedGasCostUsd(gasCost)
- setIsCalculatingFees(false)
- setUnsignedTx(tx.unsignedTx)
- setEstimatedFromValue(chargeDetails.tokenAmount)
- setSlippagePercentage(undefined)
- }
- setLoadingStep('Idle')
- } catch (err) {
- console.error('Error preparing transaction details:', err)
- const errorMessage = ErrorHandler(err)
- setError(errorMessage)
- setIsFeeEstimationError(true)
- setLoadingStep('Error')
- } finally {
- setIsPreparingTx(false)
- }
- },
- [setIsXChain, isPeanutWallet]
- )
-
- // helper function: determine charge details (fetch or create)
- const determineChargeDetails = useCallback(
- async (
- payload: InitiatePaymentPayload
- ): Promise<{ chargeDetails: TRequestChargeResponse; chargeCreated: boolean }> => {
- let chargeDetailsToUse: TRequestChargeResponse | null = null
- let chargeCreated = false
-
- if (payload.chargeId) {
- chargeDetailsToUse = activeChargeDetails
- if (!chargeDetailsToUse || chargeDetailsToUse.uuid !== payload.chargeId) {
- setLoadingStep('Fetching Charge Details')
- chargeDetailsToUse = await chargesApi.get(payload.chargeId)
- setCreatedChargeDetails(chargeDetailsToUse)
- }
- } else {
- setLoadingStep('Creating Charge')
- let validRequestId: string | undefined = payload.requestId
-
- if (payload.requestId) {
- try {
- const request = await requestsApi.get(payload.requestId)
- validRequestId = request.uuid
- } catch (error) {
- console.error('Invalid request ID provided:', payload.requestId, error)
- throw new Error('Invalid request ID')
- }
- } else if (!requestDetails) {
- console.error('Request details not found and cannot create new one for this payment type.')
- throw new Error('Request details not found.')
- } else {
- validRequestId = requestDetails.uuid
- }
-
- if (!validRequestId) {
- console.error('Could not determine request ID for charge creation.')
- throw new Error('Could not determine request ID')
- }
-
- const recipientChainId = requestDetails?.chainId ?? selectedChainID
- const recipientTokenAddress = requestDetails?.tokenAddress ?? selectedTokenAddress
- const recipientTokenSymbol = requestDetails?.tokenSymbol ?? selectedTokenData?.symbol ?? 'TOKEN'
- const recipientTokenDecimals = requestDetails?.tokenDecimals ?? selectedTokenData?.decimals ?? 18
-
- const localPrice =
- payload.currencyAmount && payload.currency
- ? { amount: payload.currencyAmount, currency: payload.currency.code }
- : { amount: payload.tokenAmount, currency: 'USD' }
- const createChargeRequestPayload: CreateChargeRequest = {
- pricing_type: 'fixed_price',
- local_price: localPrice,
- baseUrl: window.location.origin,
- requestId: validRequestId,
- requestProps: {
- chainId: recipientChainId,
- tokenAmount: payload.tokenAmount,
- tokenAddress: recipientTokenAddress,
- tokenType: isNativeCurrency(recipientTokenAddress)
- ? peanutInterfaces.EPeanutLinkType.native
- : peanutInterfaces.EPeanutLinkType.erc20,
- tokenSymbol: recipientTokenSymbol,
- tokenDecimals: Number(recipientTokenDecimals),
- recipientAddress: payload.recipient?.resolvedAddress,
- },
- transactionType: payload.transactionType,
- }
-
- // add attachment if present
- if (payload.attachmentOptions?.rawFile) {
- createChargeRequestPayload.attachment = payload.attachmentOptions.rawFile
- createChargeRequestPayload.filename = payload.attachmentOptions.rawFile.name
- }
- if (payload.attachmentOptions?.message) {
- createChargeRequestPayload.reference = payload.attachmentOptions.message
- }
-
- if (payload.attachmentOptions?.rawFile?.type) {
- createChargeRequestPayload.mimeType = payload.attachmentOptions.rawFile.type
- }
-
- console.log('Creating charge with payload:', createChargeRequestPayload)
- const charge: TCharge = await chargesApi.create(createChargeRequestPayload)
- console.log('Charge created response:', charge)
-
- if (!charge.data.id) {
- console.error('CRITICAL: Charge created but UUID (ID) is missing!', charge.data)
- throw new Error('Charge created successfully, but is missing a UUID.')
- }
-
- // fetch the charge using the correct ID field from the response
- chargeDetailsToUse = await chargesApi.get(charge.data.id)
- console.log('Fetched charge details:', chargeDetailsToUse)
-
- dispatch(paymentActions.setChargeDetails(chargeDetailsToUse))
- setCreatedChargeDetails(chargeDetailsToUse) // keep track of the newly created charge
- chargeCreated = true
-
- // update URL
- const currentUrl = new URL(window.location.href)
- if (currentUrl.searchParams.get('chargeId') !== chargeDetailsToUse.uuid) {
- const newUrl = new URL(window.location.href)
- if (payload.requestId) newUrl.searchParams.delete('id')
- newUrl.searchParams.set('chargeId', chargeDetailsToUse.uuid)
- // Use router.push (not window.history.replaceState) so that
- // the components using the search params will be updated
- router.push(newUrl.pathname + newUrl.search)
- console.log('Updated URL with chargeId:', newUrl.href)
- }
- }
-
- // ensure we have charge details to proceed
- if (!chargeDetailsToUse) {
- console.error('Charge details are null after determination step.')
- throw new Error('Failed to load or create charge details.')
- }
-
- return { chargeDetails: chargeDetailsToUse, chargeCreated }
- },
- [dispatch, requestDetails, activeChargeDetails, selectedTokenData, selectedChainID, selectedTokenAddress]
- )
-
- // helper function: Handle Peanut Wallet payment
- const handlePeanutWalletPayment = useCallback(
- async (chargeDetails: TRequestChargeResponse): Promise => {
- setLoadingStep('Preparing Transaction')
-
- // validate required properties for preparing the transaction.
- if (
- !chargeDetails.requestLink?.recipientAddress ||
- !chargeDetails.tokenAddress ||
- !chargeDetails.tokenAmount ||
- chargeDetails.tokenDecimals === undefined ||
- chargeDetails.tokenType === undefined
- ) {
- console.error('Charge data is missing required properties for transaction preparation:', chargeDetails)
- throw new Error('Charge data is missing required properties for transaction preparation.')
- }
-
- let receipt: TransactionReceipt | null
- let userOpHash: Hash
- const transactionsToSend = xChainUnsignedTxs ?? (unsignedTx ? [unsignedTx] : null)
- if (transactionsToSend && transactionsToSend.length > 0) {
- setLoadingStep('Sending Transaction')
- const txResult = await sendTransactions(transactionsToSend, PEANUT_WALLET_CHAIN.id.toString())
- receipt = txResult.receipt
- userOpHash = txResult.userOpHash
- } else if (
- areEvmAddressesEqual(chargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) &&
- chargeDetails.chainId === PEANUT_WALLET_CHAIN.id.toString()
- ) {
- const txResult = await sendMoney(
- chargeDetails.requestLink.recipientAddress as `0x${string}`,
- chargeDetails.tokenAmount
- )
- receipt = txResult.receipt
- userOpHash = txResult.userOpHash
- } else {
- console.error('No transaction prepared to send for peanut wallet.')
- throw new Error('No transaction prepared to send.')
- }
-
- // validation of the received receipt.
- if (receipt !== null && isTxReverted(receipt)) {
- console.error('Transaction reverted according to receipt:', receipt)
- throw new Error(`Transaction failed (reverted). Hash: ${receipt.transactionHash}`)
- }
-
- const txHash = receipt?.transactionHash ?? userOpHash
- // update payment status in the backend api.
- setLoadingStep('Updating Payment Status')
- // peanut wallet flow: payer is the peanut wallet itself
- const payment: PaymentCreationResponse = await chargesApi.createPayment({
- chargeId: chargeDetails.uuid,
- chainId: PEANUT_WALLET_CHAIN.id.toString(),
- hash: txHash,
- tokenAddress: PEANUT_WALLET_TOKEN,
- payerAddress: peanutWalletAddress ?? '',
- })
- console.log('Backend payment creation response:', payment)
-
- setPaymentDetails(payment)
- dispatch(paymentActions.setPaymentDetails(payment))
- dispatch(paymentActions.setTransactionHash(txHash))
-
- setLoadingStep('Success')
- console.log('Peanut Wallet payment successful.')
- return { status: 'Success', charge: chargeDetails, payment, txHash: txHash, success: true }
- },
- [sendTransactions, xChainUnsignedTxs, unsignedTx, peanutWalletAddress]
- )
-
- // helper function: Handle External Wallet payment
- const handleExternalWalletPayment = useCallback(
- async (chargeDetails: TRequestChargeResponse): Promise => {
- const sourceChainId = Number(selectedChainID)
- const connectedChainId = connectedWalletChain?.id
- console.log(`Selected chain: ${sourceChainId}, Connected chain: ${connectedChainId}`)
-
- if (connectedChainId !== undefined && sourceChainId !== connectedChainId) {
- console.log(`Switching network from ${connectedChainId} to ${sourceChainId}`)
- setLoadingStep('Switching Network')
- try {
- await switchChainAsync({ chainId: sourceChainId })
- console.log(`Network switched successfully to ${sourceChainId}`)
- } catch (switchError: any) {
- console.error('Wallet network switch failed:', switchError)
- const message =
- switchError.shortMessage ||
- `Failed to switch network to chain ${sourceChainId}. Please switch manually in your wallet.`
- throw new Error(message) // throw error, to be caught by main initiatePayment function
- }
- }
-
- const transactionsToSend = xChainUnsignedTxs ?? (unsignedTx ? [unsignedTx] : null)
- if (!transactionsToSend || transactionsToSend.length === 0) {
- console.error('No transaction prepared to send for external wallet.')
- throw new Error('No transaction prepared to send.')
- }
- console.log('Transactions prepared for sending:', transactionsToSend)
-
- let receipt: TransactionReceipt
- const receipts: TransactionReceipt[] = []
- let currentStep = 'Sending Transaction'
-
- try {
- for (let i = 0; i < transactionsToSend.length; i++) {
- const tx = transactionsToSend[i]
- console.log(`Sending transaction ${i + 1}/${transactionsToSend.length}:`, tx)
- setLoadingStep(`Sending Transaction`)
- currentStep = 'Sending Transaction'
-
- const txGasOptions: any = {}
- console.log('Using gas options:', txGasOptions)
-
- const hash = await sendTransactionAsync({
- to: (tx.to ? tx.to : undefined) as `0x${string}` | undefined,
- value: tx.value ? BigInt(tx.value.toString()) : undefined,
- data: tx.data ? (tx.data as `0x${string}`) : undefined,
- ...txGasOptions,
- chainId: sourceChainId,
- })
- console.log(`Transaction ${i + 1} hash: ${hash}`)
-
- setLoadingStep(`Confirming Transaction`)
- currentStep = 'Confirming Transaction'
-
- const txReceipt = await waitForTransactionReceipt(config, {
- hash: hash,
- chainId: sourceChainId,
- confirmations: 1,
- })
- console.log(`Transaction ${i + 1} receipt:`, txReceipt)
- receipts.push(txReceipt)
-
- if (isTxReverted(txReceipt)) {
- console.error(`Transaction ${i + 1} reverted:`, txReceipt)
- throw new Error(`Transaction ${i + 1} failed (reverted).`)
- }
- }
- // check if receipts were actually generated
- if (receipts.length === 0 || !receipts[receipts.length - 1]) {
- console.error('Transaction sequence completed, but failed to get final receipt.')
- throw new Error('Transaction sent but failed to get receipt.')
- }
- receipt = receipts[receipts.length - 1] // use the last receipt
- } catch (txError: any) {
- // re-throw the error with the current step context
- console.error(`Transaction failed during ${currentStep}:`, txError)
- throw txError
- }
-
- const txHash = receipt.transactionHash
- setTransactionHash(txHash)
- dispatch(paymentActions.setTransactionHash(txHash))
- console.log('External wallet final transaction hash:', txHash)
-
- setLoadingStep('Updating Payment Status')
- console.log('Updating payment status in backend for external wallet. Hash:', txHash)
- // external wallet / add-money flow: payer is the connected wallet address
- const payment = await chargesApi.createPayment({
- chargeId: chargeDetails.uuid,
- chainId: sourceChainId.toString(),
- hash: txHash,
- tokenAddress: selectedTokenData?.address || chargeDetails.tokenAddress,
- payerAddress: wagmiAddress ?? '',
- })
- console.log('Backend payment creation response:', payment)
-
- setPaymentDetails(payment)
- dispatch(paymentActions.setPaymentDetails(payment))
-
- setLoadingStep('Success')
- console.log('External wallet payment successful.')
- return { status: 'Success', charge: chargeDetails, payment, txHash, success: true }
- },
- [
- dispatch,
- selectedChainID,
- connectedWalletChain,
- switchChainAsync,
- xChainUnsignedTxs,
- unsignedTx,
- sendTransactionAsync,
- config,
- selectedTokenData,
- wagmiAddress,
- ]
- )
-
- // @dev TODO: Refactor to TanStack Query mutation for architectural consistency
- // Current: This async function works correctly (protected by isProcessing state)
- // but is NOT tracked by usePendingTransactions mutation system.
- // Future improvement: Wrap in useMutation for consistency with other balance-decreasing ops.
- // mutationKey: [BALANCE_DECREASE, INITIATE_PAYMENT]
- // Complexity: HIGH - complex state/Redux integration. Low priority.
- //
- // initiate and process payments
- const initiatePayment = useCallback(
- async (payload: InitiatePaymentPayload): Promise => {
- setLoadingStep('Idle')
- setError(null)
- setTransactionHash(null)
- setPaymentDetails(null)
-
- let determinedChargeDetails: TRequestChargeResponse | null = null
- let chargeCreated = false
-
- try {
- // 1. determine Charge Details
- const { chargeDetails, chargeCreated: created } = await determineChargeDetails(payload)
- determinedChargeDetails = chargeDetails
- chargeCreated = created
- console.log('Proceeding with charge details:', determinedChargeDetails.uuid)
-
- // 2. handle charge state
- if (
- payload.returnAfterChargeCreation || // For request pot payment, return after charge creation
- (chargeCreated &&
- (payload.isExternalWalletFlow ||
- !isPeanutWallet ||
- (isPeanutWallet &&
- (!areEvmAddressesEqual(determinedChargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) ||
- determinedChargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString()))))
- ) {
- console.log(
- `Charge created. Transitioning to Confirm view for: ${
- payload.isExternalWalletFlow ? 'Add Money Flow' : 'External Wallet'
- }.`
- )
- setLoadingStep('Charge Created')
- return { status: 'Charge Created', charge: determinedChargeDetails, success: false }
- }
-
- // 3. if user is on the initial screen and chargeid is present, execute the handle charge state flow
- if (payload.isExternalWalletFlow && currentView === 'INITIAL' && payload.chargeId) {
- console.log('Executing add money flow: ChargeID already exists')
- setLoadingStep('Charge Created')
-
- return { status: 'Charge Created', charge: determinedChargeDetails, success: false }
- }
-
- // 4. execute payment based on wallet type
- if (payload.isExternalWalletFlow) {
- if (!wagmiAddress) {
- console.error('Add Money flow requires an external wallet (WAGMI) to be connected.')
- throw new Error('External wallet not connected for Add Money flow.')
- }
- console.log('Executing External Wallet transaction for Add Money flow.')
- // Ensure charge details are passed, even if just created.
- if (!determinedChargeDetails)
- throw new Error('Charge details missing for Add Money external payment.')
- return await handleExternalWalletPayment(determinedChargeDetails)
- } else if (isPeanutWallet && peanutWalletAddress) {
- console.log(`Executing Peanut Wallet transaction (chargeCreated: ${chargeCreated})`)
- return await handlePeanutWalletPayment(determinedChargeDetails)
- } else if (!isPeanutWallet) {
- console.log('Handling payment for External Wallet (non-AddMoney, called from Confirm view).')
- if (!determinedChargeDetails) throw new Error('Charge details missing for External Wallet payment.')
- return await handleExternalWalletPayment(determinedChargeDetails)
- } else {
- console.error('Invalid payment state: Could not determine wallet type or required action.')
- throw new Error('Invalid payment state: Could not determine wallet type or required action.')
- }
- } catch (err) {
- // Ensure chargeId is removed from URL if error occurs after creation attempt
- if (chargeCreated && determinedChargeDetails) {
- console.log('Error occurred after charge creation, removing chargeId from URL.')
- const currentUrl = new URL(window.location.href)
- if (currentUrl.searchParams.get('chargeId') === determinedChargeDetails.uuid) {
- const newUrl = new URL(window.location.href)
- newUrl.searchParams.delete('chargeId')
- // Use router.push (not window.history.replaceState) so that
- // the components using the search params will be updated
- router.push(newUrl.pathname + newUrl.search)
- console.log('URL updated, chargeId removed.')
- }
- }
- // handleError already logs the error and sets state
- return handleError(err, loadingStep)
- }
- },
- [
- determineChargeDetails,
- handlePeanutWalletPayment,
- handleExternalWalletPayment,
- isPeanutWallet,
- peanutWalletAddress,
- wagmiAddress,
- handleError,
- setLoadingStep,
- setError,
- router,
- setTransactionHash,
- setPaymentDetails,
- loadingStep,
- ]
- )
-
- const cancelOperation = useCallback(() => {
- setError('Please confirm the request in your wallet.')
- setLoadingStep('Error')
- }, [setError, setLoadingStep])
-
- const initiateDaimoPayment = useCallback(
- async (payload: InitiatePaymentPayload) => {
- try {
- console.log('handleDaimoPayment', payload)
- let determinedChargeDetails: TRequestChargeResponse | null = null
- const { chargeDetails } = await determineChargeDetails(payload)
-
- determinedChargeDetails = chargeDetails
- console.log('Proceeding with charge details:', determinedChargeDetails.uuid)
- return { status: 'Charge Created', charge: determinedChargeDetails, success: false }
- } catch (err) {
- return handleError(err, loadingStep)
- }
- },
- [determineChargeDetails, setLoadingStep, setPaymentDetails]
- )
-
- const completeDaimoPayment = useCallback(
- async ({
- chargeDetails,
- destinationchainId,
- txHash,
- payerAddress,
- sourceChainId,
- sourceTokenAddress,
- sourceTokenSymbol,
- }: {
- chargeDetails: TRequestChargeResponse
- txHash: string
- destinationchainId: number
- payerAddress: string
- sourceChainId: number
- sourceTokenAddress: string
- sourceTokenSymbol: string
- }) => {
- try {
- setLoadingStep('Updating Payment Status')
- const payment = await chargesApi.createPayment({
- chargeId: chargeDetails.uuid,
- chainId: destinationchainId.toString(),
- hash: txHash,
- tokenAddress: chargeDetails.tokenAddress,
- payerAddress,
- sourceChainId: sourceChainId?.toString(),
- sourceTokenAddress,
- sourceTokenSymbol,
- })
-
- setPaymentDetails(payment)
- dispatch(paymentActions.setPaymentDetails(payment))
-
- setLoadingStep('Success')
- console.log('Daimo payment successful.')
- return { status: 'Success', charge: chargeDetails, payment, txHash, success: true }
- } catch (err) {
- return handleError(err, loadingStep)
- }
- },
- [determineChargeDetails, setLoadingStep, setPaymentDetails]
- )
-
- return {
- initiatePayment,
- prepareTransactionDetails,
- isProcessing,
- isPreparingTx,
- loadingStep,
- setLoadingStep,
- error,
- activeChargeDetails,
- transactionHash,
- paymentDetails,
- slippagePercentage,
- estimatedFromValue,
- xChainUnsignedTxs,
- estimatedGasCostUsd,
- unsignedTx,
- isCalculatingFees,
- isFeeEstimationError,
- isEstimatingGas,
- isXChain,
- diffTokens,
- cancelOperation,
- xChainRoute,
- reset,
- completeDaimoPayment,
- initiateDaimoPayment,
- }
-}
diff --git a/src/hooks/useRedirectQrStatus.ts b/src/hooks/useRedirectQrStatus.ts
index ee299f592..732cca469 100644
--- a/src/hooks/useRedirectQrStatus.ts
+++ b/src/hooks/useRedirectQrStatus.ts
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query'
-import { PEANUT_API_URL } from '@/constants'
+import { PEANUT_API_URL } from '@/constants/general.consts'
interface RedirectQrStatusData {
claimed: boolean
diff --git a/src/hooks/useTokenPrice.ts b/src/hooks/useTokenPrice.ts
index 8ad152162..da78ac6ab 100644
--- a/src/hooks/useTokenPrice.ts
+++ b/src/hooks/useTokenPrice.ts
@@ -1,18 +1,17 @@
import { useQuery } from '@tanstack/react-query'
import { fetchTokenPrice } from '@/app/actions/tokens'
import {
+ PEANUT_WALLET_CHAIN,
PEANUT_WALLET_TOKEN,
PEANUT_WALLET_TOKEN_DECIMALS,
PEANUT_WALLET_TOKEN_SYMBOL,
PEANUT_WALLET_TOKEN_NAME,
- PEANUT_WALLET_CHAIN,
PEANUT_WALLET_TOKEN_IMG_URL,
- STABLE_COINS,
- supportedMobulaChains,
-} from '@/constants'
+} from '@/constants/zerodev.consts'
import { type ITokenPriceData } from '@/interfaces'
import * as Sentry from '@sentry/nextjs'
import { interfaces } from '@squirrel-labs/peanut-sdk'
+import { STABLE_COINS, supportedMobulaChains } from '@/constants/general.consts'
interface UseTokenPriceParams {
tokenAddress: string | undefined
diff --git a/src/hooks/useTransactionHistory.ts b/src/hooks/useTransactionHistory.ts
index 87f078dbf..dbf841f7b 100644
--- a/src/hooks/useTransactionHistory.ts
+++ b/src/hooks/useTransactionHistory.ts
@@ -1,11 +1,11 @@
-import { PEANUT_API_URL } from '@/constants'
import { TRANSACTIONS } from '@/constants/query.consts'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import type { InfiniteData, InfiniteQueryObserverResult, QueryObserverResult } from '@tanstack/react-query'
import { useInfiniteQuery, useQuery } from '@tanstack/react-query'
import Cookies from 'js-cookie'
import { completeHistoryEntry } from '@/utils/history.utils'
import type { HistoryEntry } from '@/utils/history.utils'
+import { PEANUT_API_URL } from '@/constants/general.consts'
//TODO: remove and import all from utils everywhere
export { EHistoryEntryType, EHistoryUserRole } from '@/utils/history.utils'
diff --git a/src/hooks/useUserByUsername.ts b/src/hooks/useUserByUsername.ts
index 2661a4fc7..4d1ebe77a 100644
--- a/src/hooks/useUserByUsername.ts
+++ b/src/hooks/useUserByUsername.ts
@@ -8,7 +8,7 @@ import { type ApiUser, usersApi } from '@/services/users'
*/
export const useUserByUsername = (username: string | null | undefined) => {
const [user, setUser] = useState(null)
- const [isLoading, setIsLoading] = useState(false)
+ const [isLoading, setIsLoading] = useState(!!username)
const [error, setError] = useState(null)
useEffect(() => {
diff --git a/src/hooks/useZeroDev.ts b/src/hooks/useZeroDev.ts
index c7cb01450..c8518ecd6 100644
--- a/src/hooks/useZeroDev.ts
+++ b/src/hooks/useZeroDev.ts
@@ -6,7 +6,8 @@ import { useAuth } from '@/context/authContext'
import { useKernelClient } from '@/context/kernelClient.context'
import { useAppDispatch, useSetupStore, useZerodevStore } from '@/redux/hooks'
import { zerodevActions } from '@/redux/slices/zerodev-slice'
-import { getFromCookie, removeFromCookie, saveToCookie, clearAuthState } from '@/utils'
+import { getFromCookie, removeFromCookie, saveToCookie } from '@/utils/general.utils'
+import { clearAuthState } from '@/utils/auth.utils'
import { toWebAuthnKey, WebAuthnMode } from '@zerodev/passkey-validator'
import { useCallback, useContext } from 'react'
import type { TransactionReceipt, Hex, Hash } from 'viem'
diff --git a/src/hooks/wallet/__tests__/useSendMoney.test.tsx b/src/hooks/wallet/__tests__/useSendMoney.test.tsx
index 89c32dc9a..d8fe210f3 100644
--- a/src/hooks/wallet/__tests__/useSendMoney.test.tsx
+++ b/src/hooks/wallet/__tests__/useSendMoney.test.tsx
@@ -8,16 +8,16 @@
* 4. Balance invalidation on success
*/
-import { renderHook, waitFor } from '@testing-library/react'
+import { renderHook, waitFor, act } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ToastProvider } from '@/components/0_Bruddle/Toast'
import { useSendMoney } from '../useSendMoney'
import { parseUnits } from 'viem'
-import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
+import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import type { ReactNode } from 'react'
// Mock dependencies
-jest.mock('@/constants', () => ({
+jest.mock('@/constants/zerodev.consts', () => ({
PEANUT_WALLET_TOKEN: '0x1234567890123456789012345678901234567890',
PEANUT_WALLET_TOKEN_DECIMALS: 6,
PEANUT_WALLET_CHAIN: { id: 137 },
@@ -72,20 +72,18 @@ describe('useSendMoney', () => {
{ wrapper }
)
- // Trigger mutation
- const promise = result.current.mutateAsync({
- toAddress: '0x9999999999999999999999999999999999999999' as `0x${string}`,
- amountInUsd: amountToSend,
- })
-
- // Check optimistic update happened immediately
- await waitFor(() => {
- const currentBalance = queryClient.getQueryData(['balance', mockAddress])
- const expectedBalance = initialBalance - parseUnits(amountToSend, PEANUT_WALLET_TOKEN_DECIMALS)
- expect(currentBalance).toEqual(expectedBalance)
+ // Trigger mutation and wait for completion
+ await act(async () => {
+ await result.current.mutateAsync({
+ toAddress: '0x9999999999999999999999999999999999999999' as `0x${string}`,
+ amountInUsd: amountToSend,
+ })
})
- await promise
+ // After successful mutation, balance should reflect the deduction
+ const currentBalance = queryClient.getQueryData(['balance', mockAddress])
+ const expectedBalance = initialBalance - parseUnits(amountToSend, PEANUT_WALLET_TOKEN_DECIMALS)
+ expect(currentBalance).toEqual(expectedBalance)
})
it('should NOT optimistically update balance when insufficient balance (prevents underflow)', async () => {
diff --git a/src/hooks/wallet/useBalance.ts b/src/hooks/wallet/useBalance.ts
index ef6a89fa4..2d5611a28 100644
--- a/src/hooks/wallet/useBalance.ts
+++ b/src/hooks/wallet/useBalance.ts
@@ -1,7 +1,8 @@
+import { PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
+import { peanutPublicClient } from '@/app/actions/clients'
import { useQuery } from '@tanstack/react-query'
import { erc20Abi } from 'viem'
import type { Address } from 'viem'
-import { PEANUT_WALLET_TOKEN, peanutPublicClient } from '@/constants'
/**
* Hook to fetch and auto-refresh wallet balance using TanStack Query
diff --git a/src/hooks/wallet/useSendMoney.ts b/src/hooks/wallet/useSendMoney.ts
index 2e4d5e9a0..d64ce3a07 100644
--- a/src/hooks/wallet/useSendMoney.ts
+++ b/src/hooks/wallet/useSendMoney.ts
@@ -1,7 +1,7 @@
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 { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS, BALANCE_DECREASE, SEND_MONEY } from '@/constants/query.consts'
import { useToast } from '@/components/0_Bruddle/Toast'
diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts
index 361c23b38..64c796e38 100644
--- a/src/hooks/wallet/useWallet.ts
+++ b/src/hooks/wallet/useWallet.ts
@@ -1,16 +1,17 @@
'use client'
-import { PEANUT_WALLET_CHAIN } from '@/constants'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { useAppDispatch, useWalletStore } from '@/redux/hooks'
import { walletActions } from '@/redux/slices/wallet-slice'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
-import { useCallback, useEffect } from 'react'
-import type { Hex, Address } from 'viem'
+import { useCallback, useEffect, useMemo } from 'react'
+import { formatUnits, type Hex, type Address } from 'viem'
import { useZeroDev } from '../useZeroDev'
import { useAuth } from '@/context/authContext'
import { AccountType } from '@/interfaces'
import { useBalance } from './useBalance'
import { useSendMoney as useSendMoneyMutation } from './useSendMoney'
+import { formatCurrency } from '@/utils/general.utils'
export const useWallet = () => {
const dispatch = useAppDispatch()
@@ -89,9 +90,29 @@ export const useWallet = () => {
// consider balance as fetching until: address is validated and query has resolved
const isBalanceLoading = !isAddressReady || isFetchingBalance
+ // formatted balance for display (e.g. "1,234.56")
+ const formattedBalance = useMemo(() => {
+ if (balance === undefined) return '0.00'
+ return formatCurrency(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS))
+ }, [balance])
+
+ // check if wallet has sufficient balance for a given usd amount
+ const hasSufficientBalance = useCallback(
+ (amountUsd: string | number): boolean => {
+ if (balance === undefined) return false
+ const amount = typeof amountUsd === 'string' ? parseFloat(amountUsd) : amountUsd
+ if (isNaN(amount) || amount < 0) return false
+ const amountInWei = BigInt(Math.floor(amount * 10 ** PEANUT_WALLET_TOKEN_DECIMALS))
+ return balance >= amountInWei
+ },
+ [balance]
+ )
+
return {
address: isAddressReady ? address : undefined, // populate address only if it is validated and matches the user's wallet address
balance,
+ formattedBalance,
+ hasSufficientBalance,
isConnected: isKernelClientReady,
sendTransactions,
sendMoney,
diff --git a/src/interfaces/attachment.ts b/src/interfaces/attachment.ts
new file mode 100644
index 000000000..9b6e0864f
--- /dev/null
+++ b/src/interfaces/attachment.ts
@@ -0,0 +1,11 @@
+/**
+ * shared attachment options type used across payment flows
+ *
+ * allows users to attach a message and/or file to payments.
+ * fileUrl is the uploaded url, rawFile is the original file object.
+ */
+export interface IAttachmentOptions {
+ fileUrl: string | undefined
+ message: string | undefined
+ rawFile: File | undefined
+}
diff --git a/src/interfaces/interfaces.ts b/src/interfaces/interfaces.ts
index 27b19a490..6d8df29c1 100644
--- a/src/interfaces/interfaces.ts
+++ b/src/interfaces/interfaces.ts
@@ -1,8 +1,10 @@
-import { type BridgeKycStatus } from '@/utils'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
export type RecipientType = 'address' | 'ens' | 'iban' | 'us' | 'username'
+// Moved here from bridge-accounts.utils.ts to avoid circular dependency
+export type BridgeKycStatus = 'not_started' | 'under_review' | 'approved' | 'rejected' | 'incomplete'
+
export interface IResponse {
success: boolean
data?: any
diff --git a/src/interfaces/wallet.interfaces.ts b/src/interfaces/wallet.interfaces.ts
index 505a8aed6..dcdbd4479 100644
--- a/src/interfaces/wallet.interfaces.ts
+++ b/src/interfaces/wallet.interfaces.ts
@@ -1,10 +1,11 @@
-import * as interfaces from '@/interfaces'
-
// based on API AccountType
+
+import { AccountType, type IUserBalance } from './interfaces'
+
// https://github.com/peanutprotocol/peanut-api-ts/blob/b32570b7bd366efed7879f607040c511fa036a57/src/db/interfaces/account.ts
export enum WalletProviderType {
- PEANUT = interfaces.AccountType.PEANUT_WALLET,
- BYOW = interfaces.AccountType.EVM_ADDRESS,
+ PEANUT = AccountType.PEANUT_WALLET,
+ BYOW = AccountType.EVM_ADDRESS,
REWARDS = 'rewards',
}
@@ -34,7 +35,7 @@ export interface IWallet extends IDBWallet {
id: string
connected: boolean
balance: bigint
- balances?: interfaces.IUserBalance[]
+ balances?: IUserBalance[]
}
export enum WalletErrorType {
diff --git a/src/lib/url-parser/parser.ts b/src/lib/url-parser/parser.ts
index 0cd0c247b..f04feeb0b 100644
--- a/src/lib/url-parser/parser.ts
+++ b/src/lib/url-parser/parser.ts
@@ -1,5 +1,5 @@
import { getSquidChainsAndTokens } from '@/app/actions/squid'
-import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
import { interfaces } from '@squirrel-labs/peanut-sdk'
import { validateAmount } from '../validation/amount'
import { validateAndResolveRecipient } from '../validation/recipient'
@@ -159,23 +159,13 @@ export async function parsePaymentURL(
tokenDetails = chainDetails.tokens.find((t) => t.symbol.toLowerCase() === 'USDC'.toLowerCase())
}
- // 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
+ // 6. 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 37f7d46ce..1c7a9b31e 100644
--- a/src/lib/url-parser/types/payment.ts
+++ b/src/lib/url-parser/types/payment.ts
@@ -13,6 +13,4 @@ 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/lib/validation/recipient.test.ts b/src/lib/validation/recipient.test.ts
index 41788ee76..ceae06feb 100644
--- a/src/lib/validation/recipient.test.ts
+++ b/src/lib/validation/recipient.test.ts
@@ -13,11 +13,11 @@ jest.mock('@/app/actions/ens', () => ({
},
}))
-jest.mock('@/utils', () => ({
+jest.mock('@/utils/sentry.utils', () => ({
fetchWithSentry: jest.fn(),
}))
-jest.mock('@/constants', () => ({
+jest.mock('@/constants/general.consts', () => ({
JUSTANAME_ENS: 'testvc.eth',
PEANUT_API_URL: process.env.NEXT_PUBLIC_PEANUT_API_URL,
}))
@@ -78,7 +78,7 @@ describe('Recipient Validation', () => {
it('should throw for invalid Peanut usernames', async () => {
// Mock failed API response
- const fetchWithSentry = require('@/utils').fetchWithSentry
+ const { fetchWithSentry } = require('@/utils/sentry.utils')
fetchWithSentry.mockResolvedValueOnce({ status: 404 })
await expect(validateAndResolveRecipient('lmaoo')).rejects.toThrow('Invalid Peanut username')
@@ -92,7 +92,7 @@ describe('Recipient Validation', () => {
describe('verifyPeanutUsername', () => {
it('should return true for valid usernames', async () => {
- const fetchWithSentry = require('@/utils').fetchWithSentry
+ const { fetchWithSentry } = require('@/utils/sentry.utils')
fetchWithSentry.mockResolvedValueOnce({ status: 200 })
const result = await verifyPeanutUsername('kusharc')
@@ -100,7 +100,7 @@ describe('Recipient Validation', () => {
})
it('should return false for invalid usernames', async () => {
- const fetchWithSentry = require('@/utils').fetchWithSentry
+ const { fetchWithSentry } = require('@/utils/sentry.utils')
fetchWithSentry.mockResolvedValueOnce({ status: 404 })
const result = await verifyPeanutUsername('invaliduser')
@@ -108,7 +108,7 @@ describe('Recipient Validation', () => {
})
it('should handle API errors gracefully', async () => {
- const fetchWithSentry = require('@/utils').fetchWithSentry
+ const { fetchWithSentry } = require('@/utils/sentry.utils')
fetchWithSentry.mockRejectedValueOnce(new Error('API Error'))
const result = await verifyPeanutUsername('someuser')
diff --git a/src/lib/validation/recipient.ts b/src/lib/validation/recipient.ts
index e7427c33a..429f3dcab 100644
--- a/src/lib/validation/recipient.ts
+++ b/src/lib/validation/recipient.ts
@@ -1,13 +1,14 @@
import { isAddress } from 'viem'
import { resolveEns } from '@/app/actions/ens'
-import { PEANUT_API_URL } from '@/constants'
+
import { AccountType } from '@/interfaces'
import { usersApi } from '@/services/users'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import * as Sentry from '@sentry/nextjs'
import { RecipientValidationError } from '../url-parser/errors'
import { type RecipientType } from '../url-parser/types/payment'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export async function validateAndResolveRecipient(
recipient: string,
diff --git a/src/lib/validation/token.test.ts b/src/lib/validation/token.test.ts
index 02db056da..40064dc97 100644
--- a/src/lib/validation/token.test.ts
+++ b/src/lib/validation/token.test.ts
@@ -95,7 +95,7 @@ const mockSquidChains: Record<
},
}
-jest.mock('@/constants', () => ({
+jest.mock('@/constants/zerodev.consts', () => ({
PEANUT_WALLET_CHAIN: {
id: '1',
name: 'Ethereum',
diff --git a/src/lib/validation/token.ts b/src/lib/validation/token.ts
index 33d43ebf0..a0500cd94 100644
--- a/src/lib/validation/token.ts
+++ b/src/lib/validation/token.ts
@@ -1,5 +1,5 @@
import { getSquidChainsAndTokens } from '@/app/actions/squid'
-import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants'
+import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.consts'
import { interfaces } from '@squirrel-labs/peanut-sdk'
import { ChainValidationError } from '../url-parser/errors'
import { POPULAR_CHAIN_NAME_VARIANTS } from '../url-parser/parser.consts'
diff --git a/src/redux/constants/index.ts b/src/redux/constants/index.ts
index 6a9b137fb..1ee1e58ed 100644
--- a/src/redux/constants/index.ts
+++ b/src/redux/constants/index.ts
@@ -1,7 +1,5 @@
export const SETUP = 'setup'
export const WALLET_SLICE = 'wallet_slice'
export const ZERODEV_SLICE = 'zerodev_slice'
-export const PAYMENT_SLICE = 'payment_slice'
export const AUTH_SLICE = 'auth_slice'
-export const SEND_FLOW_SLICE = 'send_flow_slice'
export const BANK_FORM_SLICE = 'bank_form_slice'
diff --git a/src/redux/hooks.ts b/src/redux/hooks.ts
index e551d90af..d8d7ef1f3 100644
--- a/src/redux/hooks.ts
+++ b/src/redux/hooks.ts
@@ -5,10 +5,8 @@ import { type AppDispatch, type RootState } from './types'
export const useAppDispatch = () => useDispatch()
export const useAppSelector: TypedUseSelectorHook = useSelector
-// Selector hooks for utilization
+// selector hooks for utilization
export const useSetupStore = () => useAppSelector((state) => state.setup)
export const useWalletStore = () => useAppSelector((state) => state.wallet)
export const useZerodevStore = () => useAppSelector((state) => state.zeroDev)
-export const usePaymentStore = () => useAppSelector((state) => state.payment)
export const useUserStore = () => useAppSelector((state) => state.user)
-export const useSendFlowStore = () => useAppSelector((state) => state.sendFlow)
diff --git a/src/redux/slices/payment-slice.ts b/src/redux/slices/payment-slice.ts
deleted file mode 100644
index e736048d6..000000000
--- a/src/redux/slices/payment-slice.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { type ParsedURL } from '@/lib/url-parser/types/payment'
-import {
- type PaymentCreationResponse,
- type TCharge,
- type TRequestChargeResponse,
- type TRequestResponse,
-} from '@/services/services.types'
-import { createSlice, type PayloadAction } from '@reduxjs/toolkit'
-import { PAYMENT_SLICE } from '../constants'
-import { type IPaymentState, type TPaymentView } from '../types/payment.types'
-import { type IAttachmentOptions } from '../types/send-flow.types'
-
-const initialState: IPaymentState = {
- currentView: 'INITIAL',
- attachmentOptions: {
- fileUrl: '',
- message: '',
- rawFile: undefined,
- },
- parsedPaymentData: null,
- requestDetails: null,
- chargeDetails: null,
- createdChargeDetails: null,
- transactionHash: null,
- paymentDetails: null,
- resolvedAddress: null,
- error: null,
- usdAmount: null,
- daimoError: null,
- isDaimoPaymentProcessing: false,
-}
-
-const paymentSlice = createSlice({
- name: PAYMENT_SLICE,
- initialState,
- reducers: {
- setView: (state, action: PayloadAction) => {
- state.currentView = action.payload
- },
- setAttachmentOptions: (state, action: PayloadAction) => {
- state.attachmentOptions = action.payload
- },
- setParsedPaymentData: (state, action: PayloadAction) => {
- state.parsedPaymentData = action.payload
- },
- setRequestDetails: (state, action: PayloadAction) => {
- state.requestDetails = action.payload
- },
- setChargeDetails: (state, action: PayloadAction) => {
- state.chargeDetails = action.payload
- },
- setCreatedChargeDetails: (state, action: PayloadAction) => {
- state.createdChargeDetails = action.payload
- },
- setTransactionHash: (state, action: PayloadAction) => {
- state.transactionHash = action.payload
- },
- setPaymentDetails: (state, action: PayloadAction) => {
- state.paymentDetails = action.payload
- },
- setResolvedAddress: (state, action: PayloadAction) => {
- state.resolvedAddress = action.payload
- },
- setError: (state, action: PayloadAction) => {
- state.error = action.payload
- },
- setUsdAmount: (state, action: PayloadAction) => {
- state.usdAmount = action.payload
- },
- setDaimoError: (state, action: PayloadAction) => {
- state.daimoError = action.payload
- },
- setIsDaimoPaymentProcessing: (state, action: PayloadAction) => {
- state.isDaimoPaymentProcessing = action.payload
- },
- resetPaymentState: (state) => {
- return initialState
- },
- },
-})
-
-export const paymentActions = paymentSlice.actions
-export default paymentSlice.reducer
diff --git a/src/redux/slices/send-flow-slice.ts b/src/redux/slices/send-flow-slice.ts
deleted file mode 100644
index 1b9df4a11..000000000
--- a/src/redux/slices/send-flow-slice.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { createSlice } from '@reduxjs/toolkit'
-import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
-import { SEND_FLOW_SLICE } from '../constants'
-import {
- type ErrorState,
- type IAttachmentOptions,
- type ISendFlowState,
- type Recipient,
- type SendFlowTxnType,
- type SendFlowView,
-} from '../types/send-flow.types'
-
-const initialState: ISendFlowState = {
- view: 'INITIAL',
- tokenValue: undefined,
- recipient: {
- address: undefined,
- name: undefined,
- },
- usdValue: undefined,
- linkDetails: undefined,
- password: undefined,
- transactionType: 'not-gasless',
- estimatedPoints: undefined,
- gaslessPayload: undefined,
- gaslessPayloadMessage: undefined,
- preparedDepositTxs: undefined,
- txHash: undefined,
- link: undefined,
- transactionCostUSD: undefined,
- attachmentOptions: {
- fileUrl: undefined,
- message: undefined,
- rawFile: undefined,
- },
- errorState: undefined,
- crossChainDetails: undefined,
-}
-
-const sendFlowSlice = createSlice({
- name: SEND_FLOW_SLICE,
- initialState,
- reducers: {
- setView(state, action: { payload: SendFlowView }) {
- state.view = action.payload
- },
- setTokenValue(state, action: { payload: string | undefined }) {
- state.tokenValue = action.payload
- },
- setRecipient(state, action: { payload: Recipient }) {
- state.recipient = action.payload
- },
- setUsdValue(state, action: { payload: string | undefined }) {
- state.usdValue = action.payload
- },
- setLinkDetails(state, action: { payload: peanutInterfaces.IPeanutLinkDetails | undefined }) {
- state.linkDetails = action.payload
- },
- setPassword(state, action: { payload: string | undefined }) {
- state.password = action.payload
- },
- setTransactionType(state, action: { payload: SendFlowTxnType }) {
- state.transactionType = action.payload
- },
- setEstimatedPoints(state, action: { payload: number | undefined }) {
- state.estimatedPoints = action.payload
- },
- setGaslessPayload(state, action: { payload: peanutInterfaces.IGaslessDepositPayload | undefined }) {
- state.gaslessPayload = action.payload
- },
- setGaslessPayloadMessage(state, action: { payload: peanutInterfaces.IPreparedEIP712Message | undefined }) {
- state.gaslessPayloadMessage = action.payload
- },
- setPreparedDepositTxs(state, action: { payload: peanutInterfaces.IPrepareDepositTxsResponse | undefined }) {
- state.preparedDepositTxs = action.payload
- },
- setTxHash(state, action: { payload: string | undefined }) {
- state.txHash = action.payload
- },
- setLink(state, action: { payload: string | undefined }) {
- state.link = action.payload
- },
- setTransactionCostUSD(state, action: { payload: number | undefined }) {
- state.transactionCostUSD = action.payload
- },
- setAttachmentOptions(state, action: { payload: IAttachmentOptions }) {
- state.attachmentOptions = action.payload
- },
- setErrorState(state, action: { payload: ErrorState | undefined }) {
- state.errorState = action.payload
- },
- setCrossChainDetails(state, action: { payload: [] | undefined }) {
- state.crossChainDetails = action.payload
- },
- resetSendFlow: (state) => {
- return initialState
- },
- },
-})
-
-export const sendFlowActions = sendFlowSlice.actions
-export default sendFlowSlice.reducer
diff --git a/src/redux/slices/wallet-slice.ts b/src/redux/slices/wallet-slice.ts
index 787ebdbbe..4bc39443f 100644
--- a/src/redux/slices/wallet-slice.ts
+++ b/src/redux/slices/wallet-slice.ts
@@ -4,7 +4,6 @@ import { type WalletUIState } from '../types/wallet.types'
import { type PayloadAction } from '@reduxjs/toolkit'
const initialState: WalletUIState = {
- signInModalVisible: false,
balance: undefined,
}
@@ -12,9 +11,6 @@ const walletSlice = createSlice({
name: WALLET_SLICE,
initialState,
reducers: {
- setSignInModalVisible: (state, action) => {
- state.signInModalVisible = action.payload
- },
setBalance: (state, action: PayloadAction) => {
state.balance = action.payload.toString()
},
diff --git a/src/redux/store.ts b/src/redux/store.ts
index a3672945b..ea4bd3db0 100644
--- a/src/redux/store.ts
+++ b/src/redux/store.ts
@@ -1,6 +1,4 @@
import { configureStore } from '@reduxjs/toolkit'
-import paymentReducer from './slices/payment-slice'
-import sendFlowReducer from './slices/send-flow-slice'
import setupReducer from './slices/setup-slice'
import userReducer from './slices/user-slice'
import walletReducer from './slices/wallet-slice'
@@ -12,9 +10,7 @@ const store = configureStore({
setup: setupReducer,
wallet: walletReducer,
zeroDev: zeroDevReducer,
- payment: paymentReducer,
user: userReducer,
- sendFlow: sendFlowReducer,
bankForm: bankFormReducer,
},
// disable redux serialization checks
diff --git a/src/redux/types/payment.types.ts b/src/redux/types/payment.types.ts
index a837cbf4c..f413c0c7f 100644
--- a/src/redux/types/payment.types.ts
+++ b/src/redux/types/payment.types.ts
@@ -5,7 +5,7 @@ import {
type TRequestChargeResponse,
type TRequestResponse,
} from '@/services/services.types'
-import { type IAttachmentOptions } from './send-flow.types'
+import { type IAttachmentOptions } from '@/interfaces/attachment'
export type TPaymentView = 'INITIAL' | 'CONFIRM' | 'STATUS' | 'PUBLIC_PROFILE'
diff --git a/src/redux/types/send-flow.types.ts b/src/redux/types/send-flow.types.ts
deleted file mode 100644
index f9445dd30..000000000
--- a/src/redux/types/send-flow.types.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
-
-export type SendFlowView = 'INITIAL' | 'CONFIRM' | 'SUCCESS' | 'ERROR'
-
-export type SendFlowTxnType = 'not-gasless' | 'gasless'
-
-export type Recipient = { address: string | undefined; name: string | undefined }
-
-export type IAttachmentOptions = {
- fileUrl: string | undefined
- message: string | undefined
- rawFile: File | undefined
-}
-
-export type ErrorState = {
- showError: boolean
- errorMessage: string
-}
-export interface ISendFlowState {
- view: SendFlowView
- tokenValue: string | undefined
- recipient: Recipient
- usdValue: string | undefined
- linkDetails: peanutInterfaces.IPeanutLinkDetails | undefined
- password: string | undefined
- transactionType: SendFlowTxnType
- estimatedPoints: number | undefined
- gaslessPayload: peanutInterfaces.IGaslessDepositPayload | undefined
- gaslessPayloadMessage: peanutInterfaces.IPreparedEIP712Message | undefined
- preparedDepositTxs: peanutInterfaces.IPrepareDepositTxsResponse | undefined
- txHash: string | undefined
- link: string | undefined
- transactionCostUSD: number | undefined
- attachmentOptions: IAttachmentOptions
- errorState: ErrorState | undefined
- crossChainDetails: [] | undefined
-}
diff --git a/src/redux/types/wallet.types.ts b/src/redux/types/wallet.types.ts
index fc1947fc6..63e00b962 100644
--- a/src/redux/types/wallet.types.ts
+++ b/src/redux/types/wallet.types.ts
@@ -1,4 +1,3 @@
export interface WalletUIState {
- signInModalVisible: boolean
balance: string | undefined
}
diff --git a/src/services/charges.ts b/src/services/charges.ts
index ec4397997..409a6f332 100644
--- a/src/services/charges.ts
+++ b/src/services/charges.ts
@@ -1,12 +1,13 @@
-import { PEANUT_API_URL } from '@/constants'
-import { fetchWithSentry, jsonParse } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { jsonParse } from '@/utils/general.utils'
import Cookies from 'js-cookie'
import {
- type CreateChargeRequest,
+ type TRequestChargeResponse,
type PaymentCreationResponse,
type TCharge,
- type TRequestChargeResponse,
+ type CreateChargeRequest,
} from './services.types'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export const chargesApi = {
create: async (data: CreateChargeRequest): Promise => {
diff --git a/src/services/invites.ts b/src/services/invites.ts
index 624d3a7a3..6f069db51 100644
--- a/src/services/invites.ts
+++ b/src/services/invites.ts
@@ -1,8 +1,8 @@
import { validateInviteCode } from '@/app/actions/invites'
-import { PEANUT_API_URL } from '@/constants'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import Cookies from 'js-cookie'
import { EInviteType, type PointsInvitesResponse } from './services.types'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export const invitesApi = {
acceptInvite: async (
diff --git a/src/services/manteca.ts b/src/services/manteca.ts
index 282d205e4..6f7f43941 100644
--- a/src/services/manteca.ts
+++ b/src/services/manteca.ts
@@ -1,11 +1,12 @@
-import { PEANUT_API_URL, PEANUT_API_KEY } from '@/constants'
+import { PEANUT_API_URL, PEANUT_API_KEY } from '@/constants/general.consts'
import {
type MantecaDepositResponseData,
type MantecaWithdrawData,
type MantecaWithdrawResponse,
type CreateMantecaOnrampParams,
} from '@/types/manteca.types'
-import { fetchWithSentry, jsonStringify } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { jsonStringify } from '@/utils/general.utils'
import Cookies from 'js-cookie'
import type { Address } from 'viem'
import type { SignUserOperationReturnType } from '@zerodev/sdk/actions'
@@ -253,7 +254,8 @@ export const mantecaApi = {
Authorization: `Bearer ${Cookies.get('jwt-token')}`,
},
body: jsonStringify({
- usdAmount: params.usdAmount,
+ amount: params.amount,
+ isUsdDenominated: params.isUsdDenominated,
currency: params.currency,
chargeId: params.chargeId,
}),
diff --git a/src/services/notifications.ts b/src/services/notifications.ts
index 87532cef6..604d28b12 100644
--- a/src/services/notifications.ts
+++ b/src/services/notifications.ts
@@ -1,5 +1,5 @@
-import { PEANUT_API_URL } from '@/constants'
import Cookies from 'js-cookie'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export type InAppItem = {
id: string
diff --git a/src/services/points.ts b/src/services/points.ts
index 7de61b0f2..562a21880 100644
--- a/src/services/points.ts
+++ b/src/services/points.ts
@@ -1,7 +1,7 @@
import Cookies from 'js-cookie'
import { type CalculatePointsRequest, PointsAction, type TierInfo } from './services.types'
-import { fetchWithSentry } from '@/utils'
-import { PEANUT_API_URL } from '@/constants'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { PEANUT_API_URL } from '@/constants/general.consts'
type InvitesGraphResponse = {
success: boolean
diff --git a/src/services/quests.ts b/src/services/quests.ts
index d9066869d..431d77137 100644
--- a/src/services/quests.ts
+++ b/src/services/quests.ts
@@ -4,9 +4,9 @@
*/
import Cookies from 'js-cookie'
-import { fetchWithSentry } from '@/utils'
-import { PEANUT_API_URL } from '@/constants'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import type { QuestLeaderboardData, AllQuestsLeaderboardData } from '@/app/quests/types'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export const questsApi = {
/**
diff --git a/src/services/requests.ts b/src/services/requests.ts
index f7932af47..75d908180 100644
--- a/src/services/requests.ts
+++ b/src/services/requests.ts
@@ -1,7 +1,8 @@
-import { PEANUT_API_URL } from '@/constants'
import { type CreateRequestRequest, type TRequestResponse } from './services.types'
-import { fetchWithSentry, jsonStringify } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { jsonStringify } from '@/utils/general.utils'
import Cookies from 'js-cookie'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export const requestsApi = {
create: async (data: CreateRequestRequest): Promise => {
diff --git a/src/services/rewards.ts b/src/services/rewards.ts
index 3b29313d6..8c1868c09 100644
--- a/src/services/rewards.ts
+++ b/src/services/rewards.ts
@@ -1,7 +1,7 @@
-import { PEANUT_API_URL } from '@/constants'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import { type RewardLink } from './services.types'
import Cookies from 'js-cookie'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export const rewardsApi = {
getByUser: async (userId: string): Promise => {
diff --git a/src/services/rhino.ts b/src/services/rhino.ts
new file mode 100644
index 000000000..05df247d5
--- /dev/null
+++ b/src/services/rhino.ts
@@ -0,0 +1,89 @@
+import type { CreateDepositAddressResponse, RhinoChainType } from './services.types'
+import { PEANUT_API_URL } from '@/constants/general.consts'
+import Cookies from 'js-cookie'
+
+export const rhinoApi = {
+ createDepositAddress: async (
+ destinationAddress: string,
+ chainType: RhinoChainType,
+ identifier: string
+ ): Promise => {
+ const token = Cookies.get('jwt-token')
+ if (!token) {
+ throw new Error('Authentication required')
+ }
+
+ const response = await fetch(`${PEANUT_API_URL}/rhino/deposit`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ body: JSON.stringify({ destinationAddress, type: chainType, addressNote: identifier }),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to fetch deposit address: ${response.statusText}`)
+ }
+ const data = await response.json()
+ return data as CreateDepositAddressResponse
+ },
+
+ getDepositAddressStatus: async (depositAddress: string): Promise<{ status: string; amount?: number }> => {
+ const response = await fetch(`${PEANUT_API_URL}/rhino/status/${depositAddress}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch deposit address status: ${response.statusText}`)
+ }
+
+ const data = await response.json()
+ return data
+ },
+
+ resetDepositAddressStatus: async (depositAddress: string): Promise => {
+ const token = Cookies.get('jwt-token')
+ if (!token) {
+ throw new Error('Authentication required')
+ }
+ const response = await fetch(`${PEANUT_API_URL}/rhino/reset-status/${depositAddress}`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${token}`,
+ },
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to update deposit address status: ${response.statusText}`)
+ }
+
+ return true
+ },
+
+ createRequestFulfilmentAddress: async (
+ chainType: RhinoChainType,
+ chargeId: string,
+ peanutWalletAddress?: string
+ ): Promise => {
+ const response = await fetch(`${PEANUT_API_URL}/rhino/request-fulfilment`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ type: chainType,
+ chargeId,
+ senderPeanutWalletAddress: peanutWalletAddress,
+ }),
+ })
+ if (!response.ok) {
+ throw new Error(`Failed to fetch Request Fulfilment Address: ${response.statusText}`)
+ }
+ const data = await response.json()
+ return data as CreateDepositAddressResponse
+ },
+}
diff --git a/src/services/sendLinks.ts b/src/services/sendLinks.ts
index b8ba670e2..38150e112 100644
--- a/src/services/sendLinks.ts
+++ b/src/services/sendLinks.ts
@@ -1,9 +1,10 @@
// Removed claimSendLink import - no longer used (was insecure)
-import { PEANUT_API_URL } from '@/constants'
-import { fetchWithSentry, jsonParse, jsonStringify } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { jsonParse, jsonStringify } from '@/utils/general.utils'
import { generateKeysFromString, getParamsFromLink } from '@squirrel-labs/peanut-sdk'
import Cookies from 'js-cookie'
import type { SendLink } from '@/services/services.types'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export { ESendLinkStatus } from '@/services/services.types'
export type { SendLinkStatus, SendLink } from '@/services/services.types'
diff --git a/src/services/services.types.ts b/src/services/services.types.ts
index 4bc1d036c..659bde792 100644
--- a/src/services/services.types.ts
+++ b/src/services/services.types.ts
@@ -1,4 +1,4 @@
-import { type BridgeKycStatus } from '@/utils'
+import { type BridgeKycStatus } from '@/utils/bridge-accounts.utils'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
export type TStatus = 'NEW' | 'PENDING' | 'COMPLETED' | 'EXPIRED' | 'FAILED' | 'SIGNED' | 'SUCCESSFUL' | 'CANCELLED'
@@ -66,6 +66,7 @@ export interface ChargeEntry {
}
export interface RequestLink {
+ uuid: string
recipientAddress: string
reference: string | null
attachmentUrl: string | null
@@ -190,6 +191,7 @@ export interface TRequestChargeResponse {
username: string
}
requestLink: {
+ uuid: string
recipientAddress: string
reference: string | null
attachmentUrl: string | null
@@ -469,3 +471,11 @@ export interface HistoryEntryPerkReward {
originatingTxType?: string
perkName?: string
}
+
+export type RhinoChainType = 'EVM' | 'SOL' | 'TRON'
+export interface CreateDepositAddressResponse {
+ depositAddress: string
+ minDepositLimitUsd: number
+ maxDepositLimitUsd: number
+ supportedChains: string[]
+}
diff --git a/src/services/simplefi.ts b/src/services/simplefi.ts
index 7057171db..3c0eed0af 100644
--- a/src/services/simplefi.ts
+++ b/src/services/simplefi.ts
@@ -1,7 +1,7 @@
-import { PEANUT_API_URL } from '@/constants'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import Cookies from 'js-cookie'
import type { Address } from 'viem'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export type QrPaymentType = 'STATIC' | 'DYNAMIC' | 'USER_SPECIFIED'
diff --git a/src/services/swap.ts b/src/services/swap.ts
index 910fea268..21076defc 100644
--- a/src/services/swap.ts
+++ b/src/services/swap.ts
@@ -4,8 +4,10 @@ import { parseUnits, formatUnits, encodeFunctionData, erc20Abi } from 'viem'
import { fetchTokenPrice, estimateTransactionCostUsd } from '@/app/actions/tokens'
import { getPublicClient, type ChainId } from '@/app/actions/clients'
-import { fetchWithSentry, isNativeCurrency, areEvmAddressesEqual } from '@/utils'
-import { SQUID_API_URL, USDT_IN_MAINNET, SQUID_INTEGRATOR_ID, SQUID_INTEGRATOR_ID_WITHOUT_CORAL } from '@/constants'
+import { fetchWithSentry } from '@/utils/sentry.utils'
+import { isNativeCurrency, areEvmAddressesEqual } from '@/utils/general.utils'
+import { SQUID_API_URL, SQUID_INTEGRATOR_ID, SQUID_INTEGRATOR_ID_WITHOUT_CORAL } from '@/constants/general.consts'
+import { USDT_IN_MAINNET } from '@/constants/zerodev.consts'
type TokenInfo = {
address: Address
diff --git a/src/services/users.ts b/src/services/users.ts
index b485877c5..824acd885 100644
--- a/src/services/users.ts
+++ b/src/services/users.ts
@@ -1,17 +1,17 @@
import {
- PEANUT_API_URL,
PEANUT_WALLET_CHAIN,
PEANUT_WALLET_TOKEN,
PEANUT_WALLET_TOKEN_DECIMALS,
PEANUT_WALLET_TOKEN_SYMBOL,
-} from '@/constants'
+} from '@/constants/zerodev.consts'
import { AccountType, type IUserKycVerification } from '@/interfaces'
-import { type IAttachmentOptions } from '@/redux/types/send-flow.types'
-import { fetchWithSentry } from '@/utils'
+import { type IAttachmentOptions } from '@/interfaces/attachment'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import { chargesApi } from './charges'
import { type TCharge } from './services.types'
import Cookies from 'js-cookie'
+import { PEANUT_API_URL } from '@/constants/general.consts'
type ApiAccount = {
identifier: string
diff --git a/src/styles/theme.ts b/src/styles/theme.ts
deleted file mode 100644
index 311f8180f..000000000
--- a/src/styles/theme.ts
+++ /dev/null
@@ -1,54 +0,0 @@
-import { extendTheme } from '@chakra-ui/react'
-import { StepsTheme as Steps } from 'chakra-ui-steps'
-
-const config = {
- initialColorMode: 'light' as 'light',
- useSystemColorMode: false,
-}
-/**
- * Breakpoints for responsive design.
- *
- * The breakpoints are defined in em units and their equivalent in pixels are:
- * - xs: 22em (352px)
- * - sm: 30em (480px)
- * - md: 48em (768px)
- * - lg: 62em (992px)
- * - xl: 80em (1280px)
- * - 2xl: 96em (1536px)
- */
-export const breakpoints = {
- xs: '22em',
- sm: '30em',
- md: '48em',
- lg: '62em',
- xl: '80em',
- '2xl': '96em',
-}
-
-export const emToPx = (em: string) => parseFloat(em) * 16
-
-export const theme = extendTheme({
- breakpoints,
- config,
- colors: {
- stepperScheme: {
- 50: '#e1f5fe',
- 100: '#b3e5fc',
- 200: '#81d4fa',
- 300: '#4fc3f7',
- 400: '#29b6f6',
- 500: '#03a9f4',
- 600: '#039be5',
- 700: '#0288d1',
- 800: '#0277bd',
- 900: '#01579b',
- },
- pink: {
- 500: '#FF9CEA',
- },
- },
-
- components: {
- Steps,
- },
-})
diff --git a/src/types/manteca.types.ts b/src/types/manteca.types.ts
index 38040e779..d18d23f05 100644
--- a/src/types/manteca.types.ts
+++ b/src/types/manteca.types.ts
@@ -123,7 +123,8 @@ export type MantecaWithdrawResponse = {
}
export interface CreateMantecaOnrampParams {
- usdAmount: string
+ amount: string
+ isUsdDenominated?: boolean
currency: string
chargeId?: string
}
diff --git a/src/utils/__mocks__/web-push.ts b/src/utils/__mocks__/web-push.ts
deleted file mode 100644
index b0c44b7a0..000000000
--- a/src/utils/__mocks__/web-push.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Mock for web-push
- * Used in Jest tests to avoid VAPID key validation issues
- */
-
-export const setVapidDetails = jest.fn()
-export const sendNotification = jest.fn(() => Promise.resolve())
-export const generateVAPIDKeys = jest.fn(() => ({
- publicKey: 'mock-public-key',
- privateKey: 'mock-private-key',
-}))
-
-const webpush = {
- setVapidDetails,
- sendNotification,
- generateVAPIDKeys,
-}
-
-export default webpush
diff --git a/src/utils/__tests__/balance.utils.test.ts b/src/utils/__tests__/balance.utils.test.ts
new file mode 100644
index 000000000..00516f09a
--- /dev/null
+++ b/src/utils/__tests__/balance.utils.test.ts
@@ -0,0 +1,29 @@
+import { printableUsdc } from '../balance.utils'
+
+describe('balance utils', () => {
+ describe('printableUsdc', () => {
+ it.each([
+ [0n, '0.00'],
+ [10000n, '0.01'],
+ [100000n, '0.10'],
+ [1000000n, '1.00'],
+ [10000000n, '10.00'],
+ [100000000n, '100.00'],
+ [1000000000n, '1000.00'],
+ [10000000000n, '10000.00'],
+ [100000000000n, '100000.00'],
+ [1000000000000n, '1000000.00'],
+ [10000000000000n, '10000000.00'],
+ [100000000000000n, '100000000.00'],
+ [1000000000000000n, '1000000000.00'],
+ [10000000000000000n, '10000000000.00'],
+ [100000000000000000n, '100000000000.00'],
+ [1000000000000000000n, '1000000000000.00'],
+ [303340000n, '303.34'],
+ [303339000n, '303.33'],
+ [303345000n, '303.34'],
+ ])('should return the correct value for %i', (input, expected) => {
+ expect(printableUsdc(input)).toBe(expected)
+ })
+ })
+})
diff --git a/src/utils/__tests__/url-parser.test.ts b/src/utils/__tests__/url-parser.test.ts
index cdd19fe73..e26f64a73 100644
--- a/src/utils/__tests__/url-parser.test.ts
+++ b/src/utils/__tests__/url-parser.test.ts
@@ -84,16 +84,12 @@ jest.mock('@/app/actions/squid', () => ({
}),
}))
-jest.mock('@/constants', () => ({
+jest.mock('@/constants/zerodev.consts', () => ({
PEANUT_WALLET_CHAIN: {
id: '42161',
name: 'Arbitrum',
},
PEANUT_WALLET_TOKEN: '0xFF970A61A04b1cA14834A43f5dE4533eBDDB5CC8',
- chains: [
- { id: 1, name: 'Ethereum' },
- { id: 42161, name: 'Arbitrum' },
- ],
}))
jest.mock('@/lib/url-parser/parser.consts', () => ({
@@ -267,7 +263,6 @@ describe('URL Parser Tests', () => {
chain: expect.objectContaining({ chainId: 42161 }),
amount: '0.1',
token: expect.objectContaining({ symbol: 'USDC' }),
- isDevConnectFlow: false,
})
})
@@ -319,7 +314,6 @@ describe('URL Parser Tests', () => {
chain: undefined,
token: undefined,
amount: undefined,
- isDevConnectFlow: false,
})
})
diff --git a/src/utils/backendTransport.ts b/src/utils/backendTransport.ts
index c709da428..1a316f84b 100644
--- a/src/utils/backendTransport.ts
+++ b/src/utils/backendTransport.ts
@@ -1,7 +1,7 @@
import { custom, type Transport } from 'viem'
-import { PEANUT_API_URL } from '@/constants'
import { jsonStringify } from './general.utils'
import Cookies from 'js-cookie'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export function createBackendRpcTransport(chainId: number): Transport {
return custom({
diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts
index 051ee3089..48c11a0f8 100644
--- a/src/utils/balance.utils.ts
+++ b/src/utils/balance.utils.ts
@@ -1,4 +1,4 @@
-import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
+import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { type ChainValue, type IUserBalance } from '@/interfaces'
import * as Sentry from '@sentry/nextjs'
import { formatUnits } from 'viem'
@@ -30,9 +30,10 @@ export function calculateValuePerChain(balances: IUserBalance[]): ChainValue[] {
}
export const printableUsdc = (balance: bigint): string => {
- const formatted = formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)
- // floor the formatted value
- const value = Number(formatted)
- const flooredValue = Math.floor(value * 100) / 100
- return flooredValue.toFixed(2)
+ // For 6 decimals, we want 2 decimal places in output
+ // So we divide by 10^4 to keep only 2 decimal places, then format
+ const scaleFactor = BigInt(10 ** (PEANUT_WALLET_TOKEN_DECIMALS - 2)) // 10^4 = 10000n
+ const flooredBigint = (balance / scaleFactor) * scaleFactor
+ const formatted = formatUnits(flooredBigint, PEANUT_WALLET_TOKEN_DECIMALS)
+ return Number(formatted).toFixed(2)
}
diff --git a/src/utils/bridge-accounts.utils.ts b/src/utils/bridge-accounts.utils.ts
index 0c24a2ed7..61bd013c6 100644
--- a/src/utils/bridge-accounts.utils.ts
+++ b/src/utils/bridge-accounts.utils.ts
@@ -1,5 +1,6 @@
-import * as consts from '@/constants'
-import { areEvmAddressesEqual, fetchWithSentry } from '@/utils'
+import { supportedBridgeTokensDictionary, supportedBridgeChainsDictionary } from '@/constants/cashout.consts'
+import { areEvmAddressesEqual } from '@/utils/general.utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import { isIBAN } from 'validator'
const ALLOWED_PARENT_DOMAINS = ['intersend.io', 'app.intersend.io']
@@ -31,14 +32,15 @@ export const convertPersonaUrl = (url: string) => {
return `https://bridge.withpersona.com/widget?environment=production&inquiry-template-id=${templateId}&fields[iqt_token=${iqtToken}&iframe-origin=${origin}&redirect-uri=${origin}&fields[developer_id]=${developerId}&reference-id=${referenceId}`
}
-export type BridgeKycStatus = 'not_started' | 'under_review' | 'approved' | 'rejected' | 'incomplete'
+// Re-export from interfaces (defined there to avoid circular dependency)
+export type { BridgeKycStatus } from '@/interfaces/interfaces'
export async function validateIban(iban: string): Promise {
return isIBAN(iban.replace(/\s+/g, ''))
}
export function getBridgeTokenName(chainId: string, tokenAddress: string): string | undefined {
- const token = consts.supportedBridgeTokensDictionary
+ const token = supportedBridgeTokensDictionary
.find((chain) => chain.chainId === chainId)
?.tokens.find((token) => areEvmAddressesEqual(token.address, tokenAddress))
?.token.toLowerCase()
@@ -47,7 +49,7 @@ export function getBridgeTokenName(chainId: string, tokenAddress: string): strin
}
export function getBridgeChainName(chainId: string): string | undefined {
- const chain = consts.supportedBridgeChainsDictionary.find((chain) => chain.chainId === chainId)?.chain
+ const chain = supportedBridgeChainsDictionary.find((chain) => chain.chainId === chainId)?.chain
return chain ?? undefined
}
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index f13c7a6ea..afb85130a 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -1,6 +1,13 @@
-import * as consts from '@/constants'
-import { STABLE_COINS, USER_OPERATION_REVERT_REASON_TOPIC, ENS_NAME_REGEX } from '@/constants'
-import { AccountType } from '@/interfaces'
+import {
+ nativeCurrencyAddresses,
+ supportedPeanutChains,
+ peanutTokenDetails,
+ pathTitles,
+ BASE_URL,
+} from '@/constants/general.consts'
+import { type LoadingStates } from '@/constants/loadingStates.consts'
+import { STABLE_COINS, ENS_NAME_REGEX } from '@/constants/general.consts'
+import { AccountType } from '@/interfaces/interfaces'
import * as Sentry from '@sentry/nextjs'
import peanut, { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import type { Address, TransactionReceipt } from 'viem'
@@ -10,7 +17,8 @@ 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'
+import { USER_OPERATION_REVERT_REASON_TOPIC } from '@/constants/zerodev.consts'
+import { CHAIN_LOGOS, type ChainName } from '@/constants/rhino.consts'
export function urlBase64ToUint8Array(base64String: string) {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
@@ -54,8 +62,36 @@ export const shortenStringLong = (s?: string, chars?: number, firstChars?: numbe
return firstBit + '...' + endingBit
}
+// Address detection patterns (permissive to handle lowercase-stored addresses)
+// These are for display purposes, not cryptographic validation
+const SOLANA_ADDRESS_REGEX = /^[1-9a-zA-Z]{32,44}$/
+const TRON_ADDRESS_REGEX = /^[Tt][0-9a-zA-Z]{33}$/
+
+/**
+ * Checks if a string looks like a Solana address (32-44 alphanumeric characters, no 0)
+ * Permissive to handle lowercase-stored addresses
+ */
+export const isSolanaAddress = (address: string): boolean => {
+ return SOLANA_ADDRESS_REGEX.test(address)
+}
+
+/**
+ * Checks if a string looks like a Tron address (starts with T/t, 34 alphanumeric characters)
+ * Permissive to handle lowercase-stored addresses
+ */
+export const isTronAddress = (address: string): boolean => {
+ return TRON_ADDRESS_REGEX.test(address)
+}
+
+/**
+ * Checks if a string is any valid blockchain address (EVM, Solana, or Tron)
+ */
+export const isCryptoAddress = (address: string): boolean => {
+ return isAddress(address) || isSolanaAddress(address) || isTronAddress(address)
+}
+
export const printableAddress = (address: string, firstCharsLen?: number, lastCharsLen?: number): string => {
- if (!isAddress(address)) return address
+ if (!isCryptoAddress(address)) return address
return shortenStringLong(address, undefined, firstCharsLen, lastCharsLen)
}
@@ -434,7 +470,7 @@ export const isAddressZero = (address: string): boolean => {
}
export const isNativeCurrency = (address: string) => {
- if (consts.nativeCurrencyAddresses.includes(address.toLowerCase())) {
+ if (nativeCurrencyAddresses.includes(address.toLowerCase())) {
return true
} else return false
}
@@ -456,16 +492,6 @@ 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 = (
@@ -511,7 +537,7 @@ export const estimateIfIsStableCoinFromPrice = (tokenPrice: number) => {
}
export const getExplorerUrl = (chainId: string) => {
- const explorers = consts.supportedPeanutChains.find((detail) => detail.chainId === chainId)?.explorers
+ const explorers = supportedPeanutChains.find((detail) => detail.chainId === chainId)?.explorers
// if the explorers array has blockscout, return the blockscout url, else return the first one
if (explorers?.find((explorer) => explorer.url.includes('blockscout'))) {
return explorers?.find((explorer) => explorer.url.includes('blockscout'))?.url
@@ -565,7 +591,7 @@ export const switchNetwork = async ({
}: {
chainId: string
currentChainId: string | undefined
- setLoadingState: (state: consts.LoadingStates) => void
+ setLoadingState: (state: LoadingStates) => void
switchChainAsync: ({ chainId }: { chainId: number }) => Promise
}) => {
if (currentChainId !== chainId) {
@@ -585,7 +611,7 @@ export const switchNetwork = async ({
/** Gets the token decimals for a given token address and chain ID. */
export function getTokenDecimals(tokenAddress: string, chainId: string): number | undefined {
- return consts.peanutTokenDetails
+ return peanutTokenDetails
.find((chain) => chain.chainId === chainId)
?.tokens.find((token) => areEvmAddressesEqual(token.address, tokenAddress))?.decimals
}
@@ -597,7 +623,7 @@ export function getTokenDetails({ tokenAddress, chainId }: { tokenAddress: Addre
decimals: number
}
| undefined {
- const chainTokens = consts.peanutTokenDetails.find((c) => c.chainId === chainId)?.tokens
+ const chainTokens = peanutTokenDetails.find((c) => c.chainId === chainId)?.tokens
if (!chainTokens) return undefined
const tokenDetails = chainTokens.find((token) => areEvmAddressesEqual(token.address, tokenAddress))
if (!tokenDetails) return undefined
@@ -615,7 +641,7 @@ export function getTokenDetails({ tokenAddress, chainId }: { tokenAddress: Addre
export function getTokenSymbol(tokenAddress: string | undefined, chainId: string | undefined): string | undefined {
if (!tokenAddress || !chainId) return undefined
- const chainTokens = consts.peanutTokenDetails.find((chain) => chain.chainId === chainId)?.tokens
+ const chainTokens = peanutTokenDetails.find((chain) => chain.chainId === chainId)?.tokens
if (!chainTokens) return undefined
return chainTokens.find((token) => areEvmAddressesEqual(token.address, tokenAddress))?.symbol
@@ -654,12 +680,15 @@ export async function fetchTokenSymbol(tokenAddress: string, chainId: string): P
}
export function getChainName(chainId: string): string | undefined {
+ if (chainId === '0') {
+ return 'Solana'
+ }
const chain = Object.entries(wagmiChains).find(([, chain]) => chain.id === Number(chainId))?.[1]
return chain?.name ?? undefined
}
export const getHeaderTitle = (pathname: string) => {
- return consts.pathTitles[pathname] || 'Peanut' // default title if path not found
+ return pathTitles[pathname] || 'Peanut' // default title if path not found
}
/**
@@ -780,6 +809,13 @@ export function getChainLogo(chainName: string): string {
default:
name = chainName.toLowerCase()
}
+
+ const chainLogo = CHAIN_LOGOS[name.toUpperCase() as ChainName]
+
+ if (chainLogo) {
+ return chainLogo
+ }
+
return `https://raw.githubusercontent.com/0xsquid/assets/main/images/webp128/chains/${name}.webp`
}
@@ -896,7 +932,7 @@ export const generateInviteCodeSuffix = (username: string): string => {
export const generateInviteCodeLink = (username: string) => {
const suffix = generateInviteCodeSuffix(username)
const inviteCode = `${username.toUpperCase()}INVITESYOU${suffix}`
- const inviteLink = `${consts.BASE_URL}/invite?code=${inviteCode}`
+ const inviteLink = `${BASE_URL}/invite?code=${inviteCode}`
return { inviteLink, inviteCode }
}
@@ -939,109 +975,3 @@ 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/history.utils.ts b/src/utils/history.utils.ts
index 5f81f18d0..b233e4f5b 100644
--- a/src/utils/history.utils.ts
+++ b/src/utils/history.utils.ts
@@ -1,13 +1,15 @@
import { MERCADO_PAGO, PIX, SIMPLEFI } from '@/assets/payment-apps'
import { type TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
-import { getFromLocalStorage } from '@/utils'
-import { PEANUT_WALLET_TOKEN_DECIMALS, BASE_URL } from '@/constants'
+import { getFromLocalStorage } from '@/utils/general.utils'
import { formatUnits } from 'viem'
import { type Hash } from 'viem'
-import { getTokenDetails } from '@/utils'
+import { getTokenDetails } from '@/utils/general.utils'
import { getCurrencyPrice } from '@/app/actions/currency'
import { type ChargeEntry } from '@/services/services.types'
+import { BASE_URL } from '@/constants/general.consts'
+import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
+// NOTE: do not change the order, add new entries at the end, keep synced with backend
export enum EHistoryEntryType {
REQUEST = 'REQUEST',
CASHOUT = 'CASHOUT',
@@ -19,10 +21,10 @@ export enum EHistoryEntryType {
BRIDGE_ONRAMP = 'BRIDGE_ONRAMP',
BANK_SEND_LINK_CLAIM = 'BANK_SEND_LINK_CLAIM',
MANTECA_QR_PAYMENT = 'MANTECA_QR_PAYMENT',
- SIMPLEFI_QR_PAYMENT = 'SIMPLEFI_QR_PAYMENT',
MANTECA_OFFRAMP = 'MANTECA_OFFRAMP',
MANTECA_ONRAMP = 'MANTECA_ONRAMP',
BRIDGE_GUEST_OFFRAMP = 'BRIDGE_GUEST_OFFRAMP',
+ SIMPLEFI_QR_PAYMENT = 'SIMPLEFI_QR_PAYMENT',
PERK_REWARD = 'PERK_REWARD',
}
export function historyTypeToNumber(type: EHistoryEntryType): number {
diff --git a/src/utils/identityVerification.tsx b/src/utils/identityVerification.tsx
index 3f6abb80f..d4e1a3f93 100644
--- a/src/utils/identityVerification.tsx
+++ b/src/utils/identityVerification.tsx
@@ -1,22 +1,32 @@
-import { ALL_COUNTRIES_ALPHA3_TO_ALPHA2, countryData, MEXICO_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
+import {
+ ALL_COUNTRIES_ALPHA3_TO_ALPHA2,
+ countryData,
+ MEXICO_ALPHA3_TO_ALPHA2,
+ UNSUPPORTED_ALPHA3_TO_ALPHA2,
+} from '@/components/AddMoney/consts'
export const getCountriesForRegion = (region: string) => {
const supportedCountriesIso3 = Object.keys(ALL_COUNTRIES_ALPHA3_TO_ALPHA2).concat(
Object.keys(MEXICO_ALPHA3_TO_ALPHA2) // Add Mexico as well, supported by bridge
)
+ const unsupportedCountriesIso3 = Object.keys(UNSUPPORTED_ALPHA3_TO_ALPHA2)
+
const countries = countryData.filter((country) => country.region === region)
const supportedCountries = []
+ const limitedAccessCountries = []
const unsupportedCountries = []
for (const country of countries) {
if (country.iso3 && supportedCountriesIso3.includes(country.iso3)) {
supportedCountries.push({ ...country, isSupported: true })
- } else {
+ } else if (country.iso3 && unsupportedCountriesIso3.includes(country.iso3)) {
unsupportedCountries.push({ ...country, isSupported: false })
+ } else {
+ limitedAccessCountries.push({ ...country, isSupported: false })
}
}
- return { supportedCountries, unsupportedCountries }
+ return { supportedCountries, limitedAccessCountries, unsupportedCountries }
}
diff --git a/src/utils/index.ts b/src/utils/index.ts
deleted file mode 100644
index 5ea9ac668..000000000
--- a/src/utils/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-export * from './general.utils'
-export * from './sdkErrorHandler.utils'
-export * from './bridge-accounts.utils'
-export * from './balance.utils'
-export * from './sentry.utils'
-export * from './token.utils'
-export * from './ens.utils'
-export * from './history.utils'
-export * from './auth.utils'
-export * from './webauthn.utils'
-export * from './passkeyDebug'
-export * from './passkeyPreflight'
-
-// Bridge utils - explicit exports to avoid naming conflicts
-export {
- getCurrencyConfig as getBridgeCurrencyConfig,
- getOfframpCurrencyConfig,
- getPaymentRailDisplayName,
- getMinimumAmount,
-} from './bridge.utils'
-export type { BridgeOperationType } from './bridge.utils'
diff --git a/src/utils/metrics.utils.ts b/src/utils/metrics.utils.ts
index 390991bc2..9edbc8290 100644
--- a/src/utils/metrics.utils.ts
+++ b/src/utils/metrics.utils.ts
@@ -1,7 +1,7 @@
import { type JSONObject } from '@/interfaces'
-import { PEANUT_API_URL } from '@/constants'
-import { fetchWithSentry } from '@/utils'
+import { fetchWithSentry } from '@/utils/sentry.utils'
import Cookies from 'js-cookie'
+import { PEANUT_API_URL } from '@/constants/general.consts'
export async function hitUserMetric(userId: string, name: string, value: JSONObject = {}): Promise {
try {
diff --git a/src/utils/token.utils.ts b/src/utils/token.utils.ts
index ffbd6da4b..7453dea6d 100644
--- a/src/utils/token.utils.ts
+++ b/src/utils/token.utils.ts
@@ -1,4 +1,4 @@
-import { areEvmAddressesEqual } from '@/utils/'
+import { areEvmAddressesEqual } from './general.utils'
export const checkTokenSupportsXChain = (
tokenAddress: string,
diff --git a/tailwind.config.js b/tailwind.config.js
index 55292eab7..dfa378f6d 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -100,6 +100,8 @@ module.exports = {
3: '#29CC6A',
4: '#1C6A50',
5: '#88D987',
+ 6: '#ECFFE9',
+ 7: '#4B8A17',
},
white: '#FFFFFF',
red: '#FF0000',