diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 55c340738..20ba9974c 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -7,20 +7,13 @@ import ErrorAlert from '@/components/Global/ErrorAlert' import NavHeader from '@/components/Global/NavHeader' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' -import { - PEANUT_WALLET_CHAIN, - PEANUT_WALLET_TOKEN, - PEANUT_WALLET_TOKEN_DECIMALS, - PEANUT_WALLET_TOKEN_SYMBOL, -} from '@/constants' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' import { useWithdrawFlow } from '@/context/WithdrawFlowContext' import { useWallet } from '@/hooks/wallet/useWallet' import { AccountType, Account } from '@/interfaces' import { shortenAddressLong } from '@/utils/general.utils' -import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' -import { encodeFunctionData, erc20Abi, parseUnits } from 'viem' import DirectSuccessView from '@/components/Payment/Views/Status.payment.view' import { ErrorHandler, getBridgeChainName } from '@/utils' import { getOfframpCurrencyConfig } from '@/utils/bridge.utils' @@ -33,7 +26,7 @@ type View = 'INITIAL' | 'SUCCESS' export default function WithdrawBankPage() { const { amountToWithdraw, selectedBankAccount: bankAccount, error, setError } = useWithdrawFlow() const { user, fetchUser } = useAuth() - const { address, sendTransactions } = useWallet() + const { address, sendMoney } = useWallet() const router = useRouter() const [isLoading, setIsLoading] = useState(false) const [view, setView] = useState('INITIAL') @@ -136,20 +129,7 @@ export default function WithdrawBankPage() { } // Step 2: prepare and send the transaction from peanut wallet to the deposit address - const amountToSend = parseUnits(createPayload.amount, PEANUT_WALLET_TOKEN_DECIMALS) - - const txData = encodeFunctionData({ - abi: erc20Abi, - functionName: 'transfer', - args: [data.depositInstructions.toAddress as `0x${string}`, amountToSend], - }) - - const transaction: peanutInterfaces.IPeanutUnsignedTransaction = { - to: PEANUT_WALLET_TOKEN, - data: txData, - } - - const receipt = await sendTransactions([transaction], PEANUT_WALLET_CHAIN.id.toString()) + const receipt = await sendMoney(data.depositInstructions.toAddress as `0x${string}`, createPayload.amount) if (receipt.status === 'reverted') { throw new Error('Transaction reverted by the network.') diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 84f7136a2..2b18066d6 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -8,6 +8,7 @@ import ConfirmWithdrawView from '@/components/Withdraw/views/Confirm.withdraw.vi import InitialWithdrawView from '@/components/Withdraw/views/Initial.withdraw.view' import { useWithdrawFlow, WithdrawData } from '@/context/WithdrawFlowContext' import { InitiatePaymentPayload, usePaymentInitiator } from '@/hooks/usePaymentInitiator' +import { useWallet } from '@/hooks/wallet/useWallet' import { useAppDispatch, usePaymentStore } from '@/redux/hooks' import { paymentActions } from '@/redux/slices/payment-slice' import { chargesApi } from '@/services/charges' @@ -21,13 +22,15 @@ import { } from '@/services/services.types' import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' import { interfaces as peanutInterfaces } from '@squirrel-labs/peanut-sdk' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' import { useRouter } from 'next/navigation' -import { useCallback, useEffect } from 'react' +import { useCallback, useEffect, useMemo } from 'react' export default function WithdrawCryptoPage() { const router = useRouter() const dispatch = useAppDispatch() const { chargeDetails: activeChargeDetailsFromStore } = usePaymentStore() + const { isConnected: isPeanutWallet } = useWallet() const { amountToWithdraw, setAmountToWithdraw, @@ -47,10 +50,25 @@ export default function WithdrawCryptoPage() { initiatePayment, isProcessing, error: paymentErrorFromHook, - feeCalculations, prepareTransactionDetails, + xChainRoute, + isCalculatingFees, + isPreparingTx, } = usePaymentInitiator() + // Helper to manage errors consistently + const setError = useCallback( + (error: string | null) => { + setPaymentError(error) + dispatch(paymentActions.setError(error)) + }, + [setPaymentError, dispatch] + ) + + const clearErrors = useCallback(() => { + setError(null) + }, [setError]) + useEffect(() => { if (!amountToWithdraw) { console.error('Amount not available in WithdrawFlowContext for withdrawal, redirecting.') @@ -58,7 +76,7 @@ export default function WithdrawCryptoPage() { return } dispatch(paymentActions.setChargeDetails(null)) - setPaymentError(null) + clearErrors() setCurrentView('INITIAL') }, [amountToWithdraw, router, dispatch, setAmountToWithdraw, setCurrentView]) @@ -68,26 +86,31 @@ export default function WithdrawCryptoPage() { useEffect(() => { if (currentView === 'CONFIRM' && activeChargeDetailsFromStore && withdrawData) { - prepareTransactionDetails(activeChargeDetailsFromStore, false) + console.log('Preparing withdraw transaction details...') + console.dir(activeChargeDetailsFromStore) + prepareTransactionDetails( + activeChargeDetailsFromStore, + PEANUT_WALLET_TOKEN, + PEANUT_WALLET_CHAIN.id.toString(), + amountToWithdraw + ) } - }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails]) + }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails, amountToWithdraw]) const handleSetupReview = useCallback( async (data: Omit) => { if (!amountToWithdraw) { console.error('Amount to withdraw is not set or not available from context') - setPaymentError('Withdrawal amount is missing.') + setError('Withdrawal amount is missing.') return } - const completeWithdrawData = { ...data, amount: amountToWithdraw } - setWithdrawData(completeWithdrawData) - setIsPreparingReview(true) - setPaymentError(null) - dispatch(paymentActions.setError(null)) + clearErrors() dispatch(paymentActions.setChargeDetails(null)) try { + const completeWithdrawData = { ...data, amount: amountToWithdraw } + setWithdrawData(completeWithdrawData) const apiRequestPayload: CreateRequestPayloadServices = { recipientAddress: completeWithdrawData.address, chainId: completeWithdrawData.chain.chainId.toString(), @@ -97,9 +120,9 @@ export default function WithdrawCryptoPage() { ? peanutInterfaces.EPeanutLinkType.native : peanutInterfaces.EPeanutLinkType.erc20 ), + tokenAmount: amountToWithdraw, + tokenDecimals: completeWithdrawData.token.decimals.toString(), tokenSymbol: completeWithdrawData.token.symbol, - tokenDecimals: String(completeWithdrawData.token.decimals), - tokenAmount: completeWithdrawData.amount, } const newRequest: TRequestResponse = await requestsApi.create(apiRequestPayload) @@ -109,7 +132,7 @@ export default function WithdrawCryptoPage() { const chargePayload: CreateChargeRequest = { pricing_type: 'fixed_price', - local_price: { amount: completeWithdrawData.amount, currency: 'USD' }, + local_price: { amount: completeWithdrawData.amount || amountToWithdraw, currency: 'USD' }, baseUrl: window.location.origin, requestId: newRequest.uuid, requestProps: { @@ -139,8 +162,7 @@ export default function WithdrawCryptoPage() { } catch (err: any) { console.error('Error during setup review (request/charge creation):', err) const errorMessage = err.message || 'Could not prepare withdrawal. Please try again.' - setPaymentError(errorMessage) - dispatch(paymentActions.setError(errorMessage)) + setError(errorMessage) } finally { setIsPreparingReview(false) } @@ -154,18 +176,18 @@ export default function WithdrawCryptoPage() { setCurrentView('CONFIRM') } else { console.error('Proceeding to confirm, but charge details or withdraw data are missing.') - setPaymentError('Failed to load withdrawal details for confirmation. Please go back and try again.') + setError('Failed to load withdrawal details for confirmation. Please go back and try again.') } }, [activeChargeDetailsFromStore, withdrawData, setCurrentView]) const handleConfirmWithdrawal = useCallback(async () => { if (!activeChargeDetailsFromStore || !withdrawData || !amountToWithdraw) { console.error('Withdraw data, active charge details, or amount missing for final confirmation') - setPaymentError('Essential withdrawal information is missing.') + setError('Essential withdrawal information is missing.') return } - setPaymentError(null) + clearErrors() dispatch(paymentActions.setError(null)) const paymentPayload: InitiatePaymentPayload = { @@ -191,8 +213,7 @@ export default function WithdrawCryptoPage() { setCurrentView('INITIAL') // clear any errors - setPaymentError(null) - dispatch(paymentActions.setError(null)) + clearErrors() // clear charge details dispatch(paymentActions.setChargeDetails(null)) @@ -200,8 +221,7 @@ export default function WithdrawCryptoPage() { } else { console.error('Withdrawal execution failed:', result.error) const errMsg = result.error || 'Withdrawal processing failed.' - setPaymentError(errMsg) - dispatch(paymentActions.setError(errMsg)) + setError(errMsg) } }, [ activeChargeDetailsFromStore, @@ -215,15 +235,66 @@ export default function WithdrawCryptoPage() { setPaymentError, ]) + const handleRouteRefresh = useCallback(async () => { + if (!activeChargeDetailsFromStore) return + console.log('Refreshing withdraw route due to expiry...') + console.log('About to call prepareTransactionDetails with:', activeChargeDetailsFromStore) + await prepareTransactionDetails( + activeChargeDetailsFromStore, + PEANUT_WALLET_TOKEN, + PEANUT_WALLET_CHAIN.id.toString(), + amountToWithdraw + ) + }, [activeChargeDetailsFromStore, prepareTransactionDetails, amountToWithdraw]) + const handleBackFromConfirm = useCallback(() => { setCurrentView('INITIAL') - setPaymentError(null) + clearErrors() dispatch(paymentActions.setError(null)) dispatch(paymentActions.setChargeDetails(null)) }, [dispatch, setCurrentView]) - const displayError = paymentError - const confirmButtonDisabled = !activeChargeDetailsFromStore || isProcessing + // Check if this is a cross-chain withdrawal (align with usePaymentInitiator logic) + const isCrossChainWithdrawal = useMemo(() => { + if (!withdrawData || !activeChargeDetailsFromStore) return false + + // In withdraw flow, we're moving from Peanut Wallet to the selected chain + // This matches the logic in usePaymentInitiator for withdraw flows + const fromChainId = isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : withdrawData.chain.chainId + const toChainId = activeChargeDetailsFromStore.chainId + + console.log('Cross-chain check:', { + fromChainId, + toChainId, + isPeanutWallet, + isCrossChain: fromChainId !== toChainId, + }) + + return fromChainId !== toChainId + }, [withdrawData, activeChargeDetailsFromStore, isPeanutWallet]) + + // Check for route type errors (similar to payment flow) + const routeTypeError = useMemo(() => { + if (!isCrossChainWithdrawal || !xChainRoute || !isPeanutWallet) return null + + // For peanut wallet flows, only RFQ routes are allowed + if (xChainRoute.type === 'swap') { + return 'This token pair is not available for withdraw.' + } + + return null + }, [isCrossChainWithdrawal, xChainRoute, isPeanutWallet]) + + // Display payment errors first (user actions), then route errors (system limitations) + const displayError = paymentError ?? routeTypeError + + // Get network fee from route or fallback + const networkFee = useCallback(() => { + if (xChainRoute?.feeCostsUsd) { + return xChainRoute.feeCostsUsd < 0.01 ? '$ <0.01' : `$ ${xChainRoute.feeCostsUsd.toFixed(2)}` + } + return '$ 0.00' + }, [xChainRoute]) if (!amountToWithdraw) { return @@ -248,9 +319,15 @@ export default function WithdrawCryptoPage() { toAddress={withdrawData.address} onConfirm={handleConfirmWithdrawal} onBack={handleBackFromConfirm} - isProcessing={confirmButtonDisabled} + isProcessing={isProcessing} error={displayError} - networkFee={feeCalculations?.estimatedFee} + networkFee={networkFee()} + // Timer props for cross-chain withdrawals + isCrossChain={isCrossChainWithdrawal} + routeExpiry={xChainRoute?.expiry} + isRouteLoading={isCalculatingFees || isPreparingTx} + onRouteRefresh={handleRouteRefresh} + xChainRoute={xChainRoute ?? undefined} /> )} diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index dbba91a71..361a456b1 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -391,7 +391,7 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props) )} {currentView === 'STATUS' && ( diff --git a/src/app/actions/clients.ts b/src/app/actions/clients.ts index fe4c0c069..dc7229880 100644 --- a/src/app/actions/clients.ts +++ b/src/app/actions/clients.ts @@ -17,22 +17,16 @@ export type FeeOptions = { error?: string | null } -export const getPublicClient = unstable_cache( - async (chainId: ChainId): Promise => { - let client: PublicClient | undefined = PUBLIC_CLIENTS_BY_CHAIN[chainId]?.client - if (client) return client - const chain: Chain = extractChain({ chains: allChains, id: chainId }) - if (!chain) throw new Error(`No chain found for chainId ${chainId}`) - return createPublicClient({ - transport: http(infuraRpcUrls[chainId]), - chain, - }) - }, - ['getPublicClient'], - { - tags: ['getPublicClient'], - } -) +export const getPublicClient = async (chainId: ChainId): Promise => { + let client: PublicClient | undefined = PUBLIC_CLIENTS_BY_CHAIN[chainId]?.client + if (client) return client + const chain: Chain = extractChain({ chains: allChains, id: chainId }) + if (!chain) throw new Error(`No chain found for chainId ${chainId}`) + return createPublicClient({ + transport: http(infuraRpcUrls[chainId]), + chain, + }) +} export type PreparedTx = { account: Hash diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts index 0d760e855..f0d6ad166 100644 --- a/src/app/actions/tokens.ts +++ b/src/app/actions/tokens.ts @@ -2,10 +2,11 @@ import { unstable_cache } from 'next/cache' import { fetchWithSentry, isAddressZero, estimateIfIsStableCoinFromPrice } from '@/utils' import { type ITokenPriceData } from '@/interfaces' -import type { Address } from 'viem' import { parseAbi } from 'viem' import { type ChainId, getPublicClient } from '@/app/actions/clients' -import { getTokenDetails } from '@/utils' +import { getTokenDetails, NATIVE_TOKEN_ADDRESS, isStableCoin } from '@/utils' +import { formatUnits } from 'viem' +import type { Address, Hex } from 'viem' type IMobulaMarketData = { id: number @@ -76,10 +77,7 @@ export const fetchTokenPrice = unstable_cache( const json: { data: IMobulaMarketData } = await mobulaResponse.json() if (mobulaResponse.ok) { - let decimals = json.data.decimals - if (decimals === undefined) { - decimals = json.data.contracts.find((contract) => contract.blockchainId === chainId)!.decimals - } + const decimals = json.data.contracts.find((contract) => contract.blockchainId === chainId)!.decimals let data = { price: json.data.price, chainId: chainId, @@ -89,7 +87,7 @@ export const fetchTokenPrice = unstable_cache( decimals, logoURI: json.data.logo, } - if (estimateIfIsStableCoinFromPrice(json.data.price)) { + if (isStableCoin(data.symbol) || estimateIfIsStableCoinFromPrice(json.data.price)) { data.price = 1 } return data @@ -146,3 +144,73 @@ export const fetchTokenDetails = unstable_cache( tags: ['fetchTokenDetails'], } ) + +/** + * Get cached gas price for a chain + */ +const getCachedGasPrice = unstable_cache( + async (chainId: string) => { + const client = await getPublicClient(Number(chainId) as ChainId) + const gasPrice = await client.getGasPrice() + return gasPrice.toString() + }, + ['getGasPrice'], + { + revalidate: 2 * 60, // 2 minutes + } +) + +/** + * Get cached gas estimate for a transaction + */ +const getCachedGasEstimate = unstable_cache( + async (fromAddress: Address, contractAddress: Address, data: Hex, chainId: string) => { + const client = await getPublicClient(Number(chainId) as ChainId) + const gasEstimate = await client.estimateGas({ + account: fromAddress, + to: contractAddress, + data, + }) + return gasEstimate.toString() + }, + ['getGasEstimate'], + { + revalidate: 5 * 60, // 5 minutes - gas estimates are more stable + } +) + +/** + * Estimate gas cost for transaction in USD + */ +export async function estimateTransactionCostUsd( + fromAddress: Address, + contractAddress: Address, + data: Hex, + chainId: string +): Promise { + try { + // Run all API calls in parallel since they're independent + const [gasEstimateStr, gasPriceStr, nativeTokenPrice] = await Promise.all([ + getCachedGasEstimate(fromAddress, contractAddress, data, chainId), + getCachedGasPrice(chainId), + fetchTokenPrice(NATIVE_TOKEN_ADDRESS, chainId), + ]) + + // Convert strings back to BigInt for calculation + const gasEstimate = BigInt(gasEstimateStr) + const gasPrice = BigInt(gasPriceStr) + + // Calculate gas cost in native token + const gasCostWei = gasEstimate * gasPrice + + const estimatedCostUsd = nativeTokenPrice + ? Number(formatUnits(gasCostWei, nativeTokenPrice.decimals)) * nativeTokenPrice.price + : 0.01 + + return estimatedCostUsd + } catch (error) { + console.error('Error estimating transaction cost:', error) + // Return a conservative estimate if we can't calculate exact cost + return 0.01 + } +} diff --git a/src/components/Global/DirectSendQR/index.tsx b/src/components/Global/DirectSendQR/index.tsx index a3bfcbf2f..ffbd49dba 100644 --- a/src/components/Global/DirectSendQR/index.tsx +++ b/src/components/Global/DirectSendQR/index.tsx @@ -96,15 +96,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/RouteExpiryTimer/index.tsx b/src/components/Global/RouteExpiryTimer/index.tsx new file mode 100644 index 000000000..b0ea4f5b0 --- /dev/null +++ b/src/components/Global/RouteExpiryTimer/index.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect, useCallback } from 'react' +import { twMerge } from 'tailwind-merge' + +interface RouteExpiryTimerProps { + expiry?: string // ISO string from route + isLoading?: boolean + onNearExpiry?: () => void // Called when timer gets close to expiry (e.g., 30 seconds) + onExpired?: () => void // Called when timer expires + className?: string + nearExpiryThresholdMs?: number // Default 30 seconds + 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, + nearExpiryThresholdMs = 5000, // 5 seconds + disableRefetch = false, + error = null, +}) => { + const [timeRemaining, setTimeRemaining] = useState(null) + const [hasTriggeredNearExpiry, setHasTriggeredNearExpiry] = useState(false) + const [hasExpired, setHasExpired] = useState(false) + + 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 getProgressPercentage = (): number => { + if (!timeRemaining || !expiry) return 0 + + // Assuming routes typically have 1-minute expiry (300 seconds) + // This could be made configurable if needed + const totalDurationMs = 1 * 60 * 1000 // 1 minutes + const elapsedMs = totalDurationMs - timeRemaining.totalMs + return Math.max(0, Math.min(100, (elapsedMs / totalDurationMs) * 100)) + } + + const getProgressColor = (): string => { + if (!timeRemaining) return 'bg-grey-3' + + const percentage = getProgressPercentage() + + // Green for first 70% + if (percentage < 70) return 'bg-green-500' + // Yellow for 70-85% + if (percentage < 85) return 'bg-yellow-500' + // Red for final 15% + return 'bg-red' + } + + const shouldPulse = (): boolean => { + if (isLoading) return true + if (!timeRemaining) return false + // Pulse when in red zone (85%+ progress) OR near expiry threshold + const progressPercentage = getProgressPercentage() + return (progressPercentage >= 85 || timeRemaining.totalMs <= nearExpiryThresholdMs) && timeRemaining.totalMs > 0 + } + + 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/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index 9b72ade19..4ea25192e 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() @@ -373,6 +374,7 @@ export const PaymentForm = ({ const requestedToken = chargeDetails?.tokenAddress ?? requestDetails?.tokenAddress const requestedChain = chargeDetails?.chainId ?? requestDetails?.chainId + let tokenAmount = inputTokenAmount if ( requestedToken && @@ -391,7 +393,7 @@ export const PaymentForm = ({ currency, currencyAmount, isAddMoneyFlow: !!isAddMoneyFlow, - transactionType: isAddMoneyFlow ? 'DEPOSIT' : isDirectPay ? 'DIRECT_SEND' : 'REQUEST', + transactionType: isAddMoneyFlow ? 'DEPOSIT' : isDirectUsdPayment ? 'DIRECT_SEND' : 'REQUEST', attachmentOptions: attachmentOptions, } @@ -498,15 +500,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 @@ -532,7 +525,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 @@ -547,7 +540,6 @@ export const PaymentForm = ({ selectedChainID, isConnected, isActivePeanutWallet, - isXChainPeanutWalletReq, isPintaReq, ]) @@ -598,7 +590,7 @@ export const PaymentForm = ({
- {isWagmiConnected && (!isDirectPay || isAddMoneyFlow) && ( + {isWagmiConnected && (!isDirectUsdPayment || isAddMoneyFlow) && (
diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index 0523a1ac9..9689415f5 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -21,12 +21,14 @@ 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, printableAddress, areEvmAddressesEqual } from '@/utils' import { useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo } from 'react' import { useAccount } from 'wagmi' import { PaymentInfoRow } from '../PaymentInfoRow' +import { formatUnits } from 'viem' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' type ConfirmPaymentViewProps = { isPintaReq?: boolean @@ -37,13 +39,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 +73,28 @@ export default function ConfirmPaymentView({ isPreparingTx, loadingStep, error: paymentError, - feeCalculations, + estimatedGasCost, isCalculatingFees, isEstimatingGas, isFeeEstimationError, cancelOperation: cancelPaymentOperation, + xChainRoute, } = usePaymentInitiator() const { selectedTokenData, selectedChainID } = useContext(tokenSelectorContext) - const { isConnected: isPeanutWallet, address: peanutWalletAddress, fetchBalance } = useWallet() + const { isConnected: isPeanutWallet, fetchBalance } = useWallet() const { isConnected: isWagmiConnected, address: wagmiAddress } = useAccount() const { rewardWalletBalance } = useWalletStore() const queryClient = useQueryClient() - const walletAddress = useMemo(() => peanutWalletAddress ?? wagmiAddress, [peanutWalletAddress, wagmiAddress]) + const networkFee = useMemo(() => { + if (!estimatedGasCost || isPeanutWallet) return '$ 0.00' + if (isFeeEstimationError) return '-' + if (estimatedGasCost < 0.01) { + return '$ <0.01' + } else { + return `$ ${estimatedGasCost.toFixed(2)}` + } + }, [estimatedGasCost, isPeanutWallet, isFeeEstimationError]) const { tokenIconUrl: sendingTokenIconUrl, @@ -95,11 +121,13 @@ 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 + return ( + isProcessing && + (!isPeanutWallet || !!isAddMoneyFlow) && + ['Switching Network', 'Sending Transaction', 'Confirming Transaction', 'Preparing Transaction'].includes( + loadingStep + ) + ) }, [isProcessing, isPeanutWallet, loadingStep, isAddMoneyFlow, isCalculatingFees, isEstimatingGas]) useEffect(() => { @@ -119,11 +147,29 @@ export default function ConfirmPaymentView({ } }, [chargeIdFromUrl, chargeDetails, dispatch]) - useEffect(() => { + const handleRouteRefresh = useCallback(async () => { if (chargeDetails && selectedTokenData && selectedChainID) { - prepareTransactionDetails(chargeDetails, isAddMoneyFlow) + const fromTokenAddress = isPeanutWallet ? PEANUT_WALLET_TOKEN : selectedTokenData.address + const fromChainId = isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : selectedChainID + const usdAmount = + isDirectUsdPayment && chargeDetails.currencyCode.toLowerCase() === 'usd' + ? chargeDetails.currencyAmount + : undefined + await prepareTransactionDetails(chargeDetails, fromTokenAddress, fromChainId, usdAmount) } - }, [chargeDetails, walletAddress, selectedTokenData, selectedChainID, prepareTransactionDetails, isAddMoneyFlow]) + }, [ + chargeDetails, + selectedTokenData, + selectedChainID, + prepareTransactionDetails, + isDirectUsdPayment, + isPeanutWallet, + ]) + + useEffect(() => { + // get route on mount + handleRouteRefresh() + }, [handleRouteRefresh]) const isConnected = useMemo(() => isPeanutWallet || isWagmiConnected, [isPeanutWallet, isWagmiConnected]) const isInsufficientRewardsBalance = useMemo(() => { @@ -278,11 +324,37 @@ export default function ConfirmPaymentView({ } const isCrossChainPayment = useMemo((): boolean => { - if (!chargeDetails || !selectedTokenData || !selectedChainID) return false - - return chargeDetails.chainId !== selectedChainID + if (!chargeDetails) return false + if (isPeanutWallet) { + 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') { + return 'This route requires external wallet payment. Peanut Wallet only supports RFQ (Request for Quote) routes.' + } + + return null + }, [isCrossChainPayment, xChainRoute, isPeanutWallet]) + + const minReceived = useMemo(() => { + if (!xChainRoute || !chargeDetails?.tokenDecimals) return null + return formatUnits(BigInt(xChainRoute.rawResponse.route.estimate.toAmountMin), chargeDetails.tokenDecimals) + }, [xChainRoute, chargeDetails?.tokenDecimals]) + return (
{ + console.log('Route expired') + }} + disableTimerRefetch={isProcessing} + timerError={routeTypeError} /> )} + {minReceived && ( + + )} {isCrossChainPayment && ( )} - - } - /> + {isCrossChainPayment !== isPeanutWallet && ( + + } + /> + )} {isAddMoneyFlow && } ) : (
@@ -415,6 +503,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/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index bc0df26af..73d8978c4 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -12,6 +12,9 @@ import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' import { ITokenPriceData } from '@/interfaces' import { formatAmount } from '@/utils' import { interfaces } from '@squirrel-labs/peanut-sdk' +import { type PeanutCrossChainRoute } from '@/services/swap' +import { useMemo } from 'react' +import { formatUnits } from 'viem' interface WithdrawConfirmViewProps { amount: string @@ -24,6 +27,12 @@ interface WithdrawConfirmViewProps { 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({ @@ -37,6 +46,11 @@ export default function ConfirmWithdrawView({ onBack, isProcessing, error, + isCrossChain = false, + routeExpiry, + isRouteLoading = false, + onRouteRefresh, + xChainRoute, }: WithdrawConfirmViewProps) { const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ chainId: chain.chainId, @@ -44,6 +58,11 @@ export default function ConfirmWithdrawView({ tokenSymbol: token.symbol, }) + const minReceived = useMemo(() => { + if (!xChainRoute) return null + return formatUnits(BigInt(xChainRoute.rawResponse.route.estimate.toAmountMin), token.decimals) + }, [xChainRoute]) + return (
@@ -55,10 +74,26 @@ export default function ConfirmWithdrawView({ recipientType="USERNAME" recipientName={''} amount={formatAmount(amount)} - tokenSymbol={token.symbol} + tokenSymbol="USDC" + showTimer={isCrossChain} + timerExpiry={routeExpiry} + isTimerLoading={isRouteLoading} + onTimerNearExpiry={onRouteRefresh} + onTimerExpired={() => { + console.log('Withdraw route expired') + }} + disableTimerRefetch={isProcessing} + timerError={error?.includes('not available for withdraw') ? error : null} /> + {minReceived && ( + + )} @@ -107,9 +142,16 @@ export default function ConfirmWithdrawView({