)
diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
index c7c8a3497..b6b7479dc 100644
--- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
+++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx
@@ -203,7 +203,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => {
if (view === 'form') {
return (
-
+
setView('list')} />
onItemClick(account, path)}
className="p-4 py-2.5"
diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
index c4ef4ee01..5f39171d1 100644
--- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx
+++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx
@@ -177,7 +177,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D
}, [country])
return (
-
+
{
@@ -39,7 +26,6 @@ export const useCreateLink = () => {
const { selectedChainID } = useContext(tokenSelectorContext)
const { address } = useWallet()
- const { connector } = useAccount()
const { signTypedDataAsync } = useSignTypedData()
const { handleSendUserOpEncoded } = useZeroDev()
@@ -108,72 +94,6 @@ export const useCreateLink = () => {
},
[address]
)
- const isSafeConnector = (connector?: { name?: string }): boolean => {
- const name = connector?.name
- if (!name) return false
- return name.toLowerCase().includes('safe')
- }
- const estimateGasFee = useCallback(
- async ({
- from,
- chainId,
- preparedTxs,
- }: {
- from: Hex
- chainId: string
- preparedTxs: peanutInterfaces.IPeanutUnsignedTransaction[]
- }) => {
- // Return early with default values for Safe connector
- // requirement for internut (injects AA with zero gas fees)
- if (isSafeConnector({ name: connector?.name })) {
- return {
- feeOptions: [
- {
- gasLimit: BigInt(0),
- maxFeePerGas: BigInt(0),
- gasPrice: BigInt(0),
- },
- ],
- transactionCostUSD: 0,
- }
- }
- let feeOptions: FeeOptions[] = []
- let transactionCostUSD = 0
- // For when we have an approval before a transaction, we need the
- // token address to override the state
- const erc20Token = preparedTxs.length === 2 ? preparedTxs[0].to : undefined
- for (const preparedTx of preparedTxs) {
- const gasOptions = jsonParse(
- await getFeeOptions(
- Number(chainId) as ChainId,
- {
- ...preparedTx,
- value: preparedTx.value?.toString() ?? '0',
- account: from,
- erc20Token,
- } as PreparedTx
- )
- )
- if (gasOptions.error) {
- throw new Error(gasOptions.error)
- }
- feeOptions.push(gasOptions)
- let transactionCostWei = gasOptions.gas * gasOptions.maxFeePerGas
- let transactionCostNative = formatEther(transactionCostWei)
- const nativeTokenPrice = await fetchTokenPrice(NATIVE_TOKEN_ADDRESS, chainId)
- if (!nativeTokenPrice || typeof nativeTokenPrice.price !== 'number' || isNaN(nativeTokenPrice.price)) {
- throw new Error('Failed to fetch token price')
- }
- transactionCostUSD += Number(transactionCostNative) * nativeTokenPrice.price
- }
-
- return {
- feeOptions,
- transactionCostUSD,
- }
- },
- []
- )
const estimatePoints = async ({
chainId,
@@ -539,7 +459,6 @@ export const useCreateLink = () => {
makeDepositGasless,
prepareDepositTxs,
getLinkFromHash,
- estimateGasFee,
estimatePoints,
submitClaimLinkInit,
submitClaimLinkConfirm,
diff --git a/src/components/CrispChat.tsx b/src/components/CrispChat.tsx
index 5546e8578..05f55d660 100644
--- a/src/components/CrispChat.tsx
+++ b/src/components/CrispChat.tsx
@@ -7,12 +7,7 @@ export const CrispButton = ({ children, ...rest }: React.HTMLAttributes {
- if (window.$crisp) {
- window.$crisp.push(['do', 'chat:open'])
- } else {
- // Fallback to support page if Crisp isn't loaded
- router.push('/support')
- }
+ router.push('/support')
}
return (
diff --git a/src/components/Global/BalanceWarningModal/index.tsx b/src/components/Global/BalanceWarningModal/index.tsx
new file mode 100644
index 000000000..d3723a9f2
--- /dev/null
+++ b/src/components/Global/BalanceWarningModal/index.tsx
@@ -0,0 +1,122 @@
+'use client'
+
+import { Icon } from '@/components/Global/Icons/Icon'
+import Modal from '@/components/Global/Modal'
+import { useMemo } from 'react'
+import { Slider } from '@/components/Slider'
+
+enum Platform {
+ IOS = 'ios',
+ ANDROID = 'android',
+ MACOS = 'macos',
+ WINDOWS = 'windows',
+ UNKNOWN = 'unknown',
+}
+
+const PLATFORM_INFO = {
+ [Platform.IOS]: {
+ name: 'iPhone/iPad',
+ url: 'https://support.apple.com/en-us/102195',
+ },
+ [Platform.ANDROID]: {
+ name: 'Android',
+ url: 'https://support.google.com/accounts/answer/6197437',
+ },
+ [Platform.MACOS]: {
+ name: 'Mac',
+ url: 'https://support.apple.com/en-us/102195',
+ },
+ [Platform.WINDOWS]: {
+ name: 'Windows',
+ url: 'https://support.microsoft.com/en-us/windows/passkeys-in-windows-301c8944-5ea2-452b-9886-97e4d2ef4422',
+ },
+ [Platform.UNKNOWN]: {
+ name: 'your device',
+ url: 'https://www.passkeys.com/what-are-passkeys',
+ },
+} as const
+
+interface BalanceWarningModalProps {
+ visible: boolean
+ onCloseAction: () => void
+}
+
+function detectPlatform(): Platform {
+ if (typeof window === 'undefined') return Platform.UNKNOWN
+
+ const userAgent = navigator.userAgent.toLowerCase()
+
+ // iOS detection (including iPad on iOS 13+)
+ if (/ipad|iphone|ipod/.test(userAgent) || (navigator.maxTouchPoints > 1 && /mac/.test(userAgent))) {
+ return Platform.IOS
+ }
+
+ // Android detection
+ if (/android/.test(userAgent)) {
+ return Platform.ANDROID
+ }
+
+ // macOS detection
+ if (/mac/.test(userAgent) && !/ipad|iphone|ipod/.test(userAgent)) {
+ return Platform.MACOS
+ }
+
+ // Windows detection
+ if (/windows|win32|win64/.test(userAgent)) {
+ return Platform.WINDOWS
+ }
+
+ return Platform.UNKNOWN
+}
+
+export default function BalanceWarningModal({ visible, onCloseAction }: BalanceWarningModalProps) {
+ const platformInfo = useMemo(() => {
+ const platform = detectPlatform()
+ return PLATFORM_INFO[platform]
+ }, [])
+ return (
+ {}}
+ preventClose={true}
+ hideOverlay={true}
+ className="z-50 !items-center !justify-center"
+ classWrap="!self-center !bottom-auto !mx-auto"
+ >
+
+
+
+
+
+
+
High Balance Warning
+
+
+ Peanut is completely self-custodial and you need your biometric passkey to access your
+ account.
+
+
+ No support team ever has access to your account and cannot recover it.{' '}
+ {platformInfo && (
+ <>
+ Learn more about how to secure your passkey on{' '}
+
+ {platformInfo.name}
+
+ .
+ >
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/src/components/Global/Banner/index.tsx b/src/components/Global/Banner/index.tsx
index 85ab86df6..cbb990377 100644
--- a/src/components/Global/Banner/index.tsx
+++ b/src/components/Global/Banner/index.tsx
@@ -1,22 +1,44 @@
-import { MAINTAINABLE_ROUTES } from '@/config/routesUnderMaintenance'
-import { usePathname } from 'next/navigation'
-import { GenericBanner } from './GenericBanner'
+import { usePathname, useRouter } from 'next/navigation'
import { MaintenanceBanner } from './MaintenanceBanner'
+import { MarqueeWrapper } from '../MarqueeWrapper'
+import config from '@/config/routesUnderMaintenance'
+import { HandThumbsUp } from '@/assets'
+import Image from 'next/image'
export function Banner() {
const pathname = usePathname()
if (!pathname) return null
+ // Don't show banner on landing page
+ if (pathname === '/') return null
+
// First check for maintenance
- const maintenanceBanner =
- if (maintenanceBanner) return maintenanceBanner
+ const isUnderMaintenance = config.routes.some((route) => pathname.startsWith(route))
+ if (isUnderMaintenance) {
+ return
+ }
+
+ // Show beta feedback banner for all paths unless under maintenance
+ return
+}
+
+function FeedbackBanner() {
+ const router = useRouter()
- // Show beta message for all request paths (create and pay) unless under maintenance
- if (pathname.startsWith(MAINTAINABLE_ROUTES.REQUEST)) {
- return
+ const handleClick = () => {
+ router.push('/support')
}
- return null
+ return (
+
+ )
}
export { GenericBanner } from './GenericBanner'
diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx
index a3bfcbf2f..0098213f0 100644
--- a/src/components/Global/DirectSendQR/index.tsx
+++ b/src/components/Global/DirectSendQR/index.tsx
@@ -15,6 +15,7 @@ import * as Sentry from '@sentry/nextjs'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useMemo, useState, type ChangeEvent } from 'react'
import { twMerge } from 'tailwind-merge'
+import ActionModal from '../ActionModal'
import { Icon, IconName } from '../Icons/Icon'
import { EQrType, NAME_BY_QR_TYPE, parseEip681, recognizeQr } from './utils'
@@ -96,15 +97,15 @@ function DirectSendContent({ redirectTo, setIsModalOpen }: ModalContentProps) {
const router = useRouter()
return (
-
Peanut only supports USDC on Arbitrum.
-
Please confirm with the recipient that they accept USDC on Arbitrum
+
Peanut supports cross-chain payments.
+
Please confirm the payment details before sending
) => {
setUserAcknowledged(e.target.checked)
}}
className="mt-4"
- label="Got it, USDC on Arbitrum only."
+ label="I understand and will confirm payment details."
/>
)
diff --git a/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx b/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx
index 5a2fc9a60..7d5b9a5c2 100644
--- a/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx
+++ b/src/components/Global/GeneralRecipientInput/__tests__/GeneralRecipientInput.test.tsx
@@ -23,6 +23,7 @@ jest.mock('@/utils', () => {
validateBankAccount: jest.fn(),
sanitizeBankAccount: (input: string) => input.toLowerCase().replace(/\s/g, ''),
validateEnsName: actualUtils.validateEnsName, // Use the actual implementation
+ fetchWithSentry: jest.fn(),
}
})
@@ -51,9 +52,10 @@ describe('GeneralRecipientInput Type Detection', () => {
onUpdateMock = jest.fn()
jest.clearAllMocks()
;(utils.validateBankAccount as jest.Mock).mockResolvedValue(true)
+ ;(utils.fetchWithSentry as jest.Mock).mockResolvedValue({ status: 404 })
})
- const setup = async (initialValue = '') => {
+ const setup = async (initialValue = '', isWithdrawal = false) => {
let component: ReturnType
await act(async () => {
component = render(
@@ -61,6 +63,7 @@ describe('GeneralRecipientInput Type Detection', () => {
placeholder="Enter recipient"
recipient={{ address: initialValue, name: undefined }}
onUpdate={onUpdateMock}
+ isWithdrawal={isWithdrawal}
/>
)
})
@@ -157,14 +160,14 @@ describe('GeneralRecipientInput Type Detection', () => {
expectedType: 'address',
expectedValid: false,
description: 'invalid ETH address (too short)',
- expectedError: 'Invalid Ethereum address',
+ expectedError: 'Invalid address',
},
{
input: '0x742d35Cc6634C0532925a3b844Bc454e4438f44ez',
expectedType: 'address',
expectedValid: false,
description: 'invalid ETH address (invalid characters)',
- expectedError: 'Invalid Ethereum address',
+ expectedError: 'Invalid address',
},
{
input: '742d35Cc6634C0532925a3b844Bc454e4438f44e',
@@ -224,4 +227,68 @@ describe('GeneralRecipientInput Type Detection', () => {
}
)
})
+
+ describe('Withdrawal Context Behavior', () => {
+ const withdrawalTestCases: TestCase[] = [
+ {
+ input: 'kusharc',
+ expectedType: 'ens',
+ expectedValid: false,
+ description: 'username treated as ENS in withdrawal context',
+ expectedError: 'ENS name not found',
+ },
+ {
+ input: 'someuser123',
+ expectedType: 'ens',
+ expectedValid: false,
+ description: 'alphanumeric username treated as ENS in withdrawal context',
+ expectedError: 'ENS name not found',
+ },
+ {
+ input: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
+ expectedType: 'address',
+ expectedValid: true,
+ description: 'valid ETH address works in withdrawal context',
+ },
+ {
+ input: 'vitalik.eth',
+ expectedType: 'ens',
+ expectedValid: true,
+ description: 'valid ENS name works in withdrawal context',
+ expectedAddress: '0x742d35Cc6634C0532925a3b844Bc454e4438f44e',
+ expectedName: 'vitalik.eth',
+ },
+ ]
+
+ withdrawalTestCases.forEach(
+ ({ input, expectedType, expectedValid, description, expectedAddress, expectedName, expectedError }) => {
+ it(`should handle ${description}`, async () => {
+ // Setup ENS mock if needed
+ if (validateEnsName(input) || expectedType === 'ens') {
+ ;(ens.resolveEns as jest.Mock).mockResolvedValue(expectedValid ? expectedAddress : null)
+ }
+
+ await setup(input, true) // isWithdrawal = true
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ })
+
+ expect(onUpdateMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ type: expectedType,
+ isValid: expectedValid,
+ recipient: expectedValid
+ ? {
+ address: expectedAddress ?? input,
+ name: expectedName,
+ }
+ : expect.any(Object),
+ ...(expectedError && { errorMessage: expectedError }),
+ })
+ )
+ })
+ }
+ )
+ })
})
diff --git a/src/components/Global/GeneralRecipientInput/index.tsx b/src/components/Global/GeneralRecipientInput/index.tsx
index 98430bdc3..0c92500dd 100644
--- a/src/components/Global/GeneralRecipientInput/index.tsx
+++ b/src/components/Global/GeneralRecipientInput/index.tsx
@@ -59,19 +59,18 @@ const GeneralRecipientInput = ({
isValid = true
} else {
try {
- const validation = await validateAndResolveRecipient(trimmedInput)
-
- // Only accept ENS accounts, reject usernames
- if (isWithdrawal && validation.recipientType.toLowerCase() === 'username') {
- errorMessage.current = 'Peanut usernames are not supported for withdrawals.'
- return false
- }
+ const validation = await validateAndResolveRecipient(trimmedInput, isWithdrawal)
isValid = true
resolvedAddress.current = validation.resolvedAddress
type = validation.recipientType.toLowerCase() as interfaces.RecipientType
} catch (error: unknown) {
errorMessage.current = (error as Error).message
+ // For withdrawal context, failed non-address inputs should be treated as ENS
+ if (isWithdrawal && !trimmedInput.startsWith('0x')) {
+ type = 'ens'
+ }
+ recipientType.current = type
return false
}
}
diff --git a/src/components/Global/Layout/index.tsx b/src/components/Global/Layout/index.tsx
index a5401518f..46e0d5521 100644
--- a/src/components/Global/Layout/index.tsx
+++ b/src/components/Global/Layout/index.tsx
@@ -49,7 +49,6 @@ const Layout = ({ children, className }: LayoutProps) => {
-
{
diff --git a/src/components/Global/PeanutActionDetailsCard/index.tsx b/src/components/Global/PeanutActionDetailsCard/index.tsx
index ab2cf3003..9c0201be3 100644
--- a/src/components/Global/PeanutActionDetailsCard/index.tsx
+++ b/src/components/Global/PeanutActionDetailsCard/index.tsx
@@ -8,6 +8,7 @@ import { twMerge } from 'tailwind-merge'
import Attachment from '../Attachment'
import Card from '../Card'
import { Icon, IconName } from '../Icons/Icon'
+import RouteExpiryTimer from '../RouteExpiryTimer'
import Image from 'next/image'
interface PeanutActionDetailsCardProps {
@@ -19,6 +20,7 @@ interface PeanutActionDetailsCardProps {
| 'ADD_MONEY'
| 'WITHDRAW'
| 'WITHDRAW_BANK_ACCOUNT'
+ | 'ADD_MONEY_BANK_ACCOUNT'
recipientType: RecipientType | 'BANK_ACCOUNT'
recipientName: string
message?: string
@@ -30,6 +32,14 @@ interface PeanutActionDetailsCardProps {
avatarSize?: AvatarSize
countryCodeForFlag?: string
currencySymbol?: string
+ // Cross-chain timer props
+ showTimer?: boolean
+ timerExpiry?: string
+ isTimerLoading?: boolean
+ onTimerNearExpiry?: () => void
+ onTimerExpired?: () => void
+ disableTimerRefetch?: boolean
+ timerError?: string | null
}
export default function PeanutActionDetailsCard({
@@ -43,6 +53,13 @@ export default function PeanutActionDetailsCard({
className,
fileUrl,
avatarSize = 'medium',
+ showTimer = false,
+ timerExpiry,
+ isTimerLoading = false,
+ onTimerNearExpiry,
+ onTimerExpired,
+ disableTimerRefetch = false,
+ timerError = null,
countryCodeForFlag,
currencySymbol,
}: PeanutActionDetailsCardProps) {
@@ -81,7 +98,7 @@ export default function PeanutActionDetailsCard({
const getAvatarIcon = useCallback((): IconName | undefined => {
if (viewType === 'SUCCESS') return 'check'
- if (transactionType === 'WITHDRAW_BANK_ACCOUNT') return 'bank'
+ if (transactionType === 'WITHDRAW_BANK_ACCOUNT' || transactionType === 'ADD_MONEY_BANK_ACCOUNT') return 'bank'
if (recipientType !== 'USERNAME' || transactionType === 'ADD_MONEY' || transactionType === 'WITHDRAW')
return 'wallet-outline'
return undefined
@@ -115,7 +132,7 @@ export default function PeanutActionDetailsCard({
}
const isWithdrawBankAccount = transactionType === 'WITHDRAW_BANK_ACCOUNT' && recipientType === 'BANK_ACCOUNT'
- const isAddBankAccount = transactionType === 'ADD_MONEY'
+ const isAddBankAccount = transactionType === 'ADD_MONEY_BANK_ACCOUNT'
const withdrawBankIcon = () => {
if (isWithdrawBankAccount || isAddBankAccount)
@@ -139,39 +156,51 @@ export default function PeanutActionDetailsCard({
}
return (
-
+
- {isWithdrawBankAccount || isAddBankAccount ? (
- withdrawBankIcon()
- ) : (
-
- )}
-
+
+ {isWithdrawBankAccount || isAddBankAccount ? (
+ withdrawBankIcon()
+ ) : (
+
+ )}
+
-
- {getTitle()}
-
- {transactionType === 'ADD_MONEY' && currencySymbol
- ? `${currencySymbol} `
- : tokenSymbol.toLowerCase() === PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase()
- ? '$ '
- : ''}
- {amount}
+
+ {getTitle()}
+
+ {transactionType === 'ADD_MONEY' && currencySymbol
+ ? `${currencySymbol} `
+ : tokenSymbol.toLowerCase() === PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase()
+ ? '$ '
+ : ''}
+ {amount}
- {tokenSymbol.toLowerCase() !== PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase() &&
- transactionType !== 'ADD_MONEY' &&
- ` ${tokenSymbol.toLowerCase() === 'pnt' ? (Number(amount) === 1 ? 'Beer' : 'Beers') : tokenSymbol}`}
-
+ {tokenSymbol.toLowerCase() !== PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase() &&
+ transactionType !== 'ADD_MONEY' &&
+ ` ${tokenSymbol.toLowerCase() === 'pnt' ? (Number(amount) === 1 ? 'Beer' : 'Beers') : tokenSymbol}`}
+
-
+
+ {showTimer && (
+
+ )}
+
)
diff --git a/src/components/Global/RouteExpiryTimer/index.tsx b/src/components/Global/RouteExpiryTimer/index.tsx
new file mode 100644
index 000000000..539d2d23a
--- /dev/null
+++ b/src/components/Global/RouteExpiryTimer/index.tsx
@@ -0,0 +1,193 @@
+import React, { useState, useEffect, useCallback, useMemo } from 'react'
+import { twMerge } from 'tailwind-merge'
+
+interface RouteExpiryTimerProps {
+ expiry?: string // Unix timestamp in seconds
+ isLoading?: boolean
+ onNearExpiry?: () => void // Called when timer gets close to expiry (e.g., 30 seconds)
+ onExpired?: () => void // Called when timer expires
+ className?: string
+ nearExpiryThresholdPercentage?: number
+ disableRefetch?: boolean // Disable refetching when user is signing transaction
+ error?: string | null // Error message to display instead of timer
+}
+
+interface TimeRemaining {
+ minutes: number
+ seconds: number
+ totalMs: number
+}
+
+const RouteExpiryTimer: React.FC = ({
+ expiry,
+ isLoading = false,
+ onNearExpiry,
+ onExpired,
+ className,
+ nearExpiryThresholdPercentage = 0.1, // 10% of total duration
+ disableRefetch = false,
+ error = null,
+}) => {
+ const [timeRemaining, setTimeRemaining] = useState(null)
+ const [hasTriggeredNearExpiry, setHasTriggeredNearExpiry] = useState(false)
+ const [hasExpired, setHasExpired] = useState(false)
+
+ const totalDurationMs = useMemo(() => {
+ if (!expiry) return 0
+ const expiryMs = parseInt(expiry, 10) * 1000
+ const diff = expiryMs - Date.now()
+ return Math.max(0, diff)
+ }, [expiry])
+ const nearExpiryThresholdMs = useMemo(() => totalDurationMs * nearExpiryThresholdPercentage, [totalDurationMs])
+
+ const calculateTimeRemaining = useCallback((): TimeRemaining | null => {
+ if (!expiry) return null
+
+ const now = new Date().getTime()
+ // Expiry is Unix timestamp in seconds, convert to milliseconds
+ const expiryTime = parseInt(expiry) * 1000
+
+ // Check if expiry time is valid
+ if (isNaN(expiryTime)) {
+ console.warn('Invalid expiry time:', expiry)
+ return null
+ }
+
+ const diff = expiryTime - now
+
+ if (diff <= 0) {
+ return { minutes: 0, seconds: 0, totalMs: 0 }
+ }
+
+ const minutes = Math.floor(diff / 60000)
+ const seconds = Math.floor((diff % 60000) / 1000)
+
+ return { minutes, seconds, totalMs: diff }
+ }, [expiry])
+
+ useEffect(() => {
+ if (!expiry || isLoading) {
+ setTimeRemaining(null)
+ setHasTriggeredNearExpiry(false)
+ setHasExpired(false)
+ return
+ }
+
+ const updateTimer = () => {
+ const remaining = calculateTimeRemaining()
+ setTimeRemaining(remaining)
+
+ if (!remaining || remaining.totalMs <= 0) {
+ if (!hasExpired) {
+ setHasExpired(true)
+ onExpired?.()
+ }
+ return
+ }
+
+ // Trigger near expiry callback only if refetch is not disabled
+ if (
+ !disableRefetch &&
+ !hasTriggeredNearExpiry &&
+ remaining.totalMs <= nearExpiryThresholdMs &&
+ remaining.totalMs > 0
+ ) {
+ setHasTriggeredNearExpiry(true)
+ onNearExpiry?.()
+ }
+ }
+
+ // Initial calculation
+ updateTimer()
+
+ // Set up interval to update every second
+ const interval = setInterval(updateTimer, 1000)
+
+ return () => clearInterval(interval)
+ }, [
+ expiry,
+ isLoading,
+ calculateTimeRemaining,
+ onNearExpiry,
+ onExpired,
+ nearExpiryThresholdMs,
+ hasTriggeredNearExpiry,
+ hasExpired,
+ disableRefetch,
+ ])
+
+ const formatTime = (time: TimeRemaining): string => {
+ const paddedMinutes = time.minutes.toString().padStart(2, '0')
+ const paddedSeconds = time.seconds.toString().padStart(2, '0')
+ return `${paddedMinutes}:${paddedSeconds}`
+ }
+
+ const progressPercentage = useMemo((): number => {
+ if (!timeRemaining || !totalDurationMs) return 0
+ const elapsedMs = totalDurationMs - timeRemaining.totalMs
+ return Math.max(0, Math.min(100, (elapsedMs / totalDurationMs) * 100))
+ }, [timeRemaining, totalDurationMs])
+
+ const progressColor = useMemo((): string => {
+ if (!timeRemaining) return 'bg-grey-3'
+
+ // Green for first 70%
+ if (progressPercentage < 70) return 'bg-green-500'
+ // Yellow for 70-85%
+ if (progressPercentage < 85) return 'bg-yellow-500'
+ // Red for final 15%
+ return 'bg-red'
+ }, [progressPercentage, timeRemaining])
+
+ const shouldPulse = useMemo((): boolean => {
+ if (isLoading) return true
+ if (!timeRemaining) return false
+ // Pulse when in red zone (85%+ progress) OR near expiry threshold
+ return (progressPercentage >= 85 || timeRemaining.totalMs <= nearExpiryThresholdMs) && timeRemaining.totalMs > 0
+ }, [progressPercentage, timeRemaining, isLoading, nearExpiryThresholdMs])
+
+ const getText = (): string => {
+ if (error) return error
+ if (isLoading) return 'Finding best rate...'
+ if (!expiry) return 'No quote available'
+ if (!timeRemaining) return 'Quote expired'
+ if (timeRemaining.totalMs <= 0) return 'Quote expired'
+ return `Price locked for ${formatTime(timeRemaining)}`
+ }
+
+ return (
+
+ {/* Status text */}
+
+ 0)
+ ? 'text-grey-1'
+ : 'text-error'
+ )}
+ >
+ {getText()}
+
+
+
+ {/* Progress bar */}
+
+
+ )
+}
+
+export default RouteExpiryTimer
diff --git a/src/components/Global/ScreenOrientationLocker.tsx b/src/components/Global/ScreenOrientationLocker.tsx
new file mode 100644
index 000000000..e200813fa
--- /dev/null
+++ b/src/components/Global/ScreenOrientationLocker.tsx
@@ -0,0 +1,39 @@
+'use client'
+
+import { useEffect } from 'react'
+import { captureException } from '@sentry/nextjs'
+
+export function ScreenOrientationLocker() {
+ useEffect(() => {
+ const lockOrientation = async () => {
+ if (screen.orientation && (screen.orientation as any).lock) {
+ try {
+ await (screen.orientation as any).lock('portrait-primary')
+ } catch (error) {
+ console.error('Failed to lock screen orientation:', error)
+ captureException(error)
+ }
+ }
+ }
+
+ lockOrientation()
+
+ const handleOrientationChange = () => {
+ // if the orientation is no longer portrait, try to lock it back.
+ if (screen.orientation && !screen.orientation.type.startsWith('portrait')) {
+ lockOrientation()
+ }
+ }
+
+ // some browsers might not support addEventListener on screen.orientation
+ if (screen.orientation && screen.orientation.addEventListener) {
+ screen.orientation.addEventListener('change', handleOrientationChange)
+
+ return () => {
+ screen.orientation.removeEventListener('change', handleOrientationChange)
+ }
+ }
+ }, [])
+
+ return null
+}
diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx
index 7211c940e..39387c799 100644
--- a/src/components/Global/TokenAmountInput/index.tsx
+++ b/src/components/Global/TokenAmountInput/index.tsx
@@ -11,6 +11,7 @@ interface TokenAmountInputProps {
setUsdValue?: (usdvalue: string) => void
setCurrencyAmount?: (currencyvalue: string | undefined) => void
onSubmit?: () => void
+ onBlur?: () => void
disabled?: boolean
walletBalance?: string
currency?: {
@@ -28,6 +29,7 @@ const TokenAmountInput = ({
setTokenValue,
setCurrencyAmount,
onSubmit,
+ onBlur,
disabled,
walletBalance,
currency,
@@ -183,13 +185,6 @@ const TokenAmountInput = ({
}
}, [setUsdValue, displayValue, alternativeDisplayValue, isInputUsd, displayMode])
- const parentWidth = useMemo(() => {
- if (inputRef.current && inputRef.current.parentElement) {
- return inputRef.current.parentElement.offsetWidth
- }
- return 'auto'
- }, [])
-
const formRef = useRef(null)
const handleContainerClick = () => {
@@ -208,7 +203,7 @@ const TokenAmountInput = ({
{
const value = formatAmountWithoutComma(e.target.value)
@@ -227,7 +222,9 @@ const TokenAmountInput = ({
if (onSubmit) onSubmit()
}
}}
- style={{ maxWidth: `${parentWidth}px` }}
+ onBlur={() => {
+ if (onBlur) onBlur()
+ }}
disabled={disabled}
/>
diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx
index 064fd411a..be8d98a6f 100644
--- a/src/components/Global/TokenSelector/TokenSelector.tsx
+++ b/src/components/Global/TokenSelector/TokenSelector.tsx
@@ -11,7 +11,7 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants/zerodev.co
import { tokenSelectorContext } from '@/context'
import { useDynamicHeight } from '@/hooks/ui/useDynamicHeight'
import { IToken, IUserBalance } from '@/interfaces'
-import { areEvmAddressesEqual, fetchWalletBalances, formatTokenAmount, isNativeCurrency } from '@/utils'
+import { areEvmAddressesEqual, formatTokenAmount, isNativeCurrency } from '@/utils'
import { SQUID_ETH_ADDRESS } from '@/utils/token.utils'
import { useAppKit, useAppKitAccount, useDisconnect } from '@reown/appkit/react'
import EmptyState from '../EmptyStates/EmptyState'
@@ -26,6 +26,7 @@ import {
TOKEN_SELECTOR_POPULAR_NETWORK_IDS,
TOKEN_SELECTOR_SUPPORTED_NETWORK_IDS,
} from './TokenSelector.consts'
+import { fetchWalletBalances } from '@/app/actions/tokens'
interface SectionProps {
title: string
@@ -464,21 +465,13 @@ const TokenSelector: React.FC = ({ classNameButton, viewT
chainDataFromSquid?.tokens.some((squidToken) =>
areEvmAddressesEqual(squidToken.address, balance.address)
) ?? false
- // TODO: remove on coral integration
- // USDT in mainnet is not an erc20 token and needs to have the
- // allowance reseted to 0 before using it. Is not being used
- // currently in prod so we are not investing time in supporting
- // it.
- const isUsdtInMainnet =
- balance.chainId === '1' &&
- areEvmAddressesEqual(balance.address, '0xdac17f958d2ee523a2206206994597c13d831ec7')
return (
handleTokenSelect(balance)}
isSelected={isSelected}
- isEnabled={isTokenSupportedBySquid && !isUsdtInMainnet}
+ isEnabled={isTokenSupportedBySquid}
/>
)
})
diff --git a/src/components/Global/WalletNavigation/index.tsx b/src/components/Global/WalletNavigation/index.tsx
index 95a4c1dcc..ee5522da6 100644
--- a/src/components/Global/WalletNavigation/index.tsx
+++ b/src/components/Global/WalletNavigation/index.tsx
@@ -18,7 +18,8 @@ type NavPathProps = {
const desktopPaths: NavPathProps[] = [
{ name: 'Send', href: '/send', icon: 'arrow-up-right', size: 10 },
{ name: 'Request', href: '/request', icon: 'arrow-down-left', size: 10 },
- { name: 'Cashout', href: '/cashout', icon: 'arrow-down', size: 12 },
+ { name: 'Add', href: '/add-money', icon: 'arrow-down', size: 14 },
+ { name: 'Withdraw', href: '/withdraw', icon: 'arrow-up', size: 14 },
{ name: 'History', href: '/history', icon: 'history', size: 16 },
{ name: 'Docs', href: 'https://docs.peanut.to/', icon: 'docs', size: 16 },
{ name: 'Support', href: '/support', icon: 'peanut-support', size: 16 },
@@ -50,7 +51,7 @@ const NavSection: React.FC = ({ paths, pathName }) => (
{name}
- {index === 3 && }
+ {index === 4 && }
))}
>
diff --git a/src/components/Home/HomeHistory.tsx b/src/components/Home/HomeHistory.tsx
index 4119a902e..5065d98aa 100644
--- a/src/components/Home/HomeHistory.tsx
+++ b/src/components/Home/HomeHistory.tsx
@@ -15,6 +15,7 @@ import Card, { CardPosition, getCardPosition } from '../Global/Card'
import EmptyState from '../Global/EmptyStates/EmptyState'
import { KycStatusItem } from '../Kyc/KycStatusItem'
import { isKycStatusItem, KycHistoryEntry } from '@/hooks/useKycFlow'
+import { KYCStatus } from '@/utils'
/**
* component to display a preview of the most recent transactions on the home page.
@@ -26,6 +27,7 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern
const mode = isPublic ? 'public' : 'latest'
const limit = isPublic ? 20 : 5
const { data: historyData, isLoading, isError, error } = useTransactionHistory({ mode, limit, username })
+ const kycStatus: KYCStatus = user?.user?.kycStatus || 'not_started'
// WebSocket for real-time updates
const { historyEntries: wsHistoryEntries } = useWebSocket({
@@ -134,10 +136,13 @@ const HomeHistory = ({ isPublic = false, username }: { isPublic?: boolean; usern
if (!combinedEntries.length) {
return (
-
-
Activity
-
-
+ {kycStatus !== 'not_started' && (
+
+
Activity
+
+
+ )}
+
Recent Transactions
+
+ {/* Main heading */}
+
+
+
+
+ {/* Stylized BUSINESS title using knerd fonts */}
+
+
+
+ BUSINESS
+
+ BUSINESS
+
+
+
+
+ {/* Subtitle with scribble around a word */}
+
+ PLUG-AND-PLAY MONEY RAILS
FOR PRODUCTS THAT NEED TO MOVE FAST.
+
+
+ {/* CTA Button */}
+
+ INTEGRATE PEANUT
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/hero.tsx b/src/components/LandingPage/hero.tsx
index 2431b54a1..5b11a7c27 100644
--- a/src/components/LandingPage/hero.tsx
+++ b/src/components/LandingPage/hero.tsx
@@ -1,10 +1,11 @@
-import { AboutPeanut, ButterySmoothGlobalMoney, HandThumbsUp, PeanutGuyGIF, Sparkle } from '@/assets'
+import { ButterySmoothGlobalMoney, PeanutGuyGIF, Sparkle } from '@/assets'
import { Stack } from '@chakra-ui/react'
import { motion } from 'framer-motion'
import { useEffect, useState } from 'react'
import { twMerge } from 'tailwind-merge'
-import { MarqueeComp } from '../Global/MarqueeWrapper'
import { CloudImages, HeroImages } from './imageAssets'
+import Image from 'next/image'
+import instantlySendReceive from '@/assets/illustrations/instantly-send-receive.svg'
type CTAButton = {
label: string
@@ -14,72 +15,140 @@ type CTAButton = {
type HeroProps = {
heading: string
- marquee?: {
- visible: boolean
- message?: string[]
- }
primaryCta?: CTAButton
secondaryCta?: CTAButton
buttonVisible?: boolean
+ buttonScale?: number
}
-export function Hero({ heading, marquee = { visible: false }, primaryCta, secondaryCta, buttonVisible }: HeroProps) {
- const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200) // Added typeof check for SSR
+// Helper functions moved outside component for better performance
+const getInitialAnimation = (variant: 'primary' | 'secondary') => ({
+ opacity: 0,
+ translateY: 4,
+ translateX: variant === 'primary' ? 0 : 4,
+ rotate: 0.75,
+})
+
+const getAnimateAnimation = (variant: 'primary' | 'secondary', buttonVisible?: boolean, buttonScale?: number) => ({
+ opacity: buttonVisible ? 1 : 0,
+ translateY: buttonVisible ? 0 : 20,
+ translateX: buttonVisible ? (variant === 'primary' ? 0 : 0) : 20,
+ rotate: buttonVisible ? 0 : 1,
+ scale: buttonScale || 1,
+ pointerEvents: buttonVisible ? ('auto' as const) : ('none' as const),
+})
+
+const getHoverAnimation = (variant: 'primary' | 'secondary') => ({
+ translateY: 6,
+ translateX: variant === 'primary' ? 0 : 3,
+ rotate: 0.75,
+})
+
+const transitionConfig = { type: 'spring', damping: 15 } as const
+
+const getButtonContainerClasses = (variant: 'primary' | 'secondary') =>
+ `relative z-20 mt-8 md:mt-12 ${variant === 'primary' ? 'mx-auto w-fit' : 'right-[calc(50%-120px)]'}`
+
+const getButtonClasses = (variant: 'primary' | 'secondary') =>
+ `${variant === 'primary' ? 'btn bg-white fill-n-1 text-n-1 hover:bg-white/90' : 'btn-yellow'} px-7 md:px-9 py-3 md:py-8 text-base md:text-xl btn-shadow-primary-4`
+
+const renderSparkle = (variant: 'primary' | 'secondary') =>
+ variant === 'primary' && (
+
+ )
+
+const renderArrows = (variant: 'primary' | 'secondary', arrowOpacity: number, buttonVisible?: boolean) =>
+ variant === 'primary' && (
+ <>
+
+
+
+
+ >
+ )
+
+export function Hero({ heading, primaryCta, secondaryCta, buttonVisible, buttonScale = 1 }: HeroProps) {
+ const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
+ const [scrollY, setScrollY] = useState(0)
useEffect(() => {
const handleResize = () => {
setScreenWidth(window.innerWidth)
}
- handleResize() // Call once initially to set duration
- window.addEventListener('resize', handleResize) // Recalculate on window resize
+ const handleScroll = () => {
+ setScrollY(window.scrollY)
+ }
+
+ handleResize()
+ window.addEventListener('resize', handleResize)
+ window.addEventListener('scroll', handleScroll)
- return () => window.removeEventListener('resize', handleResize)
+ return () => {
+ window.removeEventListener('resize', handleResize)
+ window.removeEventListener('scroll', handleScroll)
+ }
}, [])
- const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => (
-
- {variant === 'primary' && (
-
- )}
+ const renderCTAButton = (cta: CTAButton, variant: 'primary' | 'secondary') => {
+ const arrowOpacity = 1 // Always visible
-
- {cta.label}
-
-
- )
+ {/* {renderSparkle(variant)} */}
+
+
+ {cta.label}
+
+
+ {renderArrows(variant, arrowOpacity, buttonVisible)}
+
+ )
+ }
return (
@@ -103,30 +172,28 @@ export function Hero({ heading, marquee = { visible: false }, primaryCta, second
-
+
+
+
+ MONEY ACROSS THE GLOBE
+
+
+
+ {primaryCta && renderCTAButton(primaryCta, 'primary')}
+ {secondaryCta && renderCTAButton(secondaryCta, 'secondary')}
-
-
- {marquee?.visible && (
-
- )}
-
-
-
- {primaryCta && renderCTAButton(primaryCta, 'primary')}
- {secondaryCta && renderCTAButton(secondaryCta, 'secondary')}
-
)
}
diff --git a/src/components/LandingPage/imageAssets.tsx b/src/components/LandingPage/imageAssets.tsx
index 754eab273..f6a6ff88e 100644
--- a/src/components/LandingPage/imageAssets.tsx
+++ b/src/components/LandingPage/imageAssets.tsx
@@ -131,14 +131,6 @@ export const HeroImages = () => {
src={Star.src}
className="absolute right-[1.5%] top-[-12%] w-8 sm:right-[6%] sm:top-[8%] md:right-[5%] md:top-[8%] md:w-12 lg:right-[10%]"
/>
- {/*
*/}
>
)
}
diff --git a/src/components/LandingPage/index.ts b/src/components/LandingPage/index.ts
index 515837bda..9fd9f9d87 100644
--- a/src/components/LandingPage/index.ts
+++ b/src/components/LandingPage/index.ts
@@ -1,3 +1,9 @@
+export * from './businessIntegrate'
export * from './faq'
export * from './hero'
+export * from './marquee'
+export * from './noFees'
export * from './nutsDivider'
+export * from './securityBuiltIn'
+export * from './sendInSeconds'
+export * from './yourMoney'
diff --git a/src/components/LandingPage/marquee.tsx b/src/components/LandingPage/marquee.tsx
new file mode 100644
index 000000000..e8922fad9
--- /dev/null
+++ b/src/components/LandingPage/marquee.tsx
@@ -0,0 +1,24 @@
+import { HandThumbsUp } from '@/assets'
+import { MarqueeComp } from '../Global/MarqueeWrapper'
+
+type MarqueeProps = {
+ visible?: boolean
+ message?: string[]
+ imageSrc?: string
+ backgroundColor?: string
+}
+
+export function Marquee({
+ visible = true,
+ message = ['No fees', 'Instant', '24/7', 'Dollars', 'Fiat / Crypto'],
+ imageSrc = HandThumbsUp.src,
+ backgroundColor = 'bg-secondary-1',
+}: MarqueeProps) {
+ if (!visible) return null
+
+ return (
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/noFees.tsx b/src/components/LandingPage/noFees.tsx
new file mode 100644
index 000000000..f6fbeac2b
--- /dev/null
+++ b/src/components/LandingPage/noFees.tsx
@@ -0,0 +1,181 @@
+import React, { useEffect, useState } from 'react'
+import { motion } from 'framer-motion'
+import gotItHand from '@/assets/illustrations/got-it-hand.svg'
+import gotItHandFlipped from '@/assets/illustrations/got-it-hand-flipped.svg'
+import borderCloud from '@/assets/illustrations/border-cloud.svg'
+import zero from '@/assets/illustrations/zero.svg'
+import mobileZeroFees from '@/assets/illustrations/mobile-zero-fees.svg'
+import noHiddenFees from '@/assets/illustrations/no-hidden-fees.svg'
+import { Star } from '@/assets'
+import scribble from '@/assets/scribble.svg'
+import Image from 'next/image'
+
+export function NoFees() {
+ const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
+
+ useEffect(() => {
+ const handleResize = () => {
+ setScreenWidth(window.innerWidth)
+ }
+
+ handleResize()
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ const createCloudAnimation = (side: 'left' | 'right', top: string, width: number, speed: number) => {
+ const vpWidth = screenWidth || 1080
+ const totalDistance = vpWidth + width
+
+ return {
+ initial: { x: side === 'left' ? -width : vpWidth },
+ animate: { x: side === 'left' ? vpWidth : -width },
+ transition: {
+ ease: 'linear',
+ duration: totalDistance / speed,
+ repeat: Infinity,
+ },
+ }
+ }
+
+ return (
+
+
+ {/* Animated clouds */}
+
+
+
+
+
+ {/* Animated stars */}
+
+
+
+
+
+ {/* Main stylized headline */}
+
+ {/* Mobile version */}
+
+
+
+
+ {/* Desktop version */}
+
+
+
+
+
+ FEES
+
+
+ FEES
+
+
+
+
+ {/* Bottom left arrow pointing to zero */}
+
+
+ {/* Bottom right arrow pointing to "S" in FEES */}
+
+
+
+
+ {/* No hidden fees SVG */}
+
+
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/securityBuiltIn.tsx b/src/components/LandingPage/securityBuiltIn.tsx
new file mode 100644
index 000000000..359737673
--- /dev/null
+++ b/src/components/LandingPage/securityBuiltIn.tsx
@@ -0,0 +1,110 @@
+import React from 'react'
+import Image from 'next/image'
+import handThumbsUp from '@/assets/illustrations/hand-thumbs-up.svg'
+import handWaving from '@/assets/illustrations/hand-waving.svg'
+import handPeace from '@/assets/illustrations/hand-peace.svg'
+import securityPrivacyBuiltIn from '@/assets/illustrations/security-privacy-built-in.svg'
+import mobileSecurityPrivacyBuiltIn from '@/assets/illustrations/mobile-security-privacy-built-in.svg'
+import biometricProtection from '@/assets/illustrations/biometric-protection.svg'
+import selfCustodialDesign from '@/assets/illustrations/self-custodial-design.svg'
+import kycOnlyWhenRequired from '@/assets/illustrations/kyc-only-when-required.svg'
+
+interface Feature {
+ id: number
+ title: string
+ titleSvg: any
+ description: string
+ iconSrc: any
+ iconAlt: string
+}
+
+const features: Feature[] = [
+ {
+ id: 1,
+ title: 'BIOMETRIC PROTECTION',
+ titleSvg: biometricProtection,
+ description: 'Verify with Face ID, Touch ID or passcode, every single action is yours to approve.',
+ iconSrc: handThumbsUp,
+ iconAlt: 'Thumbs up',
+ },
+ {
+ id: 2,
+ title: 'SELF-CUSTODIAL BY DESIGN',
+ titleSvg: selfCustodialDesign,
+ description: "Peanut is fully self-custodial. Your assets can't be frozen or moved by anyone else.",
+ iconSrc: handWaving,
+ iconAlt: 'Hand waving',
+ },
+ {
+ id: 3,
+ title: 'KYC ONLY WHEN REQUIRED',
+ titleSvg: kycOnlyWhenRequired,
+ description: 'No mandatory hoops, verify your identity only if you actually need the feature.',
+ iconSrc: handPeace,
+ iconAlt: 'Peace sign',
+ },
+]
+
+export function SecurityBuiltIn() {
+ return (
+
+
+
+ {/* Mobile version */}
+
+ {/* Desktop version */}
+
+
+
+ {features.map((feature) => (
+
+
+
+
+
+
+
+ {feature.description}
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/sendInSeconds.tsx b/src/components/LandingPage/sendInSeconds.tsx
new file mode 100644
index 000000000..1e7a1c688
--- /dev/null
+++ b/src/components/LandingPage/sendInSeconds.tsx
@@ -0,0 +1,242 @@
+import React, { useEffect, useState } from 'react'
+import { motion } from 'framer-motion'
+import Image from 'next/image'
+import borderCloud from '@/assets/illustrations/border-cloud.svg'
+import exclamations from '@/assets/illustrations/exclamations.svg'
+import payZeroFees from '@/assets/illustrations/pay-zero-fees.svg'
+import mobileSendInSeconds from '@/assets/illustrations/mobile-send-in-seconds.svg'
+import { Star, Sparkle } from '@/assets'
+
+export function SendInSeconds() {
+ const [screenWidth, setScreenWidth] = useState(typeof window !== 'undefined' ? window.innerWidth : 1200)
+
+ useEffect(() => {
+ const handleResize = () => {
+ setScreenWidth(window.innerWidth)
+ }
+
+ handleResize()
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ const createCloudAnimation = (side: 'left' | 'right', width: number, speed: number) => {
+ const vpWidth = screenWidth || 1080
+ const totalDistance = vpWidth + width
+
+ return {
+ initial: { x: side === 'left' ? -width : vpWidth },
+ animate: { x: side === 'left' ? vpWidth : -width },
+ transition: {
+ ease: 'linear',
+ duration: totalDistance / speed,
+ repeat: Infinity,
+ },
+ }
+ }
+
+ // Button helper functions adapted from hero.tsx
+ const getInitialAnimation = () => ({
+ opacity: 0,
+ translateY: 4,
+ translateX: 0,
+ rotate: 0.75,
+ })
+
+ const getAnimateAnimation = (buttonVisible: boolean, buttonScale: number = 1) => ({
+ opacity: buttonVisible ? 1 : 0,
+ translateY: buttonVisible ? 0 : 20,
+ translateX: buttonVisible ? 0 : 20,
+ rotate: buttonVisible ? 0 : 1,
+ scale: buttonScale,
+ pointerEvents: buttonVisible ? ('auto' as const) : ('none' as const),
+ })
+
+ const getHoverAnimation = () => ({
+ translateY: 6,
+ translateX: 0,
+ rotate: 0.75,
+ })
+
+ const transitionConfig = { type: 'spring', damping: 15 } as const
+
+ const getButtonClasses = () =>
+ `btn bg-white fill-n-1 text-n-1 hover:bg-white/90 px-9 md:px-11 py-4 md:py-10 text-lg md:text-2xl btn-shadow-primary-4`
+
+ const renderSparkle = () => (
+

+ )
+
+ const renderArrows = () => (
+ <>
+
+
+
+
+ >
+ )
+
+ return (
+
+ {/* Decorative clouds, stars, and exclamations */}
+
+ {/* Animated clouds */}
+
+
+
+
+
+
+ {/* Animated stars and exclamations */}
+
+
+
+
+
+ {/* Exclamations */}
+
+
+ {/* Main content */}
+
+
+ {/* Mobile version */}
+
+ {/* Desktop version */}
+
+
+
+
+ MOVE MONEY WORLDWIDE INSTANTLY.
+
+ ALWAYS UNDER YOUR CONTROL.
+
+
+ {/* Fixed CTA Button */}
+
+
+
+ )
+}
diff --git a/src/components/LandingPage/yourMoney.tsx b/src/components/LandingPage/yourMoney.tsx
new file mode 100644
index 000000000..97c85248c
--- /dev/null
+++ b/src/components/LandingPage/yourMoney.tsx
@@ -0,0 +1,110 @@
+import React from 'react'
+import Image from 'next/image'
+import iphoneYourMoney1 from '@/assets/iphone-ss/iphone-your-money-1.png'
+import iphoneYourMoney2 from '@/assets/iphone-ss/iphone-your-money-2.png'
+import iphoneYourMoney3 from '@/assets/iphone-ss/iphone-your-money-3.png'
+import yourMoneyAnywhere from '@/assets/illustrations/your-money-anywhere.svg'
+import mobileYourMoneyAnywhere from '@/assets/illustrations/mobile-your-money-anywhere.svg'
+import freeGlobalTransfers from '@/assets/illustrations/free-global-transfers.svg'
+import payAnyoneAnywhere from '@/assets/illustrations/pay-anyone-anywhere.svg'
+import getPaidWorldwide from '@/assets/illustrations/get-paid-worldwide.svg'
+
+interface Feature {
+ id: number
+ title: string
+ titleSvg: any
+ description: string
+ imageSrc: any
+ imageAlt: string
+}
+
+const features: Feature[] = [
+ {
+ id: 1,
+ title: 'FREE GLOBAL TRANSFERS',
+ titleSvg: freeGlobalTransfers,
+ description:
+ 'Move money between your own accounts in 140+ countries and 50+ currencies, no fees, live FX rates.',
+ imageSrc: iphoneYourMoney1,
+ imageAlt: 'iPhone showing global transfer screen',
+ },
+ {
+ id: 2,
+ title: 'PAY ANYONE, ANYWHERE',
+ titleSvg: payAnyoneAnywhere,
+ description:
+ 'Send funds in seconds through WhatsApp, a phone number, or a QR code. No bank details, no friction.',
+ imageSrc: iphoneYourMoney2,
+ imageAlt: 'iPhone showing payment options screen',
+ },
+ {
+ id: 3,
+ title: 'GET PAID WORLDWIDE',
+ titleSvg: getPaidWorldwide,
+ description:
+ 'Get paid by clients in 140+ countries, direct to your account, and settle in the currency you prefer.',
+ imageSrc: iphoneYourMoney3,
+ imageAlt: 'iPhone showing payment request screen',
+ },
+]
+
+export function YourMoney() {
+ return (
+
+
+
+ {/* Mobile version */}
+
+ {/* Desktop version */}
+
+
+
+ {features.map((feature, index) => (
+
+
+
+
+
+
+
+
+
+ {feature.description}
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx
index 9b72ade19..7cf009674 100644
--- a/src/components/Payment/PaymentForm/index.tsx
+++ b/src/components/Payment/PaymentForm/index.tsx
@@ -41,7 +41,8 @@ import { useAccount } from 'wagmi'
export type PaymentFlowProps = {
isPintaReq?: boolean
isAddMoneyFlow?: boolean
- isDirectPay?: boolean
+ /** Whether this is a direct USD payment flow (bypasses token conversion) */
+ isDirectUsdPayment?: boolean
currency?: {
code: string
symbol: string
@@ -63,7 +64,7 @@ export const PaymentForm = ({
currencyAmount,
setCurrencyAmount,
isAddMoneyFlow,
- isDirectPay,
+ isDirectUsdPayment,
}: PaymentFormProps) => {
const dispatch = useAppDispatch()
const router = useRouter()
@@ -75,8 +76,8 @@ export const PaymentForm = ({
error: paymentStoreError,
attachmentOptions,
} = usePaymentStore()
- const { isConnected: isPeanutWallet, balance } = useWallet()
- const { isConnected: isWagmiConnected, status } = useAccount()
+ const { isConnected: isPeanutWalletConnected, balance } = useWallet()
+ const { isConnected: isExternalWalletConnected, status } = useAccount()
const [initialSetupDone, setInitialSetupDone] = useState(false)
const [inputTokenAmount, setInputTokenAmount] = useState
(
chargeDetails?.tokenAmount || requestDetails?.tokenAmount || amount || ''
@@ -117,11 +118,15 @@ export const PaymentForm = ({
const requestId = searchParams.get('id')
const isDepositRequest = searchParams.get('action') === 'deposit'
+ const isUsingExternalWallet = useMemo(() => {
+ return isAddMoneyFlow || !isPeanutWalletConnected
+ }, [isPeanutWalletConnected, isAddMoneyFlow])
+
const isConnected = useMemo(() => {
- return isPeanutWallet || isWagmiConnected
- }, [isPeanutWallet, isWagmiConnected, status])
+ return isPeanutWalletConnected || isExternalWalletConnected
+ }, [isPeanutWalletConnected, isExternalWalletConnected, status])
- const isActivePeanutWallet = useMemo(() => !!user && isPeanutWallet, [user, isPeanutWallet])
+ const isActivePeanutWallet = useMemo(() => !!user && isPeanutWalletConnected, [user, isPeanutWalletConnected])
useEffect(() => {
if (initialSetupDone) return
@@ -170,7 +175,7 @@ export const PaymentForm = ({
try {
if (isAddMoneyFlow) {
// ADD MONEY FLOW: Strictly check external wallet if connected
- if (isWagmiConnected && selectedTokenData && selectedTokenBalance !== undefined) {
+ if (isExternalWalletConnected && selectedTokenData && selectedTokenBalance !== undefined) {
if (selectedTokenData.decimals === undefined) {
console.error('Selected token has no decimals information for Add Money.')
dispatch(paymentActions.setError('Cannot verify balance: token data incomplete.'))
@@ -197,7 +202,7 @@ export const PaymentForm = ({
dispatch(paymentActions.setError(null))
}
} else if (
- isWagmiConnected &&
+ isExternalWalletConnected &&
!isActivePeanutWallet &&
selectedTokenData &&
selectedTokenBalance !== undefined
@@ -238,7 +243,7 @@ export const PaymentForm = ({
isActivePeanutWallet,
dispatch,
selectedTokenData,
- isWagmiConnected,
+ isExternalWalletConnected,
isAddMoneyFlow,
])
@@ -304,7 +309,7 @@ export const PaymentForm = ({
])
const handleInitiatePayment = useCallback(async () => {
- if (!isWagmiConnected && isAddMoneyFlow) {
+ if (!isExternalWalletConnected && isAddMoneyFlow) {
openReownModal()
return
}
@@ -364,7 +369,13 @@ export const PaymentForm = ({
dispatch(paymentActions.setUsdAmount(inputUsdValue))
}
- if (!isActivePeanutWallet && isWagmiConnected && selectedTokenData && selectedChainID && !!chargeDetails) {
+ if (
+ !isActivePeanutWallet &&
+ isExternalWalletConnected &&
+ selectedTokenData &&
+ selectedChainID &&
+ !!chargeDetails
+ ) {
dispatch(paymentActions.setView('CONFIRM'))
return
}
@@ -373,6 +384,7 @@ export const PaymentForm = ({
const requestedToken = chargeDetails?.tokenAddress ?? requestDetails?.tokenAddress
const requestedChain = chargeDetails?.chainId ?? requestDetails?.chainId
+
let tokenAmount = inputTokenAmount
if (
requestedToken &&
@@ -391,7 +403,7 @@ export const PaymentForm = ({
currency,
currencyAmount,
isAddMoneyFlow: !!isAddMoneyFlow,
- transactionType: isAddMoneyFlow ? 'DEPOSIT' : isDirectPay ? 'DIRECT_SEND' : 'REQUEST',
+ transactionType: isAddMoneyFlow ? 'DEPOSIT' : isDirectUsdPayment ? 'DIRECT_SEND' : 'REQUEST',
attachmentOptions: attachmentOptions,
}
@@ -429,7 +441,7 @@ export const PaymentForm = ({
])
const getButtonText = () => {
- if (!isWagmiConnected && isAddMoneyFlow) {
+ if (!isExternalWalletConnected && isAddMoneyFlow) {
return 'Connect Wallet'
}
@@ -449,7 +461,7 @@ export const PaymentForm = ({
}
const getButtonIcon = (): IconName | undefined => {
- if (!isWagmiConnected && isAddMoneyFlow) return 'wallet-outline'
+ if (!isExternalWalletConnected && isAddMoneyFlow) return 'wallet-outline'
if (!isProcessing && isActivePeanutWallet && !isAddMoneyFlow) return 'arrow-up-right'
@@ -498,15 +510,6 @@ export const PaymentForm = ({
}
}, [isPintaReq, inputTokenAmount])
- const isXChainPeanutWalletReq = useMemo(() => {
- if (!isActivePeanutWallet || !selectedTokenData) return false
-
- const isSupportedChain = selectedChainID === PEANUT_WALLET_CHAIN.id.toString()
- const isSupportedToken = selectedTokenAddress.toLowerCase() === PEANUT_WALLET_TOKEN.toLowerCase()
-
- return !(isSupportedChain && isSupportedToken)
- }, [isActivePeanutWallet, selectedChainID, selectedTokenAddress, selectedTokenData])
-
const isButtonDisabled = useMemo(() => {
if (isProcessing) return true
if (!!error) return true
@@ -518,7 +521,7 @@ export const PaymentForm = ({
}
if (isAddMoneyFlow) {
- if (!isWagmiConnected) return false // "Connect Wallet" button should be active
+ if (!isExternalWalletConnected) return false // "Connect Wallet" button should be active
return (
!inputTokenAmount ||
isNaN(parseFloat(inputTokenAmount)) ||
@@ -532,7 +535,7 @@ export const PaymentForm = ({
// logic for non-AddMoneyFlow / non-Pinta (Pinta has its own button logic)
if (!isPintaReq) {
if (!isConnected) return true // if not connected at all, disable (covers guest non-Peanut scenarios)
- if (isActivePeanutWallet && isXChainPeanutWalletReq) return true // peanut wallet x-chain restriction
+
if (!selectedTokenAddress || !selectedChainID) return true // must have token/chain
}
// fallback for Pinta or other cases if not explicitly handled above
@@ -542,12 +545,11 @@ export const PaymentForm = ({
error,
inputTokenAmount,
isAddMoneyFlow,
- isWagmiConnected,
+ isExternalWalletConnected,
selectedTokenAddress,
selectedChainID,
isConnected,
isActivePeanutWallet,
- isXChainPeanutWalletReq,
isPintaReq,
])
@@ -598,7 +600,7 @@ export const PaymentForm = ({
- {isWagmiConnected && (!isDirectPay || isAddMoneyFlow) && (
+ {isExternalWalletConnected && isUsingExternalWallet && (
- {!isActivePeanutWallet && isConnected && !isAddMoneyFlow && (
+ {/*
+ Url request flow (peanut.me/
)
+ If we are paying from peanut wallet we only need to
+ select a token if it's not included in the url
+ From other wallets we always need to select a token
+ */}
+ {!(chain && isPeanutWalletConnected) && isConnected && !isAddMoneyFlow && (
{!isPeanutWalletUSDC && !selectedTokenAddress && !selectedChainID && (
Select token and chain to pay with
@@ -657,11 +666,11 @@ export const PaymentForm = ({
)}
- {isWagmiConnected && isAddMoneyFlow && (
-
+ {isExternalWalletConnected && isAddMoneyFlow && (
+
)}
- {isDirectPay && (
+ {isDirectUsdPayment && (
)}
- {isXChainPeanutWalletReq && !isAddMoneyFlow && (
-
- )}
+
{error && }
diff --git a/src/components/Payment/PaymentInfoRow.tsx b/src/components/Payment/PaymentInfoRow.tsx
index 525ff253d..ce671684e 100644
--- a/src/components/Payment/PaymentInfoRow.tsx
+++ b/src/components/Payment/PaymentInfoRow.tsx
@@ -2,6 +2,7 @@ import { useId, useState } from 'react'
import { twMerge } from 'tailwind-merge'
import { Icon } from '../Global/Icons/Icon'
import Loading from '../Global/Loading'
+import CopyToClipboard from '../Global/CopyToClipboard'
export const PaymentInfoRow = ({
label,
@@ -9,12 +10,16 @@ export const PaymentInfoRow = ({
moreInfoText,
loading,
hideBottomBorder,
+ allowCopy,
+ copyValue,
}: {
label: string | React.ReactNode
value: number | string | React.ReactNode
moreInfoText?: string
loading?: boolean
hideBottomBorder?: boolean
+ allowCopy?: boolean
+ copyValue?: string
}) => {
const [showMoreInfo, setShowMoreInfo] = useState(false)
const tooltipId = useId()
@@ -62,10 +67,13 @@ export const PaymentInfoRow = ({
{loading ? (
) : (
-
+
{value}
+ {allowCopy && typeof value === 'string' && (
+
+ )}
)}
diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx
index 0523a1ac9..0a754c642 100644
--- a/src/components/Payment/Views/Confirm.payment.view.tsx
+++ b/src/components/Payment/Views/Confirm.payment.view.tsx
@@ -21,12 +21,22 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { useAppDispatch, usePaymentStore, useWalletStore } from '@/redux/hooks'
import { paymentActions } from '@/redux/slices/payment-slice'
import { chargesApi } from '@/services/charges'
-import { ErrorHandler, formatAmount, printableAddress } from '@/utils'
+import { ErrorHandler, formatAmount, areEvmAddressesEqual, isStableCoin } from '@/utils'
import { useQueryClient } from '@tanstack/react-query'
import { useSearchParams } from 'next/navigation'
-import { useCallback, useContext, useEffect, useMemo } from 'react'
+import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useAccount } from 'wagmi'
import { PaymentInfoRow } from '../PaymentInfoRow'
+import { formatUnits } from 'viem'
+import type { Address } from 'viem'
+import {
+ PEANUT_WALLET_CHAIN,
+ PEANUT_WALLET_TOKEN,
+ ROUTE_NOT_FOUND_ERROR,
+ PEANUT_WALLET_TOKEN_SYMBOL,
+} from '@/constants'
+import { captureMessage } from '@sentry/nextjs'
+import AddressLink from '@/components/Global/AddressLink'
type ConfirmPaymentViewProps = {
isPintaReq?: boolean
@@ -37,13 +47,28 @@ type ConfirmPaymentViewProps = {
}
currencyAmount?: string
isAddMoneyFlow?: boolean
+ /** Whether this is a direct payment, for xchain we dont care if a little
+ * less arrives*/
+ isDirectUsdPayment?: boolean
}
+/**
+ * Confirmation view for payment transactions. Displays payment details, fees, and handles
+ * transaction execution for various payment flows including cross-chain payments, direct USD
+ * payments, and add money flows.
+ *
+ * @param isPintaReq - Whether this is a Pinta request payment (beer payment flow)
+ * @param currency - Currency details for display (code, symbol, price)
+ * @param currencyAmount - Amount in the specified currency
+ * @param isAddMoneyFlow - Whether this is an add money flow (deposit to wallet)
+ * @param isDirectUsdPayment - Whether this is a direct payment, for xchain we dont care if a little less arrives
+ */
export default function ConfirmPaymentView({
isPintaReq = false,
currency,
currencyAmount,
isAddMoneyFlow,
+ isDirectUsdPayment = false,
}: ConfirmPaymentViewProps) {
const dispatch = useAppDispatch()
const searchParams = useSearchParams()
@@ -56,19 +81,42 @@ export default function ConfirmPaymentView({
isPreparingTx,
loadingStep,
error: paymentError,
- feeCalculations,
+ estimatedGasCostUsd,
isCalculatingFees,
isEstimatingGas,
isFeeEstimationError,
cancelOperation: cancelPaymentOperation,
+ xChainRoute,
} = usePaymentInitiator()
const { selectedTokenData, selectedChainID } = useContext(tokenSelectorContext)
- const { isConnected: isPeanutWallet, address: peanutWalletAddress, fetchBalance } = useWallet()
+ const { isConnected: isPeanutWallet, fetchBalance, address: peanutWalletAddress } = useWallet()
const { isConnected: isWagmiConnected, address: wagmiAddress } = useAccount()
const { rewardWalletBalance } = useWalletStore()
const queryClient = useQueryClient()
+ const [isRouteExpired, setIsRouteExpired] = useState(false)
- const walletAddress = useMemo(() => peanutWalletAddress ?? wagmiAddress, [peanutWalletAddress, wagmiAddress])
+ const isUsingExternalWallet = isAddMoneyFlow || !isPeanutWallet
+
+ const networkFee = useMemo(() => {
+ if (isFeeEstimationError) return '-'
+ if (!estimatedGasCostUsd) {
+ return isUsingExternalWallet ? '-' : 'Sponsored by Peanut!'
+ }
+
+ if (isUsingExternalWallet) {
+ return estimatedGasCostUsd < 0.01 ? '$ <0.01' : `$ ${estimatedGasCostUsd.toFixed(2)}`
+ }
+
+ if (estimatedGasCostUsd < 0.01) return 'Sponsored by Peanut!'
+
+ return (
+ <>
+ $ {estimatedGasCostUsd.toFixed(2)}
+ {' – '}
+ Sponsored by Peanut!
+ >
+ )
+ }, [estimatedGasCostUsd, isFeeEstimationError])
const {
tokenIconUrl: sendingTokenIconUrl,
@@ -76,9 +124,9 @@ export default function ConfirmPaymentView({
resolvedChainName: sendingResolvedChainName,
resolvedTokenSymbol: sendingResolvedTokenSymbol,
} = useTokenChainIcons({
- chainId: selectedChainID,
- tokenAddress: selectedTokenData?.address,
- tokenSymbol: selectedTokenData?.symbol,
+ chainId: isUsingExternalWallet ? selectedChainID : PEANUT_WALLET_CHAIN.id.toString(),
+ tokenAddress: isUsingExternalWallet ? selectedTokenData?.address : PEANUT_WALLET_TOKEN,
+ tokenSymbol: isUsingExternalWallet ? selectedTokenData?.symbol : PEANUT_WALLET_TOKEN_SYMBOL,
})
const {
@@ -95,12 +143,14 @@ export default function ConfirmPaymentView({
const showExternalWalletConfirmationModal = useMemo((): boolean => {
if (isCalculatingFees || isEstimatingGas) return false
- return isProcessing && (!isPeanutWallet || isAddMoneyFlow)
- ? ['Switching Network', 'Sending Transaction', 'Confirming Transaction', 'Preparing Transaction'].includes(
- loadingStep
- )
- : false
- }, [isProcessing, isPeanutWallet, loadingStep, isAddMoneyFlow, isCalculatingFees, isEstimatingGas])
+ return (
+ isProcessing &&
+ isUsingExternalWallet &&
+ ['Switching Network', 'Sending Transaction', 'Confirming Transaction', 'Preparing Transaction'].includes(
+ loadingStep
+ )
+ )
+ }, [isProcessing, loadingStep, isCalculatingFees, isEstimatingGas])
useEffect(() => {
if (chargeIdFromUrl && !chargeDetails) {
@@ -119,11 +169,40 @@ export default function ConfirmPaymentView({
}
}, [chargeIdFromUrl, chargeDetails, dispatch])
- useEffect(() => {
+ const handleRouteRefresh = useCallback(async () => {
if (chargeDetails && selectedTokenData && selectedChainID) {
- prepareTransactionDetails(chargeDetails, isAddMoneyFlow)
+ setIsRouteExpired(false)
+ const fromTokenAddress = !isUsingExternalWallet ? PEANUT_WALLET_TOKEN : selectedTokenData.address
+ const fromChainId = !isUsingExternalWallet ? PEANUT_WALLET_CHAIN.id.toString() : selectedChainID
+ const usdAmount =
+ isDirectUsdPayment && chargeDetails.currencyCode.toLowerCase() === 'usd'
+ ? chargeDetails.currencyAmount
+ : undefined
+ const senderAddress = isUsingExternalWallet ? wagmiAddress : peanutWalletAddress
+ await prepareTransactionDetails({
+ chargeDetails,
+ from: {
+ address: senderAddress as Address,
+ tokenAddress: fromTokenAddress as Address,
+ chainId: fromChainId,
+ },
+ usdAmount,
+ })
}
- }, [chargeDetails, walletAddress, selectedTokenData, selectedChainID, prepareTransactionDetails, isAddMoneyFlow])
+ }, [
+ chargeDetails,
+ selectedTokenData,
+ selectedChainID,
+ prepareTransactionDetails,
+ isDirectUsdPayment,
+ wagmiAddress,
+ peanutWalletAddress,
+ ])
+
+ useEffect(() => {
+ // get route on mount
+ handleRouteRefresh()
+ }, [handleRouteRefresh])
const isConnected = useMemo(() => isPeanutWallet || isWagmiConnected, [isPeanutWallet, isWagmiConnected])
const isInsufficientRewardsBalance = useMemo(() => {
@@ -136,6 +215,52 @@ export default function ConfirmPaymentView({
[isProcessing, isPreparingTx, isCalculatingFees, isEstimatingGas]
)
+ const isCrossChainPayment = useMemo((): boolean => {
+ if (!chargeDetails) return false
+ if (!isUsingExternalWallet) {
+ return (
+ !areEvmAddressesEqual(chargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) ||
+ chargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString()
+ )
+ } else if (selectedTokenData && selectedChainID) {
+ return (
+ !areEvmAddressesEqual(chargeDetails.tokenAddress, selectedTokenData.address) ||
+ chargeDetails.chainId !== selectedChainID
+ )
+ }
+ return false
+ }, [chargeDetails, selectedTokenData, selectedChainID])
+
+ const routeTypeError = useMemo((): string | null => {
+ if (!isCrossChainPayment || !xChainRoute || !isPeanutWallet) return null
+
+ // For peanut wallet flows, only RFQ routes are allowed
+ if (xChainRoute.type === 'swap') {
+ captureMessage('No RFQ route found for this token pair', {
+ level: 'warning',
+ extra: {
+ flow: 'payment',
+ routeObject: xChainRoute,
+ },
+ })
+ return ROUTE_NOT_FOUND_ERROR
+ }
+
+ return null
+ }, [isCrossChainPayment, xChainRoute, isPeanutWallet])
+
+ const errorMessage = useMemo((): string | undefined => {
+ if (isRouteExpired) return 'This quoute has expired. Please retry to fetch latest quote.'
+ return routeTypeError ?? paymentError ?? undefined
+ }, [routeTypeError, paymentError, isRouteExpired])
+
+ const handleGoBack = () => {
+ dispatch(paymentActions.setView('INITIAL'))
+ window.history.replaceState(null, '', `${window.location.pathname}`)
+ dispatch(paymentActions.setChargeDetails(null))
+ dispatch(paymentActions.setError(null))
+ }
+
const handlePayment = useCallback(async () => {
if (!chargeDetails || !parsedPaymentData) return
@@ -152,7 +277,7 @@ export default function ConfirmPaymentView({
skipChargeCreation: true,
currency,
currencyAmount,
- isAddMoneyFlow: !!isAddMoneyFlow,
+ isAddMoneyFlow,
transactionType: isAddMoneyFlow ? 'DEPOSIT' : 'REQUEST',
})
@@ -177,6 +302,16 @@ export default function ConfirmPaymentView({
isAddMoneyFlow,
])
+ const handleRetry = useCallback(async () => {
+ if (routeTypeError) {
+ handleGoBack()
+ } else if (isRouteExpired) {
+ await handleRouteRefresh()
+ } else {
+ await handlePayment()
+ }
+ }, [handlePayment, routeTypeError, handleRouteRefresh, isRouteExpired])
+
const getButtonText = useCallback(() => {
if (isProcessing) {
if (isAddMoneyFlow) return 'Adding money'
@@ -214,12 +349,6 @@ export default function ConfirmPaymentView({
if (!chargeDetails && paymentError) {
const message = paymentError
- const handleGoBack = () => {
- dispatch(paymentActions.setView('INITIAL'))
- window.history.replaceState(null, '', `${window.location.pathname}`)
- dispatch(paymentActions.setChargeDetails(null))
- dispatch(paymentActions.setError(null))
- }
return (
@@ -231,14 +360,7 @@ export default function ConfirmPaymentView({
if (isPintaReq) {
return (
-
{
- dispatch(paymentActions.setView('INITIAL'))
- window.history.replaceState(null, '', `${window.location.pathname}`)
- dispatch(paymentActions.setChargeDetails(null))
- dispatch(paymentActions.setError(null))
- }}
- />
+
You're paying for
@@ -277,24 +399,18 @@ export default function ConfirmPaymentView({
)
}
- const isCrossChainPayment = useMemo((): boolean => {
- if (!chargeDetails || !selectedTokenData || !selectedChainID) return false
-
- return chargeDetails.chainId !== selectedChainID
- }, [chargeDetails, selectedTokenData, selectedChainID])
+ const minReceived = useMemo
(() => {
+ if (!xChainRoute || !chargeDetails?.tokenDecimals || !requestedResolvedTokenSymbol) return null
+ const amount = formatUnits(
+ BigInt(xChainRoute.rawResponse.route.estimate.toAmountMin),
+ chargeDetails.tokenDecimals
+ )
+ return isStableCoin(requestedResolvedTokenSymbol) ? `$ ${amount}` : `${amount} ${requestedResolvedTokenSymbol}`
+ }, [xChainRoute, chargeDetails?.tokenDecimals, requestedResolvedTokenSymbol])
return (
-
{
- dispatch(paymentActions.setView('INITIAL'))
- window.history.replaceState(null, '', `${window.location.pathname}`)
- dispatch(paymentActions.setChargeDetails(null))
- dispatch(paymentActions.setError(null))
- }}
- />
-
+
{parsedPaymentData?.recipient && (
{
+ setIsRouteExpired(true)
+ }}
+ disableTimerRefetch={isProcessing}
+ timerError={routeTypeError}
/>
)}
- {isCrossChainPayment && (
+ {minReceived && (
+
+ )}
+ {isCrossChainPayment && !isAddMoneyFlow && (
)}
- {isAddMoneyFlow && }
+ {isAddMoneyFlow && (
+
+ }
+ />
+ )}
- {paymentError ? (
+ {errorMessage ? (
)}
- {paymentError && (
+ {errorMessage && (
-
+
)}
@@ -415,6 +551,17 @@ interface TokenChainInfoDisplayProps {
fallbackChainName: string
}
+/**
+ * Displays token and chain information with icons and names.
+ * Shows token icon with chain icon as a badge overlay, along with formatted text.
+ *
+ * @param tokenIconUrl - URL for the token icon
+ * @param chainIconUrl - URL for the chain icon (displayed as overlay)
+ * @param resolvedTokenSymbol - Resolved token symbol from API
+ * @param fallbackTokenSymbol - Fallback token symbol if resolution fails
+ * @param resolvedChainName - Resolved chain name from API
+ * @param fallbackChainName - Fallback chain name if resolution fails
+ */
function TokenChainInfoDisplay({
tokenIconUrl,
chainIconUrl,
diff --git a/src/components/Payment/Views/Initial.payment.view.tsx b/src/components/Payment/Views/Initial.payment.view.tsx
index a13eb9508..4196365ca 100644
--- a/src/components/Payment/Views/Initial.payment.view.tsx
+++ b/src/components/Payment/Views/Initial.payment.view.tsx
@@ -8,7 +8,7 @@ export default function InitialPaymentView(props: PaymentFormProps) {
{...props}
isPintaReq={isPintaReq}
isAddMoneyFlow={props.isAddMoneyFlow}
- isDirectPay={props.isDirectPay}
+ isDirectUsdPayment={props.isDirectUsdPayment}
/>
)
}
diff --git a/src/components/Payment/Views/Status.payment.view.tsx b/src/components/Payment/Views/Status.payment.view.tsx
index 880764fb2..e2422e7d1 100644
--- a/src/components/Payment/Views/Status.payment.view.tsx
+++ b/src/components/Payment/Views/Status.payment.view.tsx
@@ -7,7 +7,7 @@ import { Icon } from '@/components/Global/Icons/Icon'
import NavHeader from '@/components/Global/NavHeader'
import { TransactionDetailsDrawer } from '@/components/TransactionDetails/TransactionDetailsDrawer'
import { TransactionDetails } from '@/components/TransactionDetails/transactionTransformer'
-import { PEANUT_WALLET_TOKEN_SYMBOL, TRANSACTIONS } from '@/constants'
+import { PEANUT_WALLET_TOKEN_SYMBOL, TRANSACTIONS, BASE_URL } from '@/constants'
import { useTokenChainIcons } from '@/hooks/useTokenChainIcons'
import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer'
import { EHistoryEntryType, EHistoryUserRole } from '@/hooks/useTransactionHistory'
@@ -90,8 +90,14 @@ const DirectSuccessView = ({
const networkFeeDisplayValue = '$ 0.00' // fee is zero for peanut wallet txns
const peanutFeeDisplayValue = '$ 0.00' // peanut doesn't charge fees yet
+ const recipientIdentifier = user?.username || parsedPaymentData?.recipient?.identifier
+ const receiptLink = recipientIdentifier
+ ? `${BASE_URL}/${recipientIdentifier}?chargeId=${chargeDetails.uuid}`
+ : undefined
+
let details: Partial = {
id: paymentDetails?.payerTransactionHash,
+ txHash: paymentDetails?.payerTransactionHash,
status: 'completed' as StatusType,
amount: parseFloat(amountValue),
date: new Date(paymentDetails?.createdAt ?? chargeDetails.createdAt),
@@ -102,6 +108,7 @@ const DirectSuccessView = ({
isLinkTransaction: false,
originalType: EHistoryEntryType.DIRECT_SEND,
originalUserRole: EHistoryUserRole.SENDER,
+ link: receiptLink,
},
userName: user?.username || parsedPaymentData?.recipient?.identifier,
sourceView: 'status',
diff --git a/src/components/Request/link/views/Create.request.link.view.tsx b/src/components/Request/link/views/Create.request.link.view.tsx
index 89aec4c03..e9e827261 100644
--- a/src/components/Request/link/views/Create.request.link.view.tsx
+++ b/src/components/Request/link/views/Create.request.link.view.tsx
@@ -2,7 +2,7 @@
import { fetchTokenDetails } from '@/app/actions/tokens'
import { Button } from '@/components/0_Bruddle'
import { useToast } from '@/components/0_Bruddle/Toast'
-import FileUploadInput, { IFileUploadInputProps } from '@/components/Global/FileUploadInput'
+import FileUploadInput from '@/components/Global/FileUploadInput'
import Loading from '@/components/Global/Loading'
import NavHeader from '@/components/Global/NavHeader'
import PeanutActionCard from '@/components/Global/PeanutActionCard'
@@ -23,7 +23,7 @@ import * as Sentry from '@sentry/nextjs'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
-import { useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useContext, useMemo, useRef, useState } from 'react'
export const CreateRequestLinkView = () => {
const toast = useToast()
@@ -41,115 +41,130 @@ export const CreateRequestLinkView = () => {
const { setLoadingState } = useContext(context.loadingStateContext)
const queryClient = useQueryClient()
- const peanutWalletBalance = useMemo(() => {
- return printableUsdc(balance)
- }, [balance])
-
- // component-specific states
- const [tokenValue, setTokenValue] = useState(undefined)
- const [usdValue, setUsdValue] = useState(undefined)
+ // Core state
+ const [tokenValue, setTokenValue] = useState('')
const [attachmentOptions, setAttachmentOptions] = useState({
message: '',
fileUrl: '',
rawFile: undefined,
})
- const [recipientAddress, setRecipientAddress] = useState('')
const [errorState, setErrorState] = useState<{
showError: boolean
errorMessage: string
}>({ showError: false, errorMessage: '' })
- const [isValidRecipient, setIsValidRecipient] = useState(false)
const [generatedLink, setGeneratedLink] = useState(null)
+ const [requestId, setRequestId] = useState(null)
const [isCreatingLink, setIsCreatingLink] = useState(false)
- const [debouncedAttachmentOptions, setDebouncedAttachmentOptions] =
- useState(attachmentOptions)
- const debounceTimerRef = useRef(null)
+ const [isUpdatingRequest, setIsUpdatingRequest] = useState(false)
+
+ // Track the last saved state to determine if updates are needed
+ const lastSavedAttachmentRef = useRef({
+ message: '',
+ fileUrl: '',
+ rawFile: undefined,
+ })
- const [_tokenValue, _setTokenValue] = useState(tokenValue ?? '')
+ // Refs for cleanup
+ const createLinkAbortRef = useRef(null)
- // debounced token value
- const [debouncedTokenValue, setDebouncedTokenValue] = useState(_tokenValue)
- const tokenDebounceTimerRef = useRef(null)
+ // Derived state
+ const peanutWalletBalance = useMemo(() => printableUsdc(balance), [balance])
- const hasAttachment = !!attachmentOptions?.rawFile || !!attachmentOptions?.message
+ const usdValue = useMemo(() => {
+ if (!selectedTokenPrice || !tokenValue) return ''
+ return (parseFloat(tokenValue) * selectedTokenPrice).toString()
+ }, [tokenValue, selectedTokenPrice])
+
+ const recipientAddress = useMemo(() => {
+ if (!isConnected || !address) return ''
+ return address
+ }, [isConnected, address])
+
+ const isValidRecipient = useMemo(() => {
+ return isConnected && !!address
+ }, [isConnected, address])
+
+ const hasAttachment = useMemo(() => {
+ return !!(attachmentOptions.rawFile || attachmentOptions.message)
+ }, [attachmentOptions.rawFile, attachmentOptions.message])
const qrCodeLink = useMemo(() => {
if (generatedLink) return generatedLink
- // use debouncedTokenValue when in the process of creating a link with attachment
- const valueToShow = hasAttachment && isCreatingLink ? debouncedTokenValue : _tokenValue
-
- return `${window.location.origin}${valueToShow ? `/${user?.user.username}/${valueToShow}USDC` : `/send/${user?.user.username}`}`
- }, [user?.user.username, _tokenValue, debouncedTokenValue, generatedLink, hasAttachment, isCreatingLink])
+ return `${window.location.origin}${
+ tokenValue ? `/${user?.user.username}/${tokenValue}USDC` : `/send/${user?.user.username}`
+ }`
+ }, [user?.user.username, tokenValue, generatedLink])
const createRequestLink = useCallback(
- async ({
- recipientAddress,
- tokenAddress,
- chainId,
- tokenValue,
- tokenData,
- attachmentOptions,
- }: {
- recipientAddress: string | undefined
- tokenAddress: string
- chainId: string
- tokenValue: string | undefined
- tokenData: Pick | undefined
- attachmentOptions: IFileUploadInputProps['attachmentOptions']
- }) => {
+ async (attachmentOptions: IAttachmentOptions) => {
if (!recipientAddress) {
setErrorState({
showError: true,
errorMessage: 'Please enter a recipient address',
})
- return
+ return null
}
- if (!tokenValue) {
- if (
- (attachmentOptions?.message && attachmentOptions.message !== ' ') ||
- attachmentOptions?.rawFile ||
- attachmentOptions?.fileUrl
- ) {
- setErrorState({
- showError: true,
- errorMessage: 'Please enter a token amount',
- })
- return
- }
- return
+
+ if (!tokenValue || parseFloat(tokenValue) <= 0) {
+ setErrorState({
+ showError: true,
+ errorMessage: 'Please enter a token amount',
+ })
+ return null
+ }
+
+ // Cleanup previous request
+ if (createLinkAbortRef.current) {
+ createLinkAbortRef.current.abort()
}
+ createLinkAbortRef.current = new AbortController()
setIsCreatingLink(true)
setLoadingState('Creating link')
+ setErrorState({ showError: false, errorMessage: '' })
- if (!tokenData) {
- const tokenDetails = await fetchTokenDetails(tokenAddress, chainId)
- tokenData = {
- address: tokenAddress,
- chainId,
- symbol: (await fetchTokenSymbol(tokenAddress, chainId)) ?? '',
- decimals: tokenDetails.decimals,
- }
- }
try {
- let inputValue = tokenValue
+ let tokenData: Pick
+ if (selectedTokenData) {
+ tokenData = {
+ chainId: selectedTokenData.chainId,
+ address: selectedTokenData.address,
+ decimals: selectedTokenData.decimals,
+ symbol: selectedTokenData.symbol,
+ }
+ } else {
+ const tokenDetails = await fetchTokenDetails(selectedTokenAddress, selectedChainID)
+ tokenData = {
+ address: selectedTokenAddress,
+ chainId: selectedChainID,
+ symbol: (await fetchTokenSymbol(selectedTokenAddress, selectedChainID)) ?? '',
+ decimals: tokenDetails.decimals,
+ }
+ }
+
const tokenType = isNativeCurrency(tokenData.address)
? peanutInterfaces.EPeanutLinkType.native
: peanutInterfaces.EPeanutLinkType.erc20
- const requestDetails = await requestsApi.create({
+
+ const requestData = {
chainId: tokenData.chainId,
- tokenAmount: inputValue,
+ tokenAmount: tokenValue,
recipientAddress,
tokenAddress: tokenData.address,
tokenDecimals: tokenData.decimals.toString(),
tokenType: tokenType.valueOf().toString(),
tokenSymbol: tokenData.symbol,
- reference: attachmentOptions?.message,
- attachment: attachmentOptions?.rawFile,
- mimeType: attachmentOptions?.rawFile?.type,
- filename: attachmentOptions?.rawFile?.name,
- })
+ reference: attachmentOptions.message || undefined,
+ attachment: attachmentOptions.rawFile || undefined,
+ mimeType: attachmentOptions.rawFile?.type || undefined,
+ filename: attachmentOptions.rawFile?.name || undefined,
+ }
+
+ // POST new request
+ const requestDetails = await requestsApi.create(requestData)
+ setRequestId(requestDetails.uuid)
+
const charge = await chargesApi.create({
pricing_type: 'fixed_price',
local_price: {
@@ -158,16 +173,25 @@ export const CreateRequestLinkView = () => {
},
baseUrl: BASE_URL,
requestId: requestDetails.uuid,
+ transactionType: 'REQUEST',
})
+
const link = getRequestLink({
...requestDetails,
uuid: undefined,
chargeId: charge.data.id,
})
+
+ // Update the last saved state
+ lastSavedAttachmentRef.current = { ...attachmentOptions }
+
toast.success('Link created successfully!')
queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
return link
} catch (error) {
+ if (error && typeof error === 'object' && 'name' in error && error.name === 'AbortError') {
+ return null
+ }
setErrorState({
showError: true,
errorMessage: 'Failed to create link',
@@ -175,256 +199,196 @@ export const CreateRequestLinkView = () => {
console.error('Failed to create link:', error)
Sentry.captureException(error)
toast.error('Failed to create link')
- return ''
+ return null
} finally {
setLoadingState('Idle')
setIsCreatingLink(false)
}
},
- [user?.user.username, toast]
- )
-
- const handleOnNext = useCallback(
- async ({
+ [
recipientAddress,
- tokenAddress,
- chainId,
tokenValue,
- tokenData,
- attachmentOptions,
- }: {
- recipientAddress: string | undefined
- tokenAddress: string
- chainId: string
- tokenValue: string | undefined
- tokenData: Pick | undefined
- attachmentOptions: IFileUploadInputProps['attachmentOptions']
- }) => {
- const link = await createRequestLink({
- recipientAddress,
- tokenAddress,
- chainId,
- tokenValue,
- tokenData,
- attachmentOptions,
- })
- setGeneratedLink(link ?? null)
- },
- [createRequestLink]
+ selectedTokenData,
+ selectedTokenAddress,
+ selectedChainID,
+ toast,
+ queryClient,
+ setLoadingState,
+ ]
)
- const generateLink = useCallback(async () => {
- if (generatedLink) return generatedLink
- if (Number(tokenValue) === 0) return qrCodeLink
- setIsCreatingLink(true)
- const link = await createRequestLink({
- recipientAddress,
- tokenAddress: selectedTokenAddress,
- chainId: selectedChainID,
- tokenValue,
- tokenData: selectedTokenData,
- attachmentOptions: {
- message: ' ',
- fileUrl: undefined,
- rawFile: undefined,
- },
- })
- setGeneratedLink(link ?? null)
- setIsCreatingLink(false)
- return link ?? ''
- }, [
- recipientAddress,
- generatedLink,
- qrCodeLink,
- tokenValue,
- selectedTokenAddress,
- selectedChainID,
- selectedTokenData,
- createRequestLink,
- ])
-
- useEffect(() => {
- if (!_tokenValue) {
- setTokenValue('')
- setUsdValue('')
- setGeneratedLink(null)
- }
- setTokenValue(_tokenValue)
- if (selectedTokenPrice) {
- setUsdValue((parseFloat(_tokenValue) * selectedTokenPrice).toString())
- }
- }, [_tokenValue])
+ const updateRequestLink = useCallback(
+ async (attachmentOptions: IAttachmentOptions) => {
+ if (!requestId) return null
- useEffect(() => {
- if (!isConnected) {
- setRecipientAddress('')
- setIsValidRecipient(false)
- return
- }
+ setIsUpdatingRequest(true)
+ setLoadingState('Requesting')
+ setErrorState({ showError: false, errorMessage: '' })
- if (address) {
- setRecipientAddress(address)
- setIsValidRecipient(true)
- setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString())
- setSelectedTokenAddress(PEANUT_WALLET_TOKEN)
- }
- }, [isConnected, address])
+ try {
+ const requestData = {
+ reference: attachmentOptions.message || undefined,
+ attachment: attachmentOptions.rawFile || undefined,
+ mimeType: attachmentOptions.rawFile?.type || undefined,
+ filename: attachmentOptions.rawFile?.name || undefined,
+ }
- // debounce attachment options changes, especially for text messages
- useEffect(() => {
- // clear any existing timer
- if (debounceTimerRef.current) {
- clearTimeout(debounceTimerRef.current)
- }
+ // PATCH existing request
+ await requestsApi.update(requestId, requestData)
- // reset error state when attachment options change
- setErrorState({ showError: false, errorMessage: '' })
+ // Update the last saved state
+ lastSavedAttachmentRef.current = { ...attachmentOptions }
- // check if attachments are completely cleared
- const hasNoAttachments = !attachmentOptions?.rawFile && !attachmentOptions?.message
+ toast.success('Request updated successfully!')
+ queryClient.invalidateQueries({ queryKey: [TRANSACTIONS] })
+ return generatedLink
+ } catch (error) {
+ setErrorState({
+ showError: true,
+ errorMessage: 'Failed to update request',
+ })
+ console.error('Failed to update request:', error)
+ Sentry.captureException(error)
+ toast.error('Failed to update request')
+ return null
+ } finally {
+ setLoadingState('Idle')
+ setIsUpdatingRequest(false)
+ }
+ },
+ [requestId, generatedLink, toast, queryClient, setLoadingState]
+ )
- if (hasNoAttachments) {
- // reset generated link when attachments are completely cleared
- setGeneratedLink(null)
- } else {
- // reset generated link when attachment options change (adding or modifying)
- setGeneratedLink(null)
- }
+ const hasUnsavedChanges = useMemo(() => {
+ if (!requestId) return false
+
+ const lastSaved = lastSavedAttachmentRef.current
+ return lastSaved.message !== attachmentOptions.message || lastSaved.rawFile !== attachmentOptions.rawFile
+ }, [requestId, attachmentOptions.message, attachmentOptions.rawFile])
+
+ const handleTokenValueChange = useCallback(
+ (value: string | undefined) => {
+ const newValue = value || ''
+ setTokenValue(newValue)
+
+ // Reset link and request when token value changes
+ if (newValue !== tokenValue) {
+ setGeneratedLink(null)
+ setRequestId(null)
+ lastSavedAttachmentRef.current = {
+ message: '',
+ fileUrl: '',
+ rawFile: undefined,
+ }
+ }
+ },
+ [tokenValue]
+ )
- // for file attachments, update immediately
- if (attachmentOptions?.rawFile) {
- setDebouncedAttachmentOptions(attachmentOptions)
- return
- }
+ const handleAttachmentOptionsChange = useCallback(
+ (options: IAttachmentOptions) => {
+ setAttachmentOptions(options)
+ setErrorState({ showError: false, errorMessage: '' })
+
+ // Reset link and request when attachments are completely cleared
+ if (!options.rawFile && !options.message) {
+ setGeneratedLink(null)
+ setRequestId(null)
+ lastSavedAttachmentRef.current = {
+ message: '',
+ fileUrl: '',
+ rawFile: undefined,
+ }
+ }
- // for text messages, debounce for 3 seconds
- if (attachmentOptions?.message) {
- // set a timer for the debounced update
- debounceTimerRef.current = setTimeout(() => {
- setDebouncedAttachmentOptions(attachmentOptions)
- }, 3000) // 3 second debounce
- } else {
- // If no message, update immediately
- setDebouncedAttachmentOptions(attachmentOptions)
- }
+ // If file was added/changed and we have a request, update it immediately
+ if (requestId && options.rawFile !== lastSavedAttachmentRef.current.rawFile) {
+ updateRequestLink(options)
+ }
+ },
+ [requestId, updateRequestLink]
+ )
- // cleanup function
- return () => {
- if (debounceTimerRef.current) {
- clearTimeout(debounceTimerRef.current)
+ const handleTokenAmountSubmit = useCallback(async () => {
+ if (!tokenValue || parseFloat(tokenValue) <= 0) return
+
+ if (!generatedLink) {
+ // POST: Create new request
+ const link = await createRequestLink(attachmentOptions)
+ if (link) {
+ setGeneratedLink(link)
}
+ } else if (hasUnsavedChanges) {
+ // PATCH: Update existing request
+ await updateRequestLink(attachmentOptions)
}
- }, [attachmentOptions])
+ }, [tokenValue, generatedLink, attachmentOptions, hasUnsavedChanges, createRequestLink, updateRequestLink])
- // debounce token value
- useEffect(() => {
- // clear timer
- if (tokenDebounceTimerRef.current) {
- clearTimeout(tokenDebounceTimerRef.current)
+ const handleAttachmentBlur = useCallback(async () => {
+ if (requestId && hasUnsavedChanges) {
+ await updateRequestLink(attachmentOptions)
}
+ }, [requestId, hasUnsavedChanges, attachmentOptions, updateRequestLink])
- // set timer for the debounced update
- tokenDebounceTimerRef.current = setTimeout(() => {
- setDebouncedTokenValue(_tokenValue)
- }, 1000) // 1 second debounce
+ const generateLink = useCallback(async () => {
+ if (generatedLink) return generatedLink
+ if (Number(tokenValue) === 0) return qrCodeLink
- // cleanup function
- return () => {
- if (tokenDebounceTimerRef.current) {
- clearTimeout(tokenDebounceTimerRef.current)
- }
+ // Create new request when share button is clicked
+ const link = await createRequestLink(attachmentOptions)
+ if (link) {
+ setGeneratedLink(link)
}
- }, [_tokenValue])
-
- // handle link creation based on input changes
- useEffect(() => {
- // only create link if there's an attachment, valid recipient, token value, and no link already being created and debounced token value matches the current token value
- if (
- hasAttachment &&
- isValidRecipient &&
- debouncedTokenValue &&
- !isCreatingLink &&
- debouncedTokenValue === _tokenValue
- ) {
- // check if we need to create a new link (either no link exists or token value changed)
- if (!generatedLink) {
- handleOnNext({
- recipientAddress,
- tokenAddress: selectedTokenAddress,
- chainId: selectedChainID,
- tokenValue,
- tokenData: selectedTokenData,
- attachmentOptions: debouncedAttachmentOptions,
- })
- }
+ return link || ''
+ }, [generatedLink, qrCodeLink, tokenValue, attachmentOptions, createRequestLink])
+
+ // Set wallet defaults when connected
+ useMemo(() => {
+ if (isConnected && address) {
+ setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString())
+ setSelectedTokenAddress(PEANUT_WALLET_TOKEN)
}
- }, [
- debouncedAttachmentOptions,
- debouncedTokenValue,
- isValidRecipient,
- isCreatingLink,
- generatedLink,
- _tokenValue,
- recipientAddress,
- ])
-
- // check for token value debouncing
- const isDebouncing =
- (hasAttachment &&
- attachmentOptions?.message &&
- (!debouncedAttachmentOptions?.message ||
- debouncedAttachmentOptions.message !== attachmentOptions.message)) ||
- (hasAttachment && _tokenValue !== debouncedTokenValue)
+ }, [isConnected, address, setSelectedChainID, setSelectedTokenAddress])
return (
-
+
router.push('/request')} title="Request" />
-
+
-
+
{
- _setTokenValue(value ?? '')
- // reset generated link when token value changes
- setGeneratedLink(null)
- }}
- tokenValue={_tokenValue}
- onSubmit={() => {
- if (hasAttachment && !generatedLink && !isDebouncing) {
- handleOnNext({
- recipientAddress,
- tokenAddress: selectedTokenAddress,
- chainId: selectedChainID,
- tokenValue,
- tokenData: selectedTokenData,
- attachmentOptions,
- })
- }
- }}
+ setTokenValue={handleTokenValueChange}
+ tokenValue={tokenValue}
+ onSubmit={handleTokenAmountSubmit}
+ onBlur={handleTokenAmountSubmit}
walletBalance={peanutWalletBalance}
+ disabled={!!generatedLink}
/>
+
- {(hasAttachment && isCreatingLink) || isDebouncing ? (
+ {isCreatingLink || isUpdatingRequest ? (
- {' Loading'}
+ Loading
) : (
Share Link
)}
+
{errorState.showError && (
-
+
)}
diff --git a/src/components/Send/link/LinkSendFlowManager.tsx b/src/components/Send/link/LinkSendFlowManager.tsx
index 8cdf9deab..cd12192f6 100644
--- a/src/components/Send/link/LinkSendFlowManager.tsx
+++ b/src/components/Send/link/LinkSendFlowManager.tsx
@@ -46,21 +46,18 @@ const LinkSendFlowManager = ({ onPrev }: LinkSendFlowManagerProps) => {
setSelectedTokenAddress(PEANUT_WALLET_TOKEN)
}, [])
- // reset send flow state when component mounts
- useEffect(() => {
- dispatch(sendFlowActions.resetSendFlow())
- }, [dispatch])
-
return (
-
+ <>
{view === 'INITIAL' && (
-
+
)}
{view === 'SUCCESS' &&
}
-
+ >
)
}
diff --git a/src/components/Send/link/views/Initial.link.send.view.tsx b/src/components/Send/link/views/Initial.link.send.view.tsx
index b5c4f00e0..6e9208999 100644
--- a/src/components/Send/link/views/Initial.link.send.view.tsx
+++ b/src/components/Send/link/views/Initial.link.send.view.tsx
@@ -95,7 +95,16 @@ const LinkSendInitialView = () => {
}, [isLoading, tokenValue, createLink, fetchBalance, dispatch, queryClient, setLoadingState, attachmentOptions])
useEffect(() => {
- if (!peanutWalletBalance || !tokenValue) return
+ if (!peanutWalletBalance || !tokenValue) {
+ // Clear error state when no balance or token value
+ dispatch(
+ sendFlowActions.setErrorState({
+ showError: false,
+ errorMessage: '',
+ })
+ )
+ return
+ }
if (
parseUnits(peanutWalletBalance, PEANUT_WALLET_TOKEN_DECIMALS) <
parseUnits(tokenValue, PEANUT_WALLET_TOKEN_DECIMALS)
@@ -114,7 +123,7 @@ const LinkSendInitialView = () => {
})
)
}
- }, [peanutWalletBalance, tokenValue])
+ }, [peanutWalletBalance, tokenValue, dispatch])
return (
diff --git a/src/components/Send/link/views/Success.link.send.view.tsx b/src/components/Send/link/views/Success.link.send.view.tsx
index 403551020..3bb78906c 100644
--- a/src/components/Send/link/views/Success.link.send.view.tsx
+++ b/src/components/Send/link/views/Success.link.send.view.tsx
@@ -25,9 +25,12 @@ const LinkSendSuccessView = () => {
const { user } = useUserStore()
const [isLoading, setIsLoading] = useState
(false)
+ if (isLoading) {
+ return
+ }
+
return (
-
- {isLoading &&
}
+
{
dispatch(sendFlowActions.resetSendFlow())
}}
/>
-
+
{link && (
{
const router = useRouter()
+ const dispatch = useAppDispatch()
const [isSendingByLink, setIsSendingByLink] = useState(false)
+ const handleLinkCardClick = () => {
+ // Reset send flow state when entering link creation flow
+ dispatch(sendFlowActions.resetSendFlow())
+ setIsSendingByLink(true)
+ }
+
+ const handlePrev = () => {
+ // Reset send flow state when leaving link creation flow
+ dispatch(sendFlowActions.resetSendFlow())
+ setIsSendingByLink(false)
+ }
+
if (isSendingByLink) {
- return setIsSendingByLink(false)} />
+ return
}
return (
setIsSendingByLink(true)}
+ onLinkCardClick={handleLinkCardClick}
onUserSelect={(username) => router.push(`/send/${username}`)}
/>
)
diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx
new file mode 100644
index 000000000..5aa386db6
--- /dev/null
+++ b/src/components/Slider/index.tsx
@@ -0,0 +1,86 @@
+'use client'
+
+import * as React from 'react'
+import * as SliderPrimitive from '@radix-ui/react-slider'
+import { twMerge } from 'tailwind-merge'
+import { Icon } from '../Global/Icons/Icon'
+
+export interface SliderProps
+ extends Omit<
+ React.ComponentPropsWithoutRef,
+ 'value' | 'onValueChange' | 'max' | 'step' | 'defaultValue'
+ > {
+ value?: boolean
+ onValueChange?: (value: boolean) => void
+ defaultValue?: boolean
+ onAccepted?: () => void
+}
+
+const Slider = React.forwardRef, SliderProps>(
+ ({ className, value, onValueChange, defaultValue, onAccepted, ...props }, ref) => {
+ const isControlled = value !== undefined
+ const [uncontrolledState, setUncontrolledState] = React.useState(defaultValue ?? false)
+ const currentValue = isControlled ? value : uncontrolledState
+
+ const [slidingValue, setSlidingValue] = React.useState(null)
+ const [isDragging, setIsDragging] = React.useState(false)
+
+ const displayValue = slidingValue ?? (currentValue ? [100] : [0])
+
+ const handleValueChange = (newValue: number[]) => {
+ if (isDragging) {
+ setSlidingValue(newValue)
+ }
+ }
+
+ const handleValueCommit = (committedValue: number[]) => {
+ if (isDragging) {
+ const committedNumericValue = committedValue[0]
+ const isChecked = committedNumericValue === 100
+ if (onValueChange) {
+ onValueChange(isChecked)
+ }
+ if (isChecked) onAccepted?.()
+ if (!isControlled) {
+ setUncontrolledState(isChecked)
+ }
+ }
+ setSlidingValue(null)
+ setIsDragging(false)
+ }
+
+ return (
+
+
+
+
+ Slide to Proceed
+
+
+ setIsDragging(true)}
+ className="flex h-full w-12 cursor-pointer items-center justify-center rounded-r-sm border-2 border-purple-1 bg-primary-1 py-3 ring-offset-black transition-colors focus-visible:outline-none focus-visible:ring-0 disabled:pointer-events-none disabled:opacity-50 "
+ >
+
+
+
+
+
+ )
+ }
+)
+Slider.displayName = SliderPrimitive.Root.displayName
+
+export { Slider }
diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx
index 585450614..f98efaf1d 100644
--- a/src/components/TransactionDetails/TransactionCard.tsx
+++ b/src/components/TransactionDetails/TransactionCard.tsx
@@ -108,6 +108,9 @@ const TransactionCard: React.FC = ({
: transaction.currencySymbol || getDisplayCurrencySymbol(actualCurrencyCode) // Use provided sign+symbol or derive symbol
let amountString = Math.abs(amount).toString()
+ if (transaction.currency?.code === 'USD' && isStableCoin) {
+ amountString = transaction.currency?.amount
+ }
// If it's a token and not USD/ARS, transaction.tokenSymbol should be displayed after amount.
// And `displayDecimals` might need to come from token itself if available, else default.
const decimalsForDisplay = actualCurrencyCode // If it's a known currency (USD, ARS)
diff --git a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx
index 753a6bfbc..bfc99fd45 100644
--- a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx
+++ b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx
@@ -9,7 +9,8 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { useUserStore } from '@/redux/hooks'
import { chargesApi } from '@/services/charges'
import { sendLinksApi } from '@/services/sendLinks'
-import { formatAmount, formatDate, getInitialsFromName } from '@/utils'
+import { formatAmount, formatDate, getInitialsFromName, isStableCoin } from '@/utils'
+import { formatIban } from '@/utils/general.utils'
import { getDisplayCurrencySymbol } from '@/utils/currency'
import { cancelOnramp } from '@/app/actions/onramp'
import { captureException } from '@sentry/nextjs'
@@ -167,9 +168,10 @@ export const TransactionDetailsReceipt = ({
amountDisplay = `${currencySymbol} ${formatAmount(Number(currencyAmount))}`
}
} else {
- amountDisplay = transaction.currency?.amount
- ? `$ ${formatAmount(Number(transaction.currency.amount))}`
- : `$ ${formatAmount(transaction.amount as number)}`
+ amountDisplay =
+ transaction.currency?.amount && isStableCoin(transaction.tokenSymbol ?? '')
+ ? `$ ${formatAmount(Number(transaction.currency.amount))}`
+ : `$ ${formatAmount(transaction.amount as number)}`
}
const feeDisplay = transaction.fee !== undefined ? formatAmount(transaction.fee as number) : 'N/A'
@@ -322,9 +324,9 @@ export const TransactionDetailsReceipt = ({
label={getBankAccountLabel(transaction.bankAccountDetails.type)}
value={
- {transaction.bankAccountDetails.identifier.toUpperCase()}
+ {formatIban(transaction.bankAccountDetails.identifier)}
@@ -439,16 +441,16 @@ export const TransactionDetailsReceipt = ({
value={
- {
+ {formatIban(
transaction.extraDataForDrawer.depositInstructions
.iban
- }
+ )}
@@ -613,7 +615,7 @@ export const TransactionDetailsReceipt = ({
)}
diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx
index bc0df26af..89085bd85 100644
--- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx
@@ -10,20 +10,30 @@ import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { useTokenChainIcons } from '@/hooks/useTokenChainIcons'
import { ITokenPriceData } from '@/interfaces'
-import { formatAmount } from '@/utils'
+import { formatAmount, isStableCoin } from '@/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'
interface WithdrawConfirmViewProps {
amount: string
token: ITokenPriceData
chain: interfaces.ISquidChain & { tokens: interfaces.ISquidToken[] }
toAddress: string
- networkFee?: string
+ networkFee?: number
peanutFee?: string
onConfirm: () => void
onBack: () => void
isProcessing?: boolean
error?: string | null
+ // Timer props for cross-chain withdrawals
+ isCrossChain?: boolean
+ routeExpiry?: string
+ isRouteLoading?: boolean
+ onRouteRefresh?: () => void
+ xChainRoute?: PeanutCrossChainRoute
}
export default function ConfirmWithdrawView({
@@ -31,19 +41,42 @@ export default function ConfirmWithdrawView({
token,
chain,
toAddress,
- networkFee = '0.00',
+ networkFee = 0,
peanutFee = '0.00',
onConfirm,
onBack,
isProcessing,
error,
+ isCrossChain = false,
+ routeExpiry,
+ isRouteLoading = false,
+ onRouteRefresh,
+ xChainRoute,
}: WithdrawConfirmViewProps) {
+ const [isRouteExpired, setIsRouteExpired] = useState(false)
const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({
chainId: chain.chainId,
tokenAddress: token.address,
tokenSymbol: token.symbol,
})
+ const minReceived = useMemo(() => {
+ if (!xChainRoute || !resolvedTokenSymbol) return null
+ const amount = formatUnits(BigInt(xChainRoute.rawResponse.route.estimate.toAmountMin), token.decimals)
+ return isStableCoin(resolvedTokenSymbol) ? `$ ${amount}` : `${amount} ${resolvedTokenSymbol}`
+ }, [xChainRoute, resolvedTokenSymbol])
+
+ const networkFeeDisplay = useMemo(() => {
+ if (networkFee < 0.01) return 'Sponsored by Peanut!'
+ return (
+ <>
+ $ {networkFee.toFixed(2)}
+ {' – '}
+ Sponsored by Peanut!
+ >
+ )
+ }, [networkFee])
+
return (
@@ -55,10 +88,29 @@ export default function ConfirmWithdrawView({
recipientType="USERNAME"
recipientName={''}
amount={formatAmount(amount)}
- tokenSymbol={token.symbol}
+ tokenSymbol="USDC"
+ showTimer={isCrossChain}
+ timerExpiry={routeExpiry}
+ isTimerLoading={isRouteLoading}
+ onTimerNearExpiry={() => {
+ setIsRouteExpired(false)
+ onRouteRefresh?.()
+ }}
+ onTimerExpired={() => {
+ setIsRouteExpired(true)
+ }}
+ disableTimerRefetch={isProcessing}
+ timerError={error == ROUTE_NOT_FOUND_ERROR ? error : null}
/>
+ {minReceived && (
+
+ )}
}
/>
-
+
@@ -107,9 +155,17 @@ export default function ConfirmWithdrawView({
{
+ if (error === ROUTE_NOT_FOUND_ERROR) {
+ onBack()
+ } else if (isRouteExpired) {
+ onRouteRefresh?.()
+ } else {
+ onConfirm()
+ }
+ }}
+ disabled={false}
+ loading={false}
className="w-full"
icon="retry"
>
@@ -120,7 +176,7 @@ export default function ConfirmWithdrawView({
variant="purple"
shadowSize="4"
onClick={onConfirm}
- disabled={isProcessing}
+ disabled={isProcessing || isRouteLoading}
loading={isProcessing}
className="w-full"
>
@@ -128,7 +184,13 @@ export default function ConfirmWithdrawView({
)}
- {error &&
}
+ {error && (
+
+ )}
)
diff --git a/src/components/Withdraw/views/Initial.withdraw.view.tsx b/src/components/Withdraw/views/Initial.withdraw.view.tsx
index fbe493268..e5655dbe7 100644
--- a/src/components/Withdraw/views/Initial.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Initial.withdraw.view.tsx
@@ -13,6 +13,7 @@ import { formatAmount } from '@/utils/general.utils'
import { interfaces } from '@squirrel-labs/peanut-sdk'
import { useRouter } from 'next/navigation'
import { useContext, useEffect } from 'react'
+import TokenSelector from '@/components/Global/TokenSelector/TokenSelector'
interface InitialWithdrawViewProps {
amount: string
@@ -67,8 +68,6 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
router.push('/withdraw')
}
- // TODO: remove this once x-chain support is added
- // set the default token and chain for withdrawals (USDC on arb)
useEffect(() => {
setSelectedChainID(PEANUT_WALLET_CHAIN.id.toString())
setSelectedTokenAddress(PEANUT_WALLET_TOKEN)
@@ -85,11 +84,10 @@ export default function InitialWithdrawView({ amount, onReview, onBack, isProces
recipientType="USERNAME"
recipientName={''}
amount={formatAmount(amount)}
- tokenSymbol={selectedTokenData?.symbol ?? ''}
+ tokenSymbol="USDC"
/>
- {/* token selector is not needed for withdrawals right now, the only token peanut wallet supports is USDC on arb, this will be re-added once x-chain support is added */}
- {/*
*/}
+
{
+ const mainnetRpcUrl = rpcUrls[mainnet.id]?.[0]
+
+ const networks = mainnetRpcUrl
+ ? [
+ {
+ chainId: mainnet.id,
+ providerUrl: mainnetRpcUrl,
+ },
+ ]
+ : []
+
return (
{children}
diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts
index 096d88711..09611c3f5 100644
--- a/src/constants/general.consts.ts
+++ b/src/constants/general.consts.ts
@@ -1,25 +1,32 @@
import * as interfaces from '@/interfaces'
import { CHAIN_DETAILS, TOKEN_DETAILS } from '@squirrel-labs/peanut-sdk'
-import { mainnet, arbitrum, arbitrumSepolia, polygon, optimism, base, bsc, scroll } from 'viem/chains'
+import { mainnet, arbitrum, arbitrumSepolia, polygon, optimism, base, bsc, scroll, baseSepolia } from 'viem/chains'
export const peanutWalletIsInPreview = true
export const INFURA_API_KEY = process.env.NEXT_PUBLIC_INFURA_API_KEY
+export const ALCHEMY_API_KEY = process.env.NEXT_PUBLIC_ALCHEMY_API_KEY
-export const SQUID_INTEGRATOR_ID = '11CBA45B-5EE9-4331-B146-48CCD7ED4C7C'
-export const SQUID_API_URL = 'https://apiplus.squidrouter.com/v2'
+export const SQUID_INTEGRATOR_ID = process.env.SQUID_INTEGRATOR_ID!
+export const SQUID_API_URL = process.env.SQUID_API_URL
-export const infuraRpcUrls: Record = {
- [mainnet.id]: `https://mainnet.infura.io/v3/${INFURA_API_KEY}`,
- [arbitrum.id]: `https://arbitrum-mainnet.infura.io/v3/${INFURA_API_KEY}`,
- [arbitrumSepolia.id]: `https://arbitrum-sepolia.infura.io/v3/${INFURA_API_KEY}`,
- [polygon.id]: `https://polygon-mainnet.infura.io/v3/${INFURA_API_KEY}`,
- [optimism.id]: `https://optimism-mainnet.infura.io/v3/${INFURA_API_KEY}`,
- [base.id]: `https://base-sepolia.infura.io/v3/${INFURA_API_KEY}`,
+const infuraUrl = (subdomain: string) => (INFURA_API_KEY ? `https://${subdomain}.infura.io/v3/${INFURA_API_KEY}` : null)
+const alchemyUrl = (subdomain: string) =>
+ ALCHEMY_API_KEY ? `https://${subdomain}.g.alchemy.com/v2/${ALCHEMY_API_KEY}` : null
+
+export const rpcUrls: Record = {
+ [mainnet.id]: [infuraUrl('mainnet'), alchemyUrl('eth-mainnet')].filter(Boolean) as string[],
+ [arbitrum.id]: [infuraUrl('arbitrum-mainnet'), alchemyUrl('arb-mainnet')].filter(Boolean) as string[],
+ [arbitrumSepolia.id]: [infuraUrl('arbitrum-sepolia'), alchemyUrl('arb-sepolia')].filter(Boolean) as string[],
+ [polygon.id]: [infuraUrl('polygon-mainnet'), alchemyUrl('polygon-mainnet')].filter(Boolean) as string[],
+ [optimism.id]: [infuraUrl('optimism-mainnet'), alchemyUrl('opt-mainnet')].filter(Boolean) as string[],
+ [base.id]: [infuraUrl('base-mainnet'), alchemyUrl('base-mainnet')].filter(Boolean) as string[],
// Infura is returning weird estimations for BSC @2025-05-14
//[bsc.id]: `https://bsc-mainnet.infura.io/v3/${INFURA_API_KEY}`,
- [bsc.id]: 'https://bsc-dataseed.bnbchain.org',
- [scroll.id]: `https://scroll-mainnet.infura.io/v3/${INFURA_API_KEY}`,
+ [bsc.id]: ['https://bsc-dataseed.bnbchain.org', infuraUrl('bsc-mainnet'), alchemyUrl('bsc-mainnet')].filter(
+ Boolean
+ ) as string[],
+ [scroll.id]: [infuraUrl('scroll-mainnet')].filter(Boolean) as string[],
}
export const ipfsProviderArray = [
@@ -193,3 +200,6 @@ export const pathTitles: { [key: string]: string } = {
}
export const STABLE_COINS = ['USDC', 'USDT', 'DAI', 'BUSD']
+
+export const ROUTE_NOT_FOUND_ERROR =
+ 'No route found for this token pair. You can try with a different token pair, or try again later'
diff --git a/src/constants/zerodev.consts.ts b/src/constants/zerodev.consts.ts
index fa8106b91..2aa852b4d 100644
--- a/src/constants/zerodev.consts.ts
+++ b/src/constants/zerodev.consts.ts
@@ -1,4 +1,4 @@
-import { infuraRpcUrls } from '@/constants/general.consts'
+import { rpcUrls } from '@/constants/general.consts'
import { getEntryPoint, KERNEL_V3_1 } from '@zerodev/sdk/constants'
import type { Chain, PublicClient } from 'viem'
import { createPublicClient, http } from 'viem'
@@ -29,6 +29,8 @@ export const PINTA_WALLET_TOKEN = '0x9Ae69fDfF2FA97e34B680752D8E70dfD529Ea6ca'
export const PINTA_WALLET_TOKEN_NAME = 'PINTA'
export const PINTA_WALLET_TOKEN_SYMBOL = 'PNT'
+export const USDT_IN_MAINNET = '0xdac17f958d2ee523a2206206994597c13d831ec7'
+
export const PEANUT_WALLET_SUPPORTED_TOKENS: Record = {
[PEANUT_WALLET_CHAIN.id.toString()]: [PEANUT_WALLET_TOKEN],
[PINTA_WALLET_CHAIN.id.toString()]: [PINTA_WALLET_TOKEN],
@@ -42,6 +44,7 @@ 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'
export const PUBLIC_CLIENTS_BY_CHAIN: Record<
string,
@@ -54,7 +57,7 @@ export const PUBLIC_CLIENTS_BY_CHAIN: Record<
> = {
[arbitrum.id]: {
client: createPublicClient({
- transport: http(infuraRpcUrls[arbitrum.id]),
+ transport: http(rpcUrls[arbitrum.id][0]),
chain: arbitrum,
pollingInterval: 500,
}),
@@ -64,7 +67,7 @@ export const PUBLIC_CLIENTS_BY_CHAIN: Record<
},
[polygon.id]: {
client: createPublicClient({
- transport: http(infuraRpcUrls[polygon.id]),
+ transport: http(rpcUrls[polygon.id][0]),
chain: polygon,
pollingInterval: 2500,
}),
diff --git a/src/context/OnrampFlowContext.tsx b/src/context/OnrampFlowContext.tsx
index 339da00de..a7703bb67 100644
--- a/src/context/OnrampFlowContext.tsx
+++ b/src/context/OnrampFlowContext.tsx
@@ -9,6 +9,23 @@ export interface InitialViewErrorState {
errorMessage: string
}
+export interface IOnrampData {
+ transferId?: string
+ depositInstructions?: {
+ amount?: string
+ currency?: string
+ depositMessage?: string
+ bankName?: string
+ bankAddress?: string
+ bankRoutingNumber?: string
+ bankAccountNumber?: string
+ bankBeneficiaryName?: string
+ bankBeneficiaryAddress?: string
+ iban?: string
+ bic?: string
+ }
+}
+
interface OnrampFlowContextType {
amountToOnramp: string
setAmountToOnramp: (amount: string) => void
@@ -18,6 +35,8 @@ interface OnrampFlowContextType {
setError: (error: InitialViewErrorState) => void
fromBankSelected: boolean
setFromBankSelected: (selected: boolean) => void
+ onrampData: IOnrampData | null
+ setOnrampData: (data: IOnrampData | null) => void
resetOnrampFlow: () => void
}
@@ -31,6 +50,7 @@ export const OnrampFlowContextProvider: React.FC<{ children: ReactNode }> = ({ c
errorMessage: '',
})
const [fromBankSelected, setFromBankSelected] = useState(false)
+ const [onrampData, setOnrampData] = useState(null)
const resetOnrampFlow = useCallback(() => {
setAmountToOnramp('')
@@ -40,6 +60,7 @@ export const OnrampFlowContextProvider: React.FC<{ children: ReactNode }> = ({ c
errorMessage: '',
})
setFromBankSelected(false)
+ setOnrampData(null)
}, [])
const value = useMemo(
@@ -52,9 +73,11 @@ export const OnrampFlowContextProvider: React.FC<{ children: ReactNode }> = ({ c
setError,
fromBankSelected,
setFromBankSelected,
+ onrampData,
+ setOnrampData,
resetOnrampFlow,
}),
- [amountToOnramp, currentView, error, fromBankSelected, resetOnrampFlow]
+ [amountToOnramp, currentView, error, fromBankSelected, onrampData, resetOnrampFlow]
)
return {children}
diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts
new file mode 100644
index 000000000..7b50fa84c
--- /dev/null
+++ b/src/hooks/useDebounce.ts
@@ -0,0 +1,23 @@
+import { useEffect, useState } from 'react'
+
+/**
+ * Custom hook for debouncing values
+ * @param value - The value to debounce
+ * @param delay - Delay in milliseconds
+ * @returns The debounced value
+ */
+export function useDebounce(value: T, delay: number): T {
+ const [debouncedValue, setDebouncedValue] = useState(value)
+
+ useEffect(() => {
+ const handler = setTimeout(() => {
+ setDebouncedValue(value)
+ }, delay)
+
+ return () => {
+ clearTimeout(handler)
+ }
+ }, [value, delay])
+
+ return debouncedValue
+}
diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts
index a30a37868..aba6a432b 100644
--- a/src/hooks/usePaymentInitiator.ts
+++ b/src/hooks/usePaymentInitiator.ts
@@ -1,14 +1,10 @@
-import type { FeeOptions } from '@/app/actions/clients'
-import { useCreateLink } from '@/components/Create/useCreateLink'
import {
PEANUT_WALLET_CHAIN,
PEANUT_WALLET_TOKEN,
- PEANUT_WALLET_TOKEN_DECIMALS,
PINTA_WALLET_CHAIN,
PINTA_WALLET_TOKEN,
PINTA_WALLET_TOKEN_DECIMALS,
PINTA_WALLET_TOKEN_SYMBOL,
- SQUID_API_URL,
} from '@/constants'
import { tokenSelectorContext } from '@/context'
import { useWallet } from '@/hooks/wallet/useWallet'
@@ -25,14 +21,33 @@ import {
TChargeTransactionType,
TRequestChargeResponse,
} from '@/services/services.types'
-import { areEvmAddressesEqual, ErrorHandler, isAddressZero, isNativeCurrency } from '@/utils'
+import { areEvmAddressesEqual, ErrorHandler, isNativeCurrency, isTxReverted } from '@/utils'
import { useAppKitAccount } from '@reown/appkit/react'
-import { captureException } from '@sentry/nextjs'
import { peanut, interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import { useCallback, useContext, useEffect, useMemo, useState } from 'react'
-import type { Hex, TransactionReceipt } from 'viem'
+import { parseUnits } from 'viem'
+import type { TransactionReceipt, Address, Hex } 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'
+
+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']
@@ -66,7 +81,7 @@ export const usePaymentInitiator = () => {
const dispatch = useAppDispatch()
const { requestDetails, chargeDetails: chargeDetailsFromStore } = usePaymentStore()
const { selectedTokenData, selectedChainID, selectedTokenAddress, setIsXChain } = useContext(tokenSelectorContext)
- const { isConnected: isPeanutWallet, address: peanutWalletAddress, sendTransactions } = useWallet()
+ const { isConnected: isPeanutWallet, address: peanutWalletAddress, sendTransactions, sendMoney } = useWallet()
const { switchChainAsync } = useSwitchChain()
const { address: wagmiAddress } = useAppKitAccount()
const { sendTransactionAsync } = useSendTransaction()
@@ -74,35 +89,24 @@ export const usePaymentInitiator = () => {
const { chain: connectedWalletChain } = useWagmiAccount()
const [slippagePercentage, setSlippagePercentage] = useState(undefined)
- const [txFee, setTxFee] = useState('0')
const [unsignedTx, setUnsignedTx] = useState(null)
const [xChainUnsignedTxs, setXChainUnsignedTxs] = useState(
null
)
- const { estimateGasFee } = useCreateLink()
- const [feeOptions, setFeeOptions] = useState[]>([])
const [isFeeEstimationError, setIsFeeEstimationError] = useState(false)
const [isCalculatingFees, setIsCalculatingFees] = useState(false)
const [isPreparingTx, setIsPreparingTx] = useState(false)
- const [estimatedGasCost, setEstimatedGasCost] = useState(undefined)
+ const [estimatedGasCostUsd, setEstimatedGasCostUsd] = useState(undefined)
const [estimatedFromValue, setEstimatedFromValue] = useState('0')
- const [loadingStep, setLoadingStep] = useState('Idle')
+ 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 [currentOperationIsAddMoney, setCurrentOperationIsAddMoney] = useState(false)
-
- // calculate fee details
- const [feeCalculations, setFeeCalculations] = useState({
- networkFee: { expected: '0.00', max: '0.00' },
- slippage: undefined as { expected: string; max: string } | undefined,
- estimatedFee: '0.00',
- totalMax: '0.00',
- })
+ const [xChainRoute, setXChainRoute] = useState(undefined)
// use chargeDetails from the store primarily, fallback to createdChargeDetails
const activeChargeDetails = useMemo(
@@ -120,19 +124,15 @@ export const usePaymentInitiator = () => {
return !areEvmAddressesEqual(selectedTokenData.address, activeChargeDetails.tokenAddress)
}, [selectedTokenData, activeChargeDetails])
- const isProcessing = useMemo(
- () => loadingStep !== 'Idle' && loadingStep !== 'Success' && loadingStep !== 'Error',
+ const isProcessing = useMemo(
+ () =>
+ loadingStep !== 'Idle' &&
+ loadingStep !== 'Success' &&
+ loadingStep !== 'Error' &&
+ loadingStep !== 'Charge Created',
[loadingStep]
)
- const calculatedSlippage = useMemo(() => {
- if (!selectedTokenData?.price || !slippagePercentage || !estimatedFromValue) return null
-
- const slippageAmount = (slippagePercentage / 100) * selectedTokenData.price * Number(estimatedFromValue)
-
- return isNaN(slippageAmount) ? null : slippageAmount.toFixed(2)
- }, [slippagePercentage, selectedTokenData?.price, estimatedFromValue])
-
// reset state
useEffect(() => {
setError(null)
@@ -144,159 +144,14 @@ export const usePaymentInitiator = () => {
setUnsignedTx(null)
setXChainUnsignedTxs(null)
+ setXChainRoute(undefined)
setEstimatedFromValue('0')
- setTxFee('0')
setSlippagePercentage(undefined)
- setEstimatedGasCost(undefined)
- setFeeOptions([])
- setFeeCalculations({
- networkFee: { expected: '0.00', max: '0.00' },
- slippage: undefined,
- estimatedFee: '0.00',
- totalMax: '0.00',
- })
-
+ setEstimatedGasCostUsd(undefined)
setTransactionHash(null)
setPaymentDetails(null)
}, [selectedChainID, selectedTokenAddress, requestDetails])
- // estimate gas fee when transaction is prepared
- useEffect(() => {
- if (!activeChargeDetails || (!unsignedTx && !xChainUnsignedTxs)) return
-
- let determinedEstimatorAccount: Hex | undefined
-
- if (xChainUnsignedTxs && xChainUnsignedTxs.length > 0) {
- // xChain transactions are always for the external (Wagmi) wallet
- determinedEstimatorAccount = wagmiAddress as Hex | undefined
- } else if (unsignedTx) {
- // for unsignedTx, check if it's an Add Money operation or dependent on PeanutWallet state
- if (currentOperationIsAddMoney) {
- determinedEstimatorAccount = wagmiAddress as Hex | undefined
- } else {
- determinedEstimatorAccount = (isPeanutWallet ? peanutWalletAddress : wagmiAddress) as Hex | undefined
- }
- }
-
- if (!determinedEstimatorAccount) {
- console.warn(
- 'Gas estimation skipped: No valid estimator account found (wagmiAddress or peanutWalletAddress). Ensure one is connected and active for the flow.'
- )
- setError('Cannot estimate fees: Wallet address not found.')
- setIsFeeEstimationError(true)
- return
- }
-
- setIsEstimatingGas(true)
- setIsFeeEstimationError(false)
- estimateGasFee({
- from: determinedEstimatorAccount as Hex,
- chainId: isXChain ? selectedChainID : activeChargeDetails.chainId,
- preparedTxs: isXChain || diffTokens ? xChainUnsignedTxs! : [unsignedTx!],
- })
- .then(({ transactionCostUSD, feeOptions: calculatedFeeOptions }) => {
- if (transactionCostUSD) setEstimatedGasCost(transactionCostUSD)
- if (calculatedFeeOptions) setFeeOptions(calculatedFeeOptions)
- })
- .catch((error) => {
- console.error('Error calculating transaction cost:', error)
- captureException(error)
- setIsFeeEstimationError(true)
- setError(`Failed to estimate gas fee: ${ErrorHandler(error)}`)
- })
- .finally(() => {
- setIsEstimatingGas(false)
- })
- }, [unsignedTx, xChainUnsignedTxs, activeChargeDetails, isXChain, diffTokens, selectedChainID, estimateGasFee])
-
- // calculate display fees
- useEffect(() => {
- if (!activeChargeDetails || !selectedTokenData || isEstimatingGas || isPreparingTx) {
- return
- }
-
- const EXPECTED_NETWORK_FEE_MULTIPLIER = 0.7
- const EXPECTED_SLIPPAGE_MULTIPLIER = 0.1
- setIsCalculatingFees(true)
- let timerId: NodeJS.Timeout | undefined
-
- try {
- // determine the base fee depending on the transaction type
- const baseNetworkFee = isPeanutWallet ? 0 : Number(estimatedGasCost || 0)
-
- const networkFee = {
- // expected fee is always a fraction of the base fee
- expected: baseNetworkFee * EXPECTED_NETWORK_FEE_MULTIPLIER,
- // max fee is the full base fee determined above
- max: baseNetworkFee,
- }
-
- const slippage =
- (isXChain || diffTokens) && calculatedSlippage
- ? {
- expected: Number(calculatedSlippage) * EXPECTED_SLIPPAGE_MULTIPLIER,
- max: Number(calculatedSlippage),
- }
- : undefined
-
- const totalMax =
- Number(estimatedFromValue) * selectedTokenData!.price + networkFee.max + (slippage?.max || 0)
-
- const formatNumberSafely = (num: number) => {
- if (isNaN(num) || !isFinite(num)) return '0.00'
- return num < 0.01 && num > 0 ? ' <0.01' : num.toFixed(2)
- }
-
- setFeeCalculations({
- networkFee: {
- expected: formatNumberSafely(networkFee.expected),
- max: formatNumberSafely(networkFee.max),
- },
- slippage: slippage
- ? {
- expected: formatNumberSafely(slippage.expected),
- max: formatNumberSafely(slippage.max),
- }
- : undefined,
- estimatedFee: formatNumberSafely(networkFee.expected + (slippage?.expected || 0)),
- totalMax: formatNumberSafely(totalMax),
- })
- } catch (error) {
- console.error('Error calculating fees:', error)
- setIsFeeEstimationError(true)
- setError('Failed to calculate fees')
- setFeeCalculations({
- networkFee: { expected: '0.00', max: '0.00' },
- slippage: undefined,
- estimatedFee: '0.00',
- totalMax: '0.00',
- })
- } finally {
- // schedule setting isCalculatingFees to false
- timerId = setTimeout(() => {
- setIsCalculatingFees(false)
- }, 100)
- }
-
- return () => {
- if (timerId) {
- clearTimeout(timerId)
- }
- }
- }, [
- isXChain,
- diffTokens,
- txFee,
- calculatedSlippage,
- activeChargeDetails,
- selectedTokenData,
- isPeanutWallet,
- estimatedGasCost,
- estimatedFromValue,
- isEstimatingGas,
- isPreparingTx,
- ])
-
const handleError = useCallback(
(err: unknown, step: string): InitiationResult => {
console.error(`Error during ${step}:`, err)
@@ -323,71 +178,67 @@ export const usePaymentInitiator = () => {
// prepare transaction details (called from Confirm view)
const prepareTransactionDetails = useCallback(
- async (chargeDetails: TRequestChargeResponse, isAddMoneyFlowContext?: boolean) => {
- setCurrentOperationIsAddMoney(!!isAddMoneyFlowContext)
-
- if (!selectedTokenData || (!peanutWalletAddress && !wagmiAddress)) {
- console.warn('Missing data for transaction preparation')
- return
+ async ({
+ chargeDetails,
+ from,
+ usdAmount,
+ }: {
+ chargeDetails: TRequestChargeResponse
+ from: {
+ address: Address
+ tokenAddress: Address
+ chainId: string
}
-
+ usdAmount?: string
+ }) => {
setError(null)
setIsFeeEstimationError(false)
setUnsignedTx(null)
setXChainUnsignedTxs(null)
+ setXChainRoute(undefined)
- setEstimatedGasCost(undefined)
- setFeeOptions([])
- setFeeCalculations({
- networkFee: { expected: '0.00', max: '0.00' },
- slippage: undefined,
- estimatedFee: '0.00',
- totalMax: '0.00',
- })
-
- // If PeanutWallet is connected AND it's NOT an Add Money flow (which mandates external wallet),
- // then we can assume preparation is for Peanut Wallet.
- // Otherwise, proceed to prepare for external wallet.
- if (isPeanutWallet && !isAddMoneyFlowContext) {
- setEstimatedFromValue(chargeDetails.tokenAmount)
- setIsPreparingTx(false)
- return
- }
+ setEstimatedGasCostUsd(undefined)
setIsPreparingTx(true)
try {
- const _isXChain = selectedChainID !== chargeDetails.chainId
- const _diffTokens = !areEvmAddressesEqual(selectedTokenData.address, chargeDetails.tokenAddress)
+ const _isXChain = from.chainId !== chargeDetails.chainId
+ const _diffTokens = !areEvmAddressesEqual(from.tokenAddress, chargeDetails.tokenAddress)
setIsXChain(_isXChain)
if (_isXChain || _diffTokens) {
setLoadingStep('Preparing Transaction')
- const txData = await prepareXChainTransaction(
- {
- address: selectedTokenData.address,
- chainId: selectedTokenData.chainId,
- decimals: selectedTokenData.decimals || 18,
- },
- {
- recipientAddress: chargeDetails.requestLink.recipientAddress,
+ 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,
- tokenAmount: chargeDetails.tokenAmount,
- tokenAddress: chargeDetails.tokenAddress,
- tokenDecimals: chargeDetails.tokenDecimals,
- tokenType: Number(chargeDetails.tokenType),
},
- peanutWalletAddress ?? wagmiAddress!
- )
-
- if (!txData?.unsignedTxs) {
- throw new Error('Failed to prepare cross-chain transaction')
- }
+ ...amount,
+ })
- setXChainUnsignedTxs(txData.unsignedTxs)
- setEstimatedFromValue(txData.estimatedFromAmount)
- setTxFee(txData.feeEstimation)
- setSlippagePercentage(txData.slippagePercentage)
+ 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({
@@ -402,9 +253,25 @@ export const usePaymentInitiator = () => {
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)
- setTxFee('0')
setSlippagePercentage(undefined)
}
setLoadingStep('Idle')
@@ -418,15 +285,7 @@ export const usePaymentInitiator = () => {
setIsPreparingTx(false)
}
},
- [
- selectedTokenData,
- selectedChainID,
- peanutWalletAddress,
- wagmiAddress,
- setIsXChain,
- isPeanutWallet,
- selectedTokenAddress,
- ]
+ [setIsXChain, isPeanutWallet]
)
// helper function: determine charge details (fetch or create)
@@ -586,28 +445,9 @@ export const usePaymentInitiator = () => {
// helper function: Handle Peanut Wallet payment
const handlePeanutWalletPayment = useCallback(
- async (chargeDetails: TRequestChargeResponse, payload: InitiatePaymentPayload): Promise => {
+ async (chargeDetails: TRequestChargeResponse): Promise => {
setLoadingStep('Preparing Transaction')
- // determine expected chain, token, and decimals based on whether it's a pinta request.
- const isPintaSpecific = payload.isPintaReq
- const expectedChainId = isPintaSpecific
- ? PINTA_WALLET_CHAIN.id.toString()
- : PEANUT_WALLET_CHAIN.id.toString()
- const expectedTokenAddress = isPintaSpecific ? PINTA_WALLET_TOKEN : PEANUT_WALLET_TOKEN
- const expectedTokenDecimals = isPintaSpecific ? PINTA_WALLET_TOKEN_DECIMALS : PEANUT_WALLET_TOKEN_DECIMALS
-
- // validate that the charge details match the expected parameters for payment.
- if (
- chargeDetails.chainId !== expectedChainId ||
- chargeDetails.tokenAddress.toLowerCase() !== expectedTokenAddress.toLowerCase() ||
- chargeDetails.tokenDecimals !== expectedTokenDecimals
- ) {
- const errorMsg = `Charge details mismatch expected values for ${isPintaSpecific ? 'Pinta' : 'Peanut'} payment. Expected Chain: ${expectedChainId}, Token: ${expectedTokenAddress}, Decimals: ${expectedTokenDecimals}. Got Chain: ${chargeDetails.chainId}, Token: ${chargeDetails.tokenAddress}, Decimals: ${chargeDetails.tokenDecimals}`
- console.error(errorMsg)
- throw new Error(errorMsg)
- }
-
// validate required properties for preparing the transaction.
if (
!chargeDetails.requestLink?.recipientAddress ||
@@ -620,32 +460,30 @@ export const usePaymentInitiator = () => {
throw new Error('Charge data is missing required properties for transaction preparation.')
}
- // prepare the transaction using peanut sdk.
- 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) {
- console.error('Failed to prepare Peanut Wallet transaction (SDK returned null/undefined unsignedTx).')
- throw new Error('Failed to prepare Peanut Wallet transaction')
+ let receipt: TransactionReceipt
+ const transactionsToSend = xChainUnsignedTxs ?? (unsignedTx ? [unsignedTx] : null)
+ if (transactionsToSend && transactionsToSend.length > 0) {
+ setLoadingStep('Sending Transaction')
+ receipt = await sendTransactions(transactionsToSend, PEANUT_WALLET_CHAIN.id.toString())
+ } else if (
+ areEvmAddressesEqual(chargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) &&
+ chargeDetails.chainId === PEANUT_WALLET_CHAIN.id.toString()
+ ) {
+ receipt = await sendMoney(
+ chargeDetails.requestLink.recipientAddress as `0x${string}`,
+ chargeDetails.tokenAmount
+ )
+ } else {
+ console.error('No transaction prepared to send for peanut wallet.')
+ throw new Error('No transaction prepared to send.')
}
- const peanutUnsignedTx = tx.unsignedTx
-
- // send the prepared transaction via the usewallet hook.
- setLoadingStep('Sending Transaction')
-
- const receipt: TransactionReceipt = await sendTransactions([peanutUnsignedTx], chargeDetails.chainId)
// validation of the received receipt.
if (!receipt || !receipt.transactionHash) {
console.error('sendTransactions returned invalid receipt (missing hash?):', receipt)
throw new Error('Transaction likely failed or was not submitted correctly by the wallet.')
}
- if (receipt.status === 'reverted') {
+ if (isTxReverted(receipt)) {
console.error('Transaction reverted according to receipt:', receipt)
throw new Error(`Transaction failed (reverted). Hash: ${receipt.transactionHash}`)
}
@@ -654,9 +492,9 @@ export const usePaymentInitiator = () => {
setLoadingStep('Updating Payment Status')
const payment: PaymentCreationResponse = await chargesApi.createPayment({
chargeId: chargeDetails.uuid,
- chainId: chargeDetails.chainId,
+ chainId: PEANUT_WALLET_CHAIN.id.toString(),
hash: receipt.transactionHash,
- tokenAddress: chargeDetails.tokenAddress,
+ tokenAddress: PEANUT_WALLET_TOKEN,
})
console.log('Backend payment creation response:', payment)
@@ -668,7 +506,7 @@ export const usePaymentInitiator = () => {
console.log('Peanut Wallet payment successful.')
return { status: 'Success', charge: chargeDetails, payment, txHash: receipt.transactionHash, success: true }
},
- [dispatch, sendTransactions]
+ [sendTransactions, xChainUnsignedTxs, unsignedTx]
)
// helper function: Handle External Wallet payment
@@ -712,14 +550,6 @@ export const usePaymentInitiator = () => {
currentStep = 'Sending Transaction'
const txGasOptions: any = {}
- const gasOptions = feeOptions[i]
- if (gasOptions) {
- if (gasOptions.gas) txGasOptions.gas = BigInt(gasOptions.gas.toString())
- if (gasOptions.maxFeePerGas)
- txGasOptions.maxFeePerGas = BigInt(gasOptions.maxFeePerGas.toString())
- if (gasOptions.maxPriorityFeePerGas)
- txGasOptions.maxPriorityFeePerGas = BigInt(gasOptions.maxPriorityFeePerGas.toString())
- }
console.log('Using gas options:', txGasOptions)
const hash = await sendTransactionAsync({
@@ -742,7 +572,7 @@ export const usePaymentInitiator = () => {
console.log(`Transaction ${i + 1} receipt:`, txReceipt)
receipts.push(txReceipt)
- if (txReceipt.status === 'reverted') {
+ if (isTxReverted(txReceipt)) {
console.error(`Transaction ${i + 1} reverted:`, txReceipt)
throw new Error(`Transaction ${i + 1} failed (reverted).`)
}
@@ -788,7 +618,6 @@ export const usePaymentInitiator = () => {
switchChainAsync,
xChainUnsignedTxs,
unsignedTx,
- feeOptions,
sendTransactionAsync,
config,
selectedTokenData,
@@ -814,7 +643,15 @@ export const usePaymentInitiator = () => {
console.log('Proceeding with charge details:', determinedChargeDetails.uuid)
// 2. handle charge state
- if (chargeCreated && (payload.isPintaReq || payload.isAddMoneyFlow || !isPeanutWallet)) {
+ if (
+ chargeCreated &&
+ (payload.isPintaReq ||
+ payload.isAddMoneyFlow ||
+ !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.isPintaReq
@@ -843,7 +680,7 @@ export const usePaymentInitiator = () => {
console.log(
`Executing Peanut Wallet transaction (chargeCreated: ${chargeCreated}, isPintaReq: ${payload.isPintaReq})`
)
- return await handlePeanutWalletPayment(determinedChargeDetails, payload)
+ 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.')
@@ -884,85 +721,6 @@ export const usePaymentInitiator = () => {
]
)
- // helper function to prepare cross-chain transactions
- const prepareXChainTransaction = useCallback(
- async (
- tokenData: {
- address: string
- chainId: string | number
- decimals: number
- },
- requestLink: {
- recipientAddress: string
- chainId: string | number
- tokenAmount: string
- tokenAddress: string
- tokenDecimals: number
- tokenType: number
- },
- senderAddress: string
- ) => {
- if (!tokenData?.address || !tokenData?.chainId || tokenData?.decimals === undefined) {
- throw new Error('Invalid token data for cross-chain transaction')
- }
-
- try {
- const formattedTokenAmount = Number(requestLink.tokenAmount).toFixed(requestLink.tokenDecimals)
-
- const linkDetails = {
- recipientAddress: requestLink.recipientAddress,
- chainId: requestLink.chainId.toString(),
- tokenAmount: formattedTokenAmount,
- tokenAddress: requestLink.tokenAddress,
- tokenDecimals: requestLink.tokenDecimals,
- tokenType: Number(requestLink.tokenType),
- }
-
- const xchainUnsignedTxs = await peanut.prepareXchainRequestFulfillmentTransaction({
- fromToken: tokenData.address,
- fromChainId: tokenData.chainId.toString(),
- senderAddress,
- squidRouterUrl: `${SQUID_API_URL}/route`,
- provider: await peanut.getDefaultProvider(tokenData.chainId.toString()),
- tokenType: isAddressZero(tokenData.address)
- ? peanutInterfaces.EPeanutLinkType.native
- : peanutInterfaces.EPeanutLinkType.erc20,
- fromTokenDecimals: tokenData.decimals,
- linkDetails,
- })
-
- if (!xchainUnsignedTxs) {
- throw new Error('Failed to prepare cross-chain transaction (SDK returned null/undefined)')
- }
-
- return xchainUnsignedTxs
- } catch (error) {
- console.error('Cross-chain preparation error:', error)
- let errorBody = undefined
- try {
- if (error instanceof Error && error.message.startsWith('{')) {
- errorBody = JSON.parse(error.message)
- } else if (typeof (error as any)?.body === 'string' && (error as any).body.startsWith('{')) {
- errorBody = JSON.parse((error as any).body)
- if (errorBody?.error?.message) errorBody.message = errorBody.error.message
- }
- } catch (e) {
- console.log('Failed to parse error as JSON, using original error message')
- }
-
- if (errorBody && errorBody.message) {
- const code = errorBody.code || errorBody.errorCode || (error as any).code
- throw new Error(`Cross-chain prep failed: ${errorBody.message}${code ? ` (Code: ${code})` : ''}`)
- } else {
- throw new Error(
- error instanceof Error ? error.message : 'Failed to estimate cross-chain transaction details'
- )
- }
- }
- },
- []
- )
-
const cancelOperation = useCallback(() => {
setError('Please confirm the request in your wallet.')
setLoadingStep('Error')
@@ -978,17 +736,17 @@ export const usePaymentInitiator = () => {
activeChargeDetails,
transactionHash,
paymentDetails,
- txFee,
slippagePercentage,
estimatedFromValue,
xChainUnsignedTxs,
+ estimatedGasCostUsd,
unsignedTx,
- feeCalculations,
isCalculatingFees,
isFeeEstimationError,
isEstimatingGas,
isXChain,
diffTokens,
cancelOperation,
+ xChainRoute,
}
}
diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts
index 12c1008d8..dc6a26ddd 100644
--- a/src/hooks/useWebSocket.ts
+++ b/src/hooks/useWebSocket.ts
@@ -93,7 +93,11 @@ export const useWebSocket = (options: UseWebSocketOptions = {}) => {
}
const handleHistoryEntry = (entry: HistoryEntry) => {
- if (entry.type === 'DIRECT_SEND' && entry.status === 'NEW' && !entry.senderAccount) {
+ if (
+ (entry.type === 'DIRECT_SEND' || entry.type === 'REQUEST') &&
+ entry.status === 'NEW' &&
+ !entry.senderAccount
+ ) {
// Ignore pending requests from the server
return
}
diff --git a/src/hooks/wallet/useWallet.ts b/src/hooks/wallet/useWallet.ts
index b208223e6..44fdafdb3 100644
--- a/src/hooks/wallet/useWallet.ts
+++ b/src/hooks/wallet/useWallet.ts
@@ -3,6 +3,7 @@
import {
PEANUT_WALLET_CHAIN,
PEANUT_WALLET_TOKEN,
+ PEANUT_WALLET_TOKEN_DECIMALS,
peanutPublicClient,
PINTA_WALLET_TOKEN,
PINTA_WALLET_TOKEN_DECIMALS,
@@ -13,8 +14,8 @@ import { walletActions } from '@/redux/slices/wallet-slice'
import { formatAmount } from '@/utils'
import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk'
import { useCallback, useEffect, useState } from 'react'
-import type { Hex } from 'viem'
-import { erc20Abi, formatUnits, getAddress } from 'viem'
+import type { Hex, Address } from 'viem'
+import { erc20Abi, formatUnits, parseUnits, encodeFunctionData, getAddress } from 'viem'
import { useZeroDev } from '../useZeroDev'
export const useWallet = () => {
@@ -24,6 +25,26 @@ export const useWallet = () => {
const [isFetchingRewardBalance, setIsFetchingRewardBalance] = useState(true)
const { balance } = useWalletStore()
+ const sendMoney = useCallback(
+ async (toAddress: Address, amountInUsd: string) => {
+ const amountToSend = parseUnits(amountInUsd, PEANUT_WALLET_TOKEN_DECIMALS)
+
+ const txData = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'transfer',
+ args: [toAddress, amountToSend],
+ })
+
+ const transaction: peanutInterfaces.IPeanutUnsignedTransaction = {
+ to: PEANUT_WALLET_TOKEN,
+ data: txData,
+ }
+
+ return await sendTransactions([transaction], PEANUT_WALLET_CHAIN.id.toString())
+ },
+ [handleSendUserOpEncoded]
+ )
+
const sendTransactions = useCallback(
async (unsignedTxs: peanutInterfaces.IPeanutUnsignedTransaction[], chainId?: string) => {
const params = unsignedTxs.map((tx: peanutInterfaces.IPeanutUnsignedTransaction) => ({
@@ -94,6 +115,7 @@ export const useWallet = () => {
balance: BigInt(balance),
isConnected: isKernelClientReady,
sendTransactions,
+ sendMoney,
getRewardWalletBalance,
fetchBalance,
isFetchingBalance,
diff --git a/src/lib/validation/recipient.test.ts b/src/lib/validation/recipient.test.ts
index 635d4e353..41788ee76 100644
--- a/src/lib/validation/recipient.test.ts
+++ b/src/lib/validation/recipient.test.ts
@@ -36,6 +36,16 @@ describe('Recipient Validation', () => {
it('should identify usernames', () => {
expect(getRecipientType('kusharc')).toBe('USERNAME')
})
+
+ it('should treat non-addresses as ENS when isWithdrawal is true', () => {
+ expect(getRecipientType('kusharc', true)).toBe('ENS')
+ expect(getRecipientType('someuser', true)).toBe('ENS')
+ })
+
+ it('should still identify ENS and addresses correctly when isWithdrawal is true', () => {
+ expect(getRecipientType('vitalik.eth', true)).toBe('ENS')
+ expect(getRecipientType('0x1234567890123456789012345678901234567890', true)).toBe('ADDRESS')
+ })
})
describe('validateAndResolveRecipient', () => {
@@ -62,8 +72,8 @@ describe('Recipient Validation', () => {
})
})
- it('should throw for invalid Ethereum addresses', async () => {
- await expect(validateAndResolveRecipient('0xinvalid')).rejects.toThrow('Invalid Ethereum address')
+ it('should throw for invalid addresses', async () => {
+ await expect(validateAndResolveRecipient('0xinvalid')).rejects.toThrow('Invalid address')
})
it('should throw for invalid Peanut usernames', async () => {
@@ -73,6 +83,11 @@ describe('Recipient Validation', () => {
await expect(validateAndResolveRecipient('lmaoo')).rejects.toThrow('Invalid Peanut username')
})
+
+ it('should treat non-addresses as ENS in withdrawal context', async () => {
+ await expect(validateAndResolveRecipient('kusharc', true)).rejects.toThrow('ENS name not found')
+ await expect(validateAndResolveRecipient('someuser', true)).rejects.toThrow('ENS name not found')
+ })
})
describe('verifyPeanutUsername', () => {
diff --git a/src/lib/validation/recipient.ts b/src/lib/validation/recipient.ts
index 69134576c..1d39918cb 100644
--- a/src/lib/validation/recipient.ts
+++ b/src/lib/validation/recipient.ts
@@ -10,9 +10,10 @@ import { RecipientValidationError } from '../url-parser/errors'
import { RecipientType } from '../url-parser/types/payment'
export async function validateAndResolveRecipient(
- recipient: string
+ recipient: string,
+ isWithdrawal: boolean = false
): Promise<{ identifier: string; recipientType: RecipientType; resolvedAddress: string }> {
- const recipientType = getRecipientType(recipient)
+ const recipientType = getRecipientType(recipient, isWithdrawal)
switch (recipientType) {
case 'ENS':
@@ -29,7 +30,7 @@ export async function validateAndResolveRecipient(
case 'ADDRESS':
if (!isAddress(recipient)) {
- throw new RecipientValidationError('Invalid Ethereum address')
+ throw new RecipientValidationError('Invalid address')
}
return {
identifier: recipient,
@@ -38,6 +39,7 @@ export async function validateAndResolveRecipient(
}
case 'USERNAME':
+ recipient = recipient.toLowerCase()
const isValidPeanutUsername = await verifyPeanutUsername(recipient)
if (!isValidPeanutUsername) {
throw new RecipientValidationError('Invalid Peanut username')
@@ -55,7 +57,7 @@ export async function validateAndResolveRecipient(
}
}
-export const getRecipientType = (recipient: string): RecipientType => {
+export const getRecipientType = (recipient: string, isWithdrawal: boolean = false): RecipientType => {
if (recipient.includes('.')) {
return 'ENS'
}
@@ -64,7 +66,12 @@ export const getRecipientType = (recipient: string): RecipientType => {
if (isAddress(recipient)) {
return 'ADDRESS'
}
- throw new RecipientValidationError('Invalid Ethereum address')
+ throw new RecipientValidationError('Invalid address')
+ }
+
+ // For withdrawals, treat non-addresses as ENS names instead of usernames
+ if (isWithdrawal) {
+ return 'ENS'
}
return 'USERNAME'
diff --git a/src/redux/slices/send-flow-slice.ts b/src/redux/slices/send-flow-slice.ts
index 9a70bb209..611f435d6 100644
--- a/src/redux/slices/send-flow-slice.ts
+++ b/src/redux/slices/send-flow-slice.ts
@@ -27,7 +27,6 @@ const initialState: ISendFlowState = {
preparedDepositTxs: undefined,
txHash: undefined,
link: undefined,
- feeOptions: undefined,
transactionCostUSD: undefined,
attachmentOptions: {
fileUrl: undefined,
@@ -81,9 +80,6 @@ const sendFlowSlice = createSlice({
setLink(state, action: { payload: string | undefined }) {
state.link = action.payload
},
- setFeeOptions(state, action: { payload: any | undefined }) {
- state.feeOptions = action.payload
- },
setTransactionCostUSD(state, action: { payload: number | undefined }) {
state.transactionCostUSD = action.payload
},
diff --git a/src/redux/types/send-flow.types.ts b/src/redux/types/send-flow.types.ts
index a38a1db70..f9445dd30 100644
--- a/src/redux/types/send-flow.types.ts
+++ b/src/redux/types/send-flow.types.ts
@@ -30,7 +30,6 @@ export interface ISendFlowState {
preparedDepositTxs: peanutInterfaces.IPrepareDepositTxsResponse | undefined
txHash: string | undefined
link: string | undefined
- feeOptions: any | undefined
transactionCostUSD: number | undefined
attachmentOptions: IAttachmentOptions
errorState: ErrorState | undefined
diff --git a/src/services/requests.ts b/src/services/requests.ts
index 99d7c32f8..b0f80c45c 100644
--- a/src/services/requests.ts
+++ b/src/services/requests.ts
@@ -18,7 +18,40 @@ export const requestsApi = {
})
if (!response.ok) {
- throw new Error(`Failed to create request: ${response.statusText}`)
+ let errorMessage = `Failed to create request: ${response.statusText}`
+
+ try {
+ const errorData = await response.json()
+ if (errorData.error) {
+ errorMessage = errorData.error
+ }
+ } catch (parseError) {
+ // If we can't parse the response, use the default error message
+ console.warn('Could not parse error response:', parseError)
+ }
+
+ throw new Error(errorMessage)
+ }
+
+ return response.json()
+ },
+
+ update: async (id: string, data: Partial): Promise => {
+ const formData = new FormData()
+
+ Object.entries(data).forEach(([key, value]) => {
+ if (value !== undefined) {
+ formData.append(key, value)
+ }
+ })
+
+ const response = await fetchWithSentry(`/api/proxy/withFormData/requests/${id}`, {
+ method: 'PATCH',
+ body: formData,
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to update request: ${response.statusText}`)
}
return response.json()
diff --git a/src/services/services.types.ts b/src/services/services.types.ts
index 444289c7a..eefa460b5 100644
--- a/src/services/services.types.ts
+++ b/src/services/services.types.ts
@@ -181,6 +181,8 @@ export interface TRequestChargeResponse {
updatedAt: string
payments: Payment[]
fulfillmentPayment: Payment | null
+ currencyCode: string
+ currencyAmount: string
timeline: TimelineEntry[]
requestee?: {
userId: string
@@ -200,8 +202,6 @@ export interface TRequestChargeResponse {
}
}
}
- currencyAmount?: string
- currencyCode?: string
}
// create payment response
diff --git a/src/services/swap.ts b/src/services/swap.ts
new file mode 100644
index 000000000..e87713cd8
--- /dev/null
+++ b/src/services/swap.ts
@@ -0,0 +1,532 @@
+'use server'
+import type { Address, Hash, Hex } from 'viem'
+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 } from '@/constants'
+
+type TokenInfo = {
+ address: Address
+ tokenAddress: Address
+ chainId: string
+}
+
+type RouteParams = {
+ from: TokenInfo
+ to: TokenInfo
+} & (
+ | {
+ fromAmount: bigint
+ toAmount?: undefined
+ fromUsd?: undefined
+ toUsd?: undefined
+ }
+ | {
+ fromAmount?: undefined
+ toAmount: bigint
+ fromUsd?: undefined
+ toUsd?: undefined
+ }
+ | {
+ fromAmount?: undefined
+ toAmount?: undefined
+ fromUsd: string
+ toUsd?: undefined
+ }
+ | {
+ fromAmount?: undefined
+ toAmount?: undefined
+ fromUsd?: undefined
+ toUsd: string
+ }
+)
+
+type SquidGetRouteParams = {
+ fromChain: string
+ fromToken: string
+ fromAmount: string
+ fromAddress: string
+ toAddress: string
+ toChain: string
+ toToken: string
+}
+
+type SquidCall = {
+ chainType: string
+ callType: number
+ target: Address
+ callData: Hex
+ value: string
+ payload: {
+ tokenAddress: Address
+ inputPos: number
+ }
+ estimatedGas: string
+}
+
+type SquidAction = {
+ type: 'swap' | 'rfq'
+ chainType: string
+ data: {
+ liquidityProvider: string
+ provider: string
+ type: string
+ fillerAddress: Address
+ expiry: string
+ logoURI: string
+ calls: SquidCall[]
+ }
+ fromChain: string
+ toChain: string
+ fromToken: SquidToken
+ toToken: SquidToken
+ fromAmount: string
+ toAmount: string
+ toAmountMin: string
+ exchangeRate: string
+ priceImpact: string
+ stage: number
+ provider: string
+ logoURI: string
+ description: string
+ orderHash: Hash
+}
+
+type SquidToken = {
+ id: string
+ symbol: string
+ address: Address
+ chainId: string
+ name: string
+ decimals: number
+ coingeckoId: string
+ type: string
+ logoURI: string
+ axelarNetworkSymbol: string
+ subGraphOnly: boolean
+ subGraphIds: string[]
+ enabled: boolean
+ active: boolean
+ visible: boolean
+ usdPrice: number
+}
+
+type SquidFeeCost = {
+ amount: string
+ amountUsd: string
+ description: string
+ gasLimit: string
+ gasMultiplier: number
+ name: string
+ token: SquidToken
+ logoURI: string
+}
+
+type SquidGasCost = {
+ type: string
+ token: SquidToken
+ amount: string
+ gasLimit: string
+ amountUsd: string
+}
+
+type SquidRouteResponse = {
+ route: {
+ estimate: {
+ actions: SquidAction[]
+ fromAmount: string
+ toAmount: string
+ toAmountMin: string
+ exchangeRate: string
+ aggregatePriceImpact: string
+ fromAmountUSD: string
+ toAmountUSD: string
+ toAmountMinUSD: string
+ aggregateSlippage: number
+ fromToken: SquidToken
+ toToken: SquidToken
+ isBoostSupported: boolean
+ feeCosts: SquidFeeCost[]
+ gasCosts: SquidGasCost[]
+ estimatedRouteDuration: number
+ }
+ transactionRequest: {
+ type: string
+ target: Address
+ data: Hex
+ value: string
+ gasLimit: string
+ lastBaseFeePerGas: string
+ maxFeePerGas: string
+ maxPriorityFeePerGas: string
+ gasPrice: string
+ requestId: string
+ expiry: string
+ expiryOffset: string
+ }
+ params: SquidGetRouteParams
+ quoteId: string
+ }
+}
+
+/**
+ * Check current allowance for a token
+ */
+async function checkTokenAllowance(
+ tokenAddress: Address,
+ ownerAddress: Address,
+ spenderAddress: Address,
+ chainId: string
+): Promise {
+ const client = await getPublicClient(Number(chainId) as ChainId)
+
+ const allowance = await client.readContract({
+ address: tokenAddress,
+ abi: erc20Abi,
+ functionName: 'allowance',
+ args: [ownerAddress, spenderAddress],
+ })
+
+ return allowance
+}
+
+/**
+ * Create an approve transaction
+ */
+function createApproveTransaction(
+ tokenAddress: Address,
+ spenderAddress: Address,
+ amount: bigint
+): { to: Address; data: Hex; value: string } {
+ const data = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spenderAddress, amount],
+ })
+
+ return {
+ to: tokenAddress,
+ data,
+ value: '0',
+ }
+}
+
+/**
+ * Estimate gas cost for approve transaction in USD
+ */
+async function estimateApprovalCostUsd(
+ tokenAddress: Address,
+ spenderAddress: Address,
+ amount: bigint,
+ fromAddress: Address,
+ chainId: string
+): Promise {
+ const estimateCost = await estimateTransactionCostUsd(
+ fromAddress,
+ tokenAddress,
+ encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [spenderAddress, amount],
+ }),
+ chainId
+ )
+ return estimateCost
+}
+
+/**
+ * Fetch the route from the squid API.
+ * We use this when we fetch the route several times while finding the optimal fromAmount.
+ */
+async function getSquidRouteRaw(params: SquidGetRouteParams): Promise {
+ const response = await fetchWithSentry(`${SQUID_API_URL}/v2/route`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'x-integrator-id': process.env.SQUID_INTEGRATOR_ID!,
+ },
+ body: JSON.stringify(params),
+ })
+
+ if (!response.ok) {
+ throw new Error(`Failed to get route: ${response.status}`)
+ }
+
+ const data = await response.json()
+ return data as SquidRouteResponse
+}
+
+/**
+ * Find the optimal fromAmount for a given target amount of tokens.
+ *
+ * Uses binary search to find the optimal fromAmount for a given target amount of tokens.
+ * This is done by calling the squid API with different fromAmount values until the
+ * overage is below a certain threshold.
+ */
+async function findOptimalFromAmount(
+ params: Omit,
+ targetToAmount: bigint,
+ _toTokenPrice?: { price: number; decimals: number }
+): Promise {
+ // Only fetch if not provided
+ const [toTokenPrice, fromTokenPrice] = await Promise.all([
+ _toTokenPrice ? Promise.resolve(_toTokenPrice) : fetchTokenPrice(params.toToken, params.toChain),
+ fetchTokenPrice(params.fromToken, params.fromChain),
+ ])
+ if (!toTokenPrice) throw new Error('Could not fetch to token price')
+ if (!fromTokenPrice) throw new Error('Could not fetch from token price')
+
+ const targetUsd = Number(formatUnits(targetToAmount, toTokenPrice.decimals)) * toTokenPrice.price
+ const initialFromAmount = parseUnits((targetUsd / fromTokenPrice.price).toString(), fromTokenPrice.decimals)
+ console.info('findOptimalFromAmount', { params, targetToAmount, toTokenPrice, targetUsd })
+
+ // Dynamic tolerances based on USD amount
+ // This is needed because for different quantities the slippage is different
+ // for example 0.4% of 10 USD is different than 0.4% of 1000 USD
+ let maxOverage: number
+ let rangeMultiplier: { low: number; high: number }
+ if (targetUsd < 0.3) {
+ // Really small amounts, mainly used for testing
+ maxOverage = 0.05 // 5%
+ rangeMultiplier = { low: 0.945, high: 1.15 }
+ } else if (targetUsd < 1) {
+ maxOverage = 0.01 // 1%
+ rangeMultiplier = { low: 0.985, high: 1.03 }
+ } else if (targetUsd < 10) {
+ maxOverage = 0.005 // 0.5%
+ rangeMultiplier = { low: 0.9925, high: 1.015 }
+ } else if (targetUsd < 1000) {
+ maxOverage = 0.003 // 0.3%
+ rangeMultiplier = { low: 0.995, high: 1.009 }
+ } else {
+ maxOverage = 0.001 // 0.1%
+ rangeMultiplier = { low: 0.995, high: 1.01 }
+ }
+
+ let lowBound = (initialFromAmount * BigInt(Math.floor(rangeMultiplier.low * 10000))) / 10000n
+ let highBound = (initialFromAmount * BigInt(Math.floor(rangeMultiplier.high * 10000))) / 10000n
+
+ let bestResult: { response: SquidRouteResponse; overage: number } | null = null
+ let iterations = 0
+ const maxIterations = 3 // Avoid too many calls to squid API!
+
+ // Binary search to find the optimal fromAmount
+ while (iterations < maxIterations && highBound > lowBound) {
+ const midPoint = (lowBound + highBound) / 2n
+ const testParams = { ...params, fromAmount: midPoint.toString() }
+
+ try {
+ const response = await getSquidRouteRaw(testParams)
+ const receivedAmount = BigInt(response.route.estimate.toAmountMin)
+ console.log('fromAmount', midPoint)
+ console.log('receivedAmount', receivedAmount)
+ console.log('targetToAmount', targetToAmount)
+ if (receivedAmount >= targetToAmount) {
+ iterations++
+ const diff = receivedAmount - targetToAmount
+ const target = targetToAmount
+
+ let overage: number
+ if (diff <= Number.MAX_SAFE_INTEGER && target <= Number.MAX_SAFE_INTEGER) {
+ overage = Number(diff) / Number(target)
+ } else {
+ // Handle very large numbers with careful scaling
+ overage = Number(diff / (target / 1_000_000_000_000_000_000n)) / 1e18
+ }
+
+ if (overage <= maxOverage) {
+ return response
+ }
+
+ bestResult = { response, overage }
+ highBound = midPoint - 1n
+ } else {
+ lowBound = midPoint + 1n
+ }
+ } catch (error) {
+ console.warn('Error fetching route:', error)
+ lowBound = midPoint + 1n
+ iterations++
+ }
+ }
+
+ // Return best result found, or make one final call with high bound
+ if (bestResult) {
+ return bestResult.response
+ }
+
+ // Fallback call
+ return await getSquidRouteRaw({ ...params, fromAmount: highBound.toString() })
+}
+
+export type PeanutCrossChainRoute = {
+ expiry: string
+ type: 'swap' | 'rfq'
+ fromAmount: string
+ transactions: {
+ to: Address
+ data: Hex
+ value: string
+ }[]
+ feeCostsUsd: number
+ rawResponse: SquidRouteResponse
+}
+
+/**
+ * Get the route for a given amount of tokens from one chain to another.
+ *
+ * Accepts any specified amount either in tokens or USD, specifying send or receive amount.
+ *
+ * Returns the route with the less slippage..
+ */
+export async function getRoute({ from, to, ...amount }: RouteParams): Promise {
+ let fromAmount: string
+ let response: SquidRouteResponse
+
+ console.info('getRoute', { from, to }, amount)
+
+ if (amount.fromAmount) {
+ fromAmount = amount.fromAmount.toString()
+ response = await getSquidRouteRaw({
+ fromChain: from.chainId,
+ fromToken: from.tokenAddress,
+ fromAmount: fromAmount,
+ fromAddress: from.address,
+ toAddress: to.address,
+ toChain: to.chainId,
+ toToken: to.tokenAddress,
+ })
+ } else if (amount.fromUsd) {
+ // Convert USD to token amount
+ const fromTokenPrice = await fetchTokenPrice(from.tokenAddress, from.chainId)
+ if (!fromTokenPrice) throw new Error('Could not fetch from token price')
+
+ const tokenAmount = Number(amount.fromUsd) / fromTokenPrice.price
+ fromAmount = parseUnits(tokenAmount.toFixed(fromTokenPrice.decimals), fromTokenPrice.decimals).toString()
+
+ response = await getSquidRouteRaw({
+ fromChain: from.chainId,
+ fromToken: from.tokenAddress,
+ fromAmount,
+ fromAddress: from.address,
+ toAddress: to.address,
+ toChain: to.chainId,
+ toToken: to.tokenAddress,
+ })
+ } else if (amount.toAmount) {
+ // Use binary search to find optimal fromAmount
+ response = await findOptimalFromAmount(
+ {
+ fromChain: from.chainId,
+ fromToken: from.tokenAddress,
+ fromAddress: from.address,
+ toAddress: to.address,
+ toChain: to.chainId,
+ toToken: to.tokenAddress,
+ },
+ amount.toAmount
+ )
+ } else if (amount.toUsd) {
+ // Convert target USD to token amount, then use binary search
+ const toTokenPrice = await fetchTokenPrice(to.tokenAddress, to.chainId)
+ if (!toTokenPrice) throw new Error('Could not fetch to token price')
+
+ const targetToAmount = parseUnits(
+ (parseFloat(amount.toUsd) / toTokenPrice.price).toFixed(toTokenPrice.decimals),
+ toTokenPrice.decimals
+ )
+
+ response = await findOptimalFromAmount(
+ {
+ fromChain: from.chainId,
+ fromToken: from.tokenAddress,
+ fromAddress: from.address,
+ toAddress: to.address,
+ toChain: to.chainId,
+ toToken: to.tokenAddress,
+ },
+ targetToAmount,
+ toTokenPrice // Pass the already-fetched price
+ )
+ } else {
+ throw new Error('No amount specified')
+ }
+
+ const route = response.route
+
+ let feeCostsUsd = [...route.estimate.feeCosts, ...route.estimate.gasCosts].reduce(
+ (sum, cost) => sum + Number(cost.amountUsd),
+ 0
+ )
+
+ const transactions: {
+ to: Address
+ data: Hex
+ value: string
+ }[] = []
+
+ // Check if approval is needed for non-native tokens
+ if (!isNativeCurrency(from.tokenAddress)) {
+ const fromAmount = BigInt(route.estimate.fromAmount)
+ const spenderAddress = route.transactionRequest.target
+
+ try {
+ let currentAllowance = await checkTokenAllowance(
+ from.tokenAddress,
+ from.address,
+ spenderAddress,
+ from.chainId
+ )
+
+ const isUsdtInMainnet = from.chainId === '1' && areEvmAddressesEqual(from.tokenAddress, USDT_IN_MAINNET)
+ // USDT in mainnet is not an erc20 token and needs to have the
+ // allowance reseted to 0 before using it.
+ if (isUsdtInMainnet && currentAllowance > 0n) {
+ transactions.push(createApproveTransaction(from.tokenAddress, spenderAddress, 0n))
+ currentAllowance = 0n
+ }
+
+ // If current allowance is insufficient, create approve transaction
+ if (currentAllowance < fromAmount) {
+ // Add approval transaction to the transactions array
+ transactions.push(createApproveTransaction(from.tokenAddress, spenderAddress, fromAmount))
+
+ // Add approval cost to fee costs
+ const approvalCostUsd = await estimateApprovalCostUsd(
+ from.tokenAddress,
+ spenderAddress,
+ fromAmount,
+ from.address,
+ from.chainId
+ )
+ feeCostsUsd += approvalCostUsd
+ }
+ } catch (error) {
+ console.error('Error checking allowance:', error)
+ // Continue without approval transaction if there's an error
+ }
+ }
+
+ // Add the main swap transaction
+ transactions.push({
+ to: route.transactionRequest.target,
+ data: route.transactionRequest.data,
+ value: route.transactionRequest.value,
+ })
+
+ const xChainRoute = {
+ expiry: route.transactionRequest.expiry,
+ type: route.estimate.actions[0].type,
+ fromAmount: route.estimate.fromAmount,
+ transactions,
+ feeCostsUsd,
+ rawResponse: response,
+ }
+
+ console.info('xChainRoute', xChainRoute)
+ console.info('xChainRoute created with expiry:', route.transactionRequest.expiry)
+
+ return xChainRoute
+}
diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts
index 86e117aca..3732893bf 100644
--- a/src/utils/__tests__/bridge.utils.test.ts
+++ b/src/utils/__tests__/bridge.utils.test.ts
@@ -4,7 +4,6 @@ import {
getOfframpCurrencyConfig,
getCurrencySymbol,
getPaymentRailDisplayName,
- type BridgeOperationType,
getMinimumAmount,
} from '../bridge.utils'
@@ -20,7 +19,7 @@ describe('bridge.utils', () => {
const offrampConfig = getCurrencyConfig('US', 'offramp')
expect(offrampConfig).toEqual({
currency: 'usd',
- paymentRail: 'ach_pull',
+ paymentRail: 'ach',
})
})
@@ -96,7 +95,7 @@ describe('bridge.utils', () => {
const config = getOfframpCurrencyConfig('US')
expect(config).toEqual({
currency: 'usd',
- paymentRail: 'ach_pull',
+ paymentRail: 'ach',
})
})
@@ -176,7 +175,7 @@ describe('bridge.utils', () => {
describe('getPaymentRailDisplayName', () => {
it('should return correct display names for supported payment rails', () => {
expect(getPaymentRailDisplayName('ach_push')).toBe('ACH Transfer')
- expect(getPaymentRailDisplayName('ach_pull')).toBe('ACH Transfer')
+ expect(getPaymentRailDisplayName('ach')).toBe('ACH Transfer')
expect(getPaymentRailDisplayName('sepa')).toBe('SEPA Transfer')
expect(getPaymentRailDisplayName('spei')).toBe('SPEI Transfer')
expect(getPaymentRailDisplayName('wire')).toBe('Wire Transfer')
@@ -198,7 +197,7 @@ describe('bridge.utils', () => {
const offrampConfig = getCurrencyConfig('US', 'offramp')
expect(onrampConfig.paymentRail).toBe('ach_push')
- expect(offrampConfig.paymentRail).toBe('ach_pull')
+ expect(offrampConfig.paymentRail).toBe('ach')
expect(onrampConfig.currency).toBe(offrampConfig.currency)
})
diff --git a/src/utils/balance.utils.ts b/src/utils/balance.utils.ts
index 227e2953d..1b644c002 100644
--- a/src/utils/balance.utils.ts
+++ b/src/utils/balance.utils.ts
@@ -1,88 +1,7 @@
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
import { ChainValue, IUserBalance } from '@/interfaces'
-import { areEvmAddressesEqual, fetchWithSentry, isAddressZero } from '@/utils'
import * as Sentry from '@sentry/nextjs'
import { formatUnits } from 'viem'
-import { NATIVE_TOKEN_ADDRESS } from './token.utils'
-
-export async function fetchWalletBalances(
- address: string
-): Promise<{ balances: IUserBalance[]; totalBalance: number }> {
- try {
- const apiResponse = await fetchWithSentry('/api/walletconnect/fetch-wallet-balance', {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({ address }),
- })
-
- if (!apiResponse.ok) {
- throw new Error('API request failed')
- }
-
- const apiResponseJson = await apiResponse.json()
-
- const processedBalances = apiResponseJson.balances
- .filter((balance: any) => balance.value > 0.009)
- .map((item: any) => ({
- chainId: item?.chainId ? item.chainId.split(':')[1] : '1',
- address: item?.address ? item.address.split(':')[2] : NATIVE_TOKEN_ADDRESS,
- name: item.name,
- symbol: item.symbol,
- decimals: parseInt(item.quantity.decimals),
- price: item.price,
- amount: parseFloat(item.quantity.numeric),
- currency: 'usd',
- logoURI: item.iconUrl,
- value: item.value.toString(),
- }))
- .map((balance: any) =>
- balance.chainId === '8508132'
- ? { ...balance, chainId: '534352' }
- : balance.chainId === '81032'
- ? { ...balance, chainId: '81457' }
- : balance.chainId === '59160'
- ? { ...balance, chainId: '59144' }
- : balance
- )
- .sort((a: any, b: any) => {
- const valueA = parseFloat(a.value)
- const valueB = parseFloat(b.value)
-
- if (valueA === valueB) {
- if (isAddressZero(a.address)) return -1
- if (isAddressZero(b.address)) return 1
- return b.amount - a.amount
- }
- return valueB - valueA
- })
-
- const totalBalance = processedBalances.reduce((acc: number, balance: any) => acc + Number(balance.value), 0)
-
- return {
- balances: processedBalances,
- totalBalance,
- }
- } catch (error) {
- console.error('Error fetching wallet balances:', error)
- if (error instanceof Error && error.message !== 'API request failed') {
- Sentry.captureException(error)
- }
- return { balances: [], totalBalance: 0 }
- }
-}
-
-export function balanceByToken(
- balances: IUserBalance[],
- chainId: string,
- tokenAddress: string
-): IUserBalance | undefined {
- if (!chainId || !tokenAddress) return undefined
- return balances.find(
- (balance) => balance.chainId === chainId && areEvmAddressesEqual(balance.address, tokenAddress)
- )
-}
export function calculateValuePerChain(balances: IUserBalance[]): ChainValue[] {
let result: ChainValue[] = []
diff --git a/src/utils/bridge.utils.ts b/src/utils/bridge.utils.ts
index 14162593c..0dc594247 100644
--- a/src/utils/bridge.utils.ts
+++ b/src/utils/bridge.utils.ts
@@ -77,7 +77,7 @@ export const getMinimumAmount = (countryId: string): number => {
export const getPaymentRailDisplayName = (paymentRail: string): string => {
const displayNames: Record = {
ach_push: 'ACH Transfer',
- ach_pull: 'ACH Transfer',
+ ach: 'ACH Transfer',
sepa: 'SEPA Transfer',
spei: 'SPEI Transfer',
wire: 'Wire Transfer',
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index 27d377d66..917b9d901 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -7,6 +7,7 @@ import {
PINTA_WALLET_TOKEN_NAME,
PINTA_WALLET_TOKEN_SYMBOL,
STABLE_COINS,
+ USER_OPERATION_REVERT_REASON_TOPIC,
} from '@/constants'
import * as interfaces from '@/interfaces'
import { AccountType } from '@/interfaces'
@@ -16,6 +17,7 @@ import { SiweMessage } from 'siwe'
import type { Address, TransactionReceipt } from 'viem'
import { getAddress, isAddress } from 'viem'
import * as wagmiChains from 'wagmi/chains'
+import { getSDKProvider } from './sdk.utils'
import { NATIVE_TOKEN_ADDRESS, SQUID_ETH_ADDRESS } from './token.utils'
export function urlBase64ToUint8Array(base64String: string) {
@@ -92,12 +94,17 @@ export function jsonParse(data: string): T {
})
}
-export const saveToLocalStorage = (key: string, data: any) => {
+export const saveToLocalStorage = (key: string, data: any, expirySeconds?: number) => {
if (typeof localStorage === 'undefined') return
try {
// Convert the data to a string before storing it in localStorage
const serializedData = jsonStringify(data)
localStorage.setItem(key, serializedData)
+ if (expirySeconds) {
+ localStorage.setItem(`${key}-expiry`, (new Date().getTime() + expirySeconds * 1000).toString())
+ } else {
+ localStorage.removeItem(`${key}-expiry`)
+ }
console.log(`Saved ${key} to localStorage:`, data)
} catch (error) {
Sentry.captureException(error)
@@ -108,6 +115,13 @@ export const saveToLocalStorage = (key: string, data: any) => {
export const getFromLocalStorage = (key: string) => {
if (typeof localStorage === 'undefined') return
try {
+ const expiry = localStorage.getItem(`${key}-expiry`)
+ if (expiry) {
+ const expiryTimestamp = Number(expiry)
+ if (expiryTimestamp < new Date().getTime()) {
+ return null
+ }
+ }
const data = localStorage.getItem(key)
if (data === null) {
console.log(`No data found in localStorage for ${key}`)
@@ -956,9 +970,14 @@ export async function fetchTokenSymbol(tokenAddress: string, chainId: string): P
let tokenSymbol = getTokenSymbol(tokenAddress, chainId)
if (!tokenSymbol) {
try {
+ const provider = await getSDKProvider({ chainId })
+ if (!provider) {
+ console.error(`Failed to get provider for chain ID ${chainId}`)
+ return undefined
+ }
const contract = await peanut.getTokenContractDetails({
address: tokenAddress,
- provider: await peanut.getDefaultProvider(chainId),
+ provider: provider,
})
tokenSymbol = contract?.symbol?.toUpperCase()
} catch (error) {
@@ -1182,3 +1201,8 @@ export function isAndroid(): boolean {
const userAgent = window.navigator.userAgent.toLowerCase()
return /android/.test(userAgent)
}
+
+export function isTxReverted(receipt: TransactionReceipt): boolean {
+ if (receipt.status === 'reverted') return true
+ return receipt.logs.some((log) => log.topics[0] === USER_OPERATION_REVERT_REASON_TOPIC)
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 990765362..6a5ca5b0c 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -3,6 +3,7 @@ export * from './sdkErrorHandler.utils'
export * from './bridge-accounts.utils'
export * from './balance.utils'
export * from './sentry.utils'
+export * from './token.utils'
export * from './onramp.utils'
// Bridge utils - explicit exports to avoid naming conflicts
diff --git a/src/utils/sdk.utils.ts b/src/utils/sdk.utils.ts
new file mode 100644
index 000000000..76d6c067b
--- /dev/null
+++ b/src/utils/sdk.utils.ts
@@ -0,0 +1,59 @@
+import peanut from '@squirrel-labs/peanut-sdk'
+import { rpcUrls } from '@/constants/general.consts'
+import { providers } from 'ethers'
+import * as Sentry from '@sentry/nextjs'
+
+const providerCache = new Map()
+
+/**
+ * Gets a provider for a given chain. It uses a cached provider if available.
+ * If not, it creates a new FallbackProvider with multiple RPC URLs for resiliency,
+ * or falls back to the default provider.
+ * @param chainId - The ID of the chain to get a provider for.
+ * @returns A provider for the given chain, or undefined if no provider could be created.
+ */
+export async function getSDKProvider({ chainId }: { chainId: string }): Promise {
+ if (providerCache.has(chainId)) {
+ return providerCache.get(chainId)
+ }
+
+ const urls = rpcUrls[Number(chainId)]
+
+ // f we have specific RPC URLs, use them with a FallbackProvider for resiliency.
+ if (urls && urls.length > 0) {
+ try {
+ const providerConfigs: providers.FallbackProviderConfig[] = urls.map((url, index) => ({
+ provider: new providers.JsonRpcProvider(url),
+ priority: index,
+ stallTimeout: 2000, // a request that has not returned in 2s is considered stalled
+ }))
+
+ const fallbackProvider = new providers.FallbackProvider(providerConfigs, 1) // Quorum of 1, we only need one to work.
+
+ await fallbackProvider.getNetwork() // this checks if at least one provider is responsive.
+
+ providerCache.set(chainId, fallbackProvider)
+ return fallbackProvider
+ } catch (error) {
+ Sentry.captureException(error)
+ console.warn(
+ `FallbackProvider creation failed for chain ID ${chainId}, falling back to default. Error:`,
+ error
+ )
+ }
+ }
+
+ // fallback to the default provider from the SDK if no URLs are specified or if FallbackProvider fails.
+ try {
+ const provider = await peanut.getDefaultProvider(chainId)
+ providerCache.set(chainId, provider)
+ return provider
+ } catch (error) {
+ Sentry.captureException(error)
+ console.error(`Failed to get default provider for chain ID ${chainId}`, error)
+ }
+
+ Sentry.captureException(new Error(`Failed to create any provider for chain ID ${chainId}`))
+ console.error(`Failed to create a provider for chain ID ${chainId}`)
+ return undefined
+}
diff --git a/tailwind.config.js b/tailwind.config.js
index 95d5b0125..703f8cc2e 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -173,11 +173,17 @@ module.exports = {
'50%': { transform: 'scale(1.05)', opacity: '1' },
'100%': { transform: 'scale(0.95)', opacity: '0.8' },
},
+ 'pulse-strong': {
+ '0%': { opacity: '1' },
+ '50%': { opacity: '0.3' },
+ '100%': { opacity: '1' },
+ },
},
animation: {
colorPulse: 'colorPulse 2.5s cubic-bezier(0.4, 0, 0.6, 1) infinite',
fadeIn: 'fadeIn 0.3s ease-in-out',
pulsate: 'pulsate 1.5s ease-in-out infinite',
+ 'pulse-strong': 'pulse-strong 1s ease-in-out infinite',
},
opacity: {
85: '.85',