diff --git a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx index de5019928c..3caa0c30fb 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/__tests__/crypto-withdraw-confirm.test.tsx @@ -239,6 +239,8 @@ const mockCrossChainTransfer = { isCalculating: false, isXChain: false, isDiffToken: false, + isFeeEstimationError: false, + quoteExpiresAt: null as string | null, error: null, calculate: jest.fn(), reset: jest.fn(), @@ -274,7 +276,71 @@ const confirm = async () => { beforeEach(() => { jest.clearAllMocks() mockRecordPayment.mockResolvedValue(PAYMENT_RESULT) - Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false }) + Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false, quoteExpiresAt: null }) +}) + +describe('crypto withdraw confirm — expired Rhino quote', () => { + afterEach(() => jest.useRealTimers()) + + it('re-quotes instead of signing when the quote aged out while the screen sat open', async () => { + jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') }) + // Fresh at render: expires in 60s. + Object.assign(mockCrossChainTransfer, { + isXChain: true, + quoteExpiresAt: new Date(Date.now() + 60_000).toISOString(), + }) + render() + + const quotesBeforeTap = mockCrossChainTransfer.calculate.mock.calls.length + + // …then the user waits past it. No render happens in between. + jest.setSystemTime(Date.now() + 120_000) + fireEvent.click(screen.getByTestId('confirm-withdraw')) + + await waitFor(() => expect(mockCrossChainTransfer.calculate).toHaveBeenCalledTimes(quotesBeforeTap + 1)) + expect(mockSendTransactions).not.toHaveBeenCalled() + expect(mockSendMoney).not.toHaveBeenCalled() + expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS') + }) + + it('a tap with nothing prepared (an expiry refresh that failed) re-quotes instead of dead-ending', async () => { + Object.assign(mockCrossChainTransfer, { isXChain: true, transactions: null, quoteExpiresAt: null }) + try { + render() + const quotesBeforeTap = mockCrossChainTransfer.calculate.mock.calls.length + + fireEvent.click(screen.getByTestId('confirm-withdraw')) + + await waitFor(() => expect(mockCrossChainTransfer.calculate).toHaveBeenCalledTimes(quotesBeforeTap + 1)) + expect(mockSendTransactions).not.toHaveBeenCalled() + expect(mockSetWithdrawError).not.toHaveBeenCalledWith(expect.objectContaining({ showError: true })) + } finally { + Object.assign(mockCrossChainTransfer, { transactions: [{ to: RECIPIENT, value: 0n, data: '0x' }] }) + } + }) + + it('signs while the quote is still fresh', async () => { + jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') }) + Object.assign(mockCrossChainTransfer, { + isXChain: true, + quoteExpiresAt: new Date(Date.now() + 120_000).toISOString(), + }) + mockSendTransactions.mockResolvedValue({ + userOpHash: '0xuserop', + receipt: { transactionHash: '0xmined', status: 'success' }, + strategy: 'mixed', + intentId: 'prep-intent-9', + }) + render() + // Entering the confirm view quotes once; the tap must not quote again. + const quotesBeforeTap = mockCrossChainTransfer.calculate.mock.calls.length + + jest.setSystemTime(Date.now() + 10_000) + fireEvent.click(screen.getByTestId('confirm-withdraw')) + + await waitFor(() => expect(mockSendTransactions).toHaveBeenCalled()) + expect(mockCrossChainTransfer.calculate).toHaveBeenCalledTimes(quotesBeforeTap) + }) }) // ---------- tests ---------- diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index 2a6a644191..687e165a98 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -35,6 +35,7 @@ import { tokenSelectorContext } from '@/context/tokenSelector.context' import { useAppHaptic } from '@/hooks/useAppHaptic' import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' +import { isQuoteNearExpiry } from '@/services/rhino-bridge' import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' import { isTxReverted, printableAddress, validateEnsName } from '@/utils/general.utils' import { appBaseUrl } from '@/utils/url.utils' @@ -96,6 +97,8 @@ export default function WithdrawCryptoPage() { isXChain, isDiffToken, error: routeError, + isFeeEstimationError, + quoteExpiresAt, calculate: calculateRoute, reset: resetRouteCalculation, } = useCrossChainTransfer() @@ -164,33 +167,39 @@ export default function WithdrawCryptoPage() { } }, [routeError, recordError, setPaymentError]) + // Quote the route (Rhino preview + SDA / bridge quote, or the same-chain + // tx). Runs on entering the confirm view and again before signing when the + // quote on screen has expired. + const quoteRoute = useCallback(() => { + if (!chargeDetails || !withdrawData || !address) return Promise.resolve() + return calculateRoute({ + source: { + address: address as Address, + tokenAddress: PEANUT_WALLET_TOKEN as Address, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + // amountToWithdraw is USD-denominated; source token is USDC (1:1). + // Required for the bridge path's 'pay' mode (cross-chain ETH/etc). + tokenAmount: amountToWithdraw, + }, + destination: { + recipientAddress: chargeDetails.requestLink.recipientAddress as Address, + tokenAddress: chargeDetails.tokenAddress as Address, + tokenAmount: chargeDetails.tokenAmount, + tokenDecimals: chargeDetails.tokenDecimals, + tokenType: Number(chargeDetails.tokenType), + chainId: chargeDetails.chainId, + }, + context: 'withdraw', + contextId: chargeDetails.uuid, + senderPeanutWalletAddress: address as Address, + skipGasEstimate: true, // peanut wallet handles gas + }) + }, [chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw]) + // prepare transaction when entering confirm view useEffect(() => { - if (currentView === 'CONFIRM' && chargeDetails && withdrawData && address) { - calculateRoute({ - source: { - address: address as Address, - tokenAddress: PEANUT_WALLET_TOKEN as Address, - chainId: PEANUT_WALLET_CHAIN.id.toString(), - // amountToWithdraw is USD-denominated; source token is USDC (1:1). - // Required for the bridge path's 'pay' mode (cross-chain ETH/etc). - tokenAmount: amountToWithdraw, - }, - destination: { - recipientAddress: chargeDetails.requestLink.recipientAddress as Address, - tokenAddress: chargeDetails.tokenAddress as Address, - tokenAmount: chargeDetails.tokenAmount, - tokenDecimals: chargeDetails.tokenDecimals, - tokenType: Number(chargeDetails.tokenType), - chainId: chargeDetails.chainId, - }, - context: 'withdraw', - contextId: chargeDetails.uuid, - senderPeanutWalletAddress: address as Address, - skipGasEstimate: true, // peanut wallet handles gas - }) - } - }, [currentView, chargeDetails, withdrawData, calculateRoute, address, amountToWithdraw]) + if (currentView === 'CONFIRM') void quoteRoute() + }, [currentView, quoteRoute]) const handleSetupReview = useCallback( async (data: Omit) => { @@ -338,8 +347,23 @@ export default function WithdrawCryptoPage() { } if (!transactions || transactions.length === 0) { - console.error('No transactions prepared for withdrawal') - setError(t('errors.txNotPrepared')) + // Nothing prepared — the route never resolved, or an expiry refresh + // just failed. Quote again instead of dead-ending on "not prepared"; + // a persistent failure keeps surfacing through routeError. + await quoteRoute() + return + } + + // The numbers on screen are Rhino's quote only until it expires. Decide + // that NOW, at the tap — a render-time flag goes stale on a screen left + // open — with the signing lead time the bridge path uses. Past expiry, + // refresh and let the user confirm the fresh numbers instead of signing + // a stale pay amount — unless funds already moved for this charge (the + // record-only retry below must never re-quote). + const alreadySpent = executedSpendRef.current?.chargeId === chargeDetails.uuid + const quoteExpired = quoteExpiresAt ? isQuoteNearExpiry(quoteExpiresAt) : false + if (quoteExpired && !alreadySpent) { + await quoteRoute() return } @@ -530,6 +554,8 @@ export default function WithdrawCryptoPage() { address, transactions, payAmount, + quoteExpiresAt, + quoteRoute, usdAmount, sendTransactions, sendMoney, @@ -648,6 +674,7 @@ export default function WithdrawCryptoPage() { networkFee={networkFee} isCrossChain={isCrossChainWithdrawal} isCalculating={isCalculating} + quoteFailed={isFeeEstimationError} receiveAmount={receiveAmount} payAmount={payAmount} showHighFeeWarning={showHighFeeWarning} diff --git a/src/components/Claim/Claim.consts.ts b/src/components/Claim/Claim.consts.ts index 14d516e26d..6c03e51962 100644 --- a/src/components/Claim/Claim.consts.ts +++ b/src/components/Claim/Claim.consts.ts @@ -17,6 +17,12 @@ export interface ClaimXChainPreview { receiveAmount: string /** Rhino fee in USD. */ feeUsd: number + /** The address the account-bound quote was priced for — a cached route + * is only valid for that recipient (see findClaimRoute). */ + quotedFor: string + /** ISO expiry of the Rhino quote behind receiveAmount/feeUsd; an expired + * route is a cache miss (see findClaimRoute). */ + expiresAt: string } export type ClaimType = 'claim' | 'claimxchain' diff --git a/src/components/Claim/Link/Initial.view.tsx b/src/components/Claim/Link/Initial.view.tsx index fe9a5d44cd..6c75803a55 100644 --- a/src/components/Claim/Link/Initial.view.tsx +++ b/src/components/Claim/Link/Initial.view.tsx @@ -41,6 +41,7 @@ import ActionModal from '@/components/Global/ActionModal' import { BankFlowManager } from './views/BankFlowManager.view' import { type ClaimXChainPreview } from '../Claim.consts' import { previewSdaTransfer } from '@/services/rhino-sda' +import { findClaimRoute, resolveClaimQuoteRecipient } from '@/utils/claim-route.utils' import { evmChainIdToRhinoName } from '@/constants/rhino.consts' import { getTokenSymbol, getChainName } from '@/utils/general.utils' import { belowClaimBridgeMinimum } from '@/utils/claim-min-guard' @@ -217,6 +218,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { }, [user, resetClaimBankFlow]) const hasTrackedClaimView = useRef(false) + // Each route quote gets a generation; a result whose generation is no + // longer current (the recipient changed, or a newer quote started) is + // cached but never selected — a slow quote for A must not land on a + // confirm screen for B. + const quoteGenerationRef = useRef(0) useEffect(() => { if (claimLinkData && !hasTrackedClaimView.current) { hasTrackedClaimView.current = true @@ -658,6 +664,23 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { setIsValidRecipient(!!recipient.address) }, [recipient.address]) + // A route is priced for one recipient (account-bound quote). Switching the + // external address drops the stale selection and re-quotes for the new one; + // an unchanged effective recipient (bank claims, the Peanut wallet) keeps it. + useEffect(() => { + if (!selectedRoute) return + const quotedFor = resolveClaimQuoteRecipient({ + recipientAddress: recipient.address, + walletAddress: address, + senderAddress: claimLinkData.senderAddress, + }) + if (selectedRoute.quotedFor.toLowerCase() === quotedFor.toLowerCase()) return + quoteGenerationRef.current += 1 + setSelectedRoute(undefined) + setHasFetchedRoute(false) + setRefetchXchainRoute(true) + }, [recipient.address, address, claimLinkData.senderAddress, selectedRoute, setSelectedRoute, setHasFetchedRoute]) + useEffect(() => { if (!selectedTokenData) return if ( @@ -694,11 +717,19 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { } const chainId = toChain ?? selectedTokenData!.chainId const tokenAddress = toToken ?? selectedTokenData!.address + // The quote is account- and address-bound, so a cached route is only + // valid for the recipient it was priced for. + const quotedFor = resolveClaimQuoteRecipient({ + recipientAddress: recipient.address, + walletAddress: address, + senderAddress: claimLinkData.senderAddress, + }) + + const generation = ++quoteGenerationRef.current + const isCurrent = () => generation === quoteGenerationRef.current try { - const existingRoute = routes.find( - (route) => route.chainId === chainId && areEvmAddressesEqual(route.tokenAddress, tokenAddress) - ) + const existingRoute = findClaimRoute(routes, { chainId, tokenAddress, quotedFor }) if (existingRoute) { setSelectedRoute(existingRoute) @@ -726,12 +757,17 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { // Rhino preview expects a decimal string, so format down. const decimals = selectedTokenData?.decimals ?? 6 const previewAmount = formatUnits(claimLinkData.amount, decimals) + // The SDA deposit itself comes from the Peanut claim relayer, so + // the link sender's address (always an EVM address on the link's + // chain) stands in as depositor for pricing. const preview = await previewSdaTransfer({ chainIn: sourceRhinoChain, chainOut: destRhinoChain, token: tokenSymbol, amount: previewAmount, mode: 'pay', + depositor: claimLinkData.senderAddress, + recipient: quotedFor, }) const route: ClaimXChainPreview = { @@ -739,16 +775,24 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { tokenAddress: tokenAddress as Address, receiveAmount: preview.receiveAmount, feeUsd: preview.feeUsd, + quotedFor, + expiresAt: preview.expiresAt, } - setRoutes([...routes, route]) - if (!toToken && !toChain) { + // Functional update: concurrent quotes must not overwrite each + // other's cache entry (each miss costs a flow credit). + setRoutes((prev) => [...prev, route]) + if (!toToken && !toChain && isCurrent()) { setSelectedRoute(route) setHasFetchedRoute(true) } return route } catch (error) { console.error('Error fetching route:', error) + Sentry.captureException(error) + // A superseded quote's failure must not clear a newer route or + // install its error over a newer success. + if (!isCurrent()) return undefined if (!toToken && !toChain) { setSelectedRoute(undefined) setHasFetchedRoute(true) @@ -757,14 +801,25 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { showError: true, errorMessage: ROUTE_NOT_FOUND_ERROR, }) - Sentry.captureException(error) return undefined } finally { - setIsXchainLoading(false) - setLoadingState('Idle') + if (isCurrent()) { + setIsXchainLoading(false) + setLoadingState('Idle') + } } }, - [claimLinkData, isXChain, selectedTokenData, setLoadingState, routes, setHasFetchedRoute, setSelectedRoute] + [ + claimLinkData, + isXChain, + selectedTokenData, + setLoadingState, + routes, + setHasFetchedRoute, + setSelectedRoute, + recipient.address, + address, + ] ) useEffect(() => { @@ -778,7 +833,11 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => { useEffect(() => { if (!selectedChainID || !selectedTokenAddress) return - // Clear the old route when selection changes + // Clear the old route when selection changes — and retire any quote + // still in flight for the old chain/token, synchronously, so it can + // never resolve as current and select a route for a destination the + // user has left. + quoteGenerationRef.current += 1 setSelectedRoute(undefined) setHasFetchedRoute(false) diff --git a/src/components/Claim/Link/Onchain/Confirm.view.tsx b/src/components/Claim/Link/Onchain/Confirm.view.tsx index e45dc8f5c3..31a2f8724a 100644 --- a/src/components/Claim/Link/Onchain/Confirm.view.tsx +++ b/src/components/Claim/Link/Onchain/Confirm.view.tsx @@ -5,6 +5,7 @@ import { Notification } from '@/components/0_Bruddle/Notification' import Card from '@/components/Global/Card' import DisplayIcon from '@/components/Global/DisplayIcon' import NavHeader from '@/components/Global/NavHeader' +import NetworkFeeRow from '@/components/Global/NetworkFeeRow' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { loadingStateContext } from '@/context/loadingStates.context' @@ -28,6 +29,7 @@ import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' import underMaintenanceConfig, { CROSS_CHAIN_DISABLED_MESSAGE } from '@/config/underMaintenance.config' import { useTranslations, useFormatter } from 'next-intl' import { badgeCampaignForLegacyWire } from '@/components/Invites/badge-campaign-context' +import { isQuoteNearExpiry } from '@/services/rhino-bridge' export const ConfirmClaimLinkView = ({ onNext, @@ -39,6 +41,8 @@ export const ConfirmClaimLinkView = ({ setTransactionHash, attachment, selectedRoute, + setSelectedRoute, + setHasFetchedRoute, }: _consts.IClaimScreenProps) => { const t = useTranslations('claim') const format = useFormatter() @@ -80,14 +84,22 @@ export const ConfirmClaimLinkView = ({ return isStableCoin(resolvedTokenSymbol) ? `$ ${amount}` : `${amount} ${resolvedTokenSymbol}` }, [selectedRoute, resolvedTokenSymbol]) - // Network fee display – always sponsored in this flow - const networkFeeDisplay: string = tCommon('sponsoredByPeanut') - const handleOnClaim = async () => { if (!recipient) { return } + // The route's fee and receive amount are Rhino's quote only until it + // expires. Decided at the tap: past expiry, drop the route and return + // to the initial view, which re-quotes for the same selection — never + // execute against numbers Rhino no longer stands behind. + if (selectedRoute && isQuoteNearExpiry(selectedRoute.expiresAt)) { + setSelectedRoute(undefined) + setHasFetchedRoute(false) + onPrev() + return + } + setLoadingState('Loading') setErrorState({ showError: false, @@ -263,8 +275,12 @@ export const ConfirmClaimLinkView = ({ /> } - {/* Max network fee row */} - + {/* Max network fee row — the route preview's quoted fee, verbatim */} + {/* Peanut fee row */} diff --git a/src/components/Claim/Link/Onchain/__tests__/Confirm.view.test.tsx b/src/components/Claim/Link/Onchain/__tests__/Confirm.view.test.tsx new file mode 100644 index 0000000000..528d3dcdd9 --- /dev/null +++ b/src/components/Claim/Link/Onchain/__tests__/Confirm.view.test.tsx @@ -0,0 +1,146 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' + +jest.mock('next/navigation', () => ({ useSearchParams: () => ({ get: () => null }) })) +jest.mock('posthog-js', () => ({ __esModule: true, default: { capture: jest.fn() } })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/PeanutActionDetailsCard', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/DisplayIcon', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + onClick, + disabled, + }: { + children: React.ReactNode + onClick?: () => void + disabled?: boolean + }) => ( + + ), +})) +jest.mock('@/context/loadingStates.context', () => { + const ReactActual = jest.requireActual('react') + return { loadingStateContext: ReactActual.createContext({ setLoadingState: jest.fn(), isLoading: false }) } +}) +jest.mock('@/context/tokenSelector.context', () => { + const ReactActual = jest.requireActual('react') + return { + tokenSelectorContext: ReactActual.createContext({ + selectedChainID: '8453', + selectedTokenAddress: '0xusdc', + isXChain: true, + }), + } +}) +jest.mock('@/hooks/useTokenChainIcons', () => ({ + useTokenChainIcons: () => ({ resolvedChainName: 'Base', resolvedTokenSymbol: 'USDC' }), +})) +jest.mock('@/hooks/wallet/useWallet', () => ({ useWallet: () => ({ address: '0x2222' }) })) +jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: null }) })) +const mockClaimLinkXchain = jest.fn() +jest.mock('../../../useClaimLink', () => ({ + __esModule: true, + default: () => ({ claimLinkXchain: mockClaimLinkXchain, claimLink: jest.fn() }), +})) +jest.mock('@/hooks/useRecipientDisplay', () => ({ useRecipientDisplay: () => ({ displayName: 'bob' }) })) +jest.mock('@/hooks/useFriendlyError', () => ({ useFriendlyError: () => (e: unknown) => String(e) })) +jest.mock('@/services/sendLinks', () => ({ sendLinksApi: { associateClaim: jest.fn() } })) +jest.mock('@/constants/analytics.consts', () => ({ ANALYTICS_EVENTS: {} })) +jest.mock('@/config/underMaintenance.config', () => ({ + __esModule: true, + default: { disableXchainSend: false }, + CROSS_CHAIN_DISABLED_MESSAGE: '', +})) +jest.mock('@/components/Invites/badge-campaign-context', () => ({ badgeCampaignForLegacyWire: () => null })) +jest.mock('../../../Claim.consts', () => ({})) + +import { fireEvent } from '@testing-library/react' +import { ConfirmClaimLinkView } from '../Confirm.view' +import type { ClaimXChainPreview } from '../../../Claim.consts' + +const props = { + onNext: jest.fn(), + onPrev: jest.fn(), + setSelectedRoute: jest.fn(), + setHasFetchedRoute: jest.fn(), + setClaimType: jest.fn(), + claimLinkData: { + amount: 10_000_000n, + tokenDecimals: 6, + tokenSymbol: 'USDC', + chainId: '42161', + link: 'https://peanut.test/claim', + senderAddress: '0x9999', + sender: null, + }, + recipient: { address: '0x2222', name: '' }, + tokenPrice: 1, + setTransactionHash: jest.fn(), + attachment: { message: '', attachmentUrl: '' }, +} as unknown as React.ComponentProps + +describe('ConfirmClaimLinkView — max network fee row', () => { + it('shows the sponsored label for a zero-fee cross-chain route', () => { + renderWithIntl( + + ) + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + }) + + it('shows a quoted route fee verbatim', () => { + renderWithIntl( + + ) + expect(screen.getByText('$0.50')).toBeInTheDocument() + }) + + it('an expired route is re-quoted, never executed: drops the route and returns to the initial view', () => { + jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') }) + try { + const route: ClaimXChainPreview = { + chainId: '8453', + tokenAddress: '0xusdc', + receiveAmount: '10', + feeUsd: 0, + quotedFor: '0x2222', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + renderWithIntl() + + jest.setSystemTime(Date.now() + 120_000) + fireEvent.click(screen.getByRole('button', { name: /receive now/i })) + + expect(mockClaimLinkXchain).not.toHaveBeenCalled() + expect(props.setSelectedRoute).toHaveBeenCalledWith(undefined) + expect(props.setHasFetchedRoute).toHaveBeenCalledWith(false) + expect(props.onPrev).toHaveBeenCalled() + } finally { + jest.useRealTimers() + } + }) +}) diff --git a/src/components/Global/NetworkFeeRow/__tests__/NetworkFeeRow.test.tsx b/src/components/Global/NetworkFeeRow/__tests__/NetworkFeeRow.test.tsx new file mode 100644 index 0000000000..0eea528f80 --- /dev/null +++ b/src/components/Global/NetworkFeeRow/__tests__/NetworkFeeRow.test.tsx @@ -0,0 +1,33 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' +import NetworkFeeRow from '@/components/Global/NetworkFeeRow' + +describe('NetworkFeeRow', () => { + it('shows the sponsored label for a zero cross-chain quote (the 1:1 account config)', () => { + renderWithIntl() + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + }) + + it('shows a non-zero quote verbatim', () => { + renderWithIntl() + expect(screen.getByText('$0.51')).toBeInTheDocument() + expect(screen.queryByText('Sponsored by Peanut!')).not.toBeInTheDocument() + }) + + it('is sponsored on same-chain even if a stale fee is passed', () => { + renderWithIntl() + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + }) + + it('strikes through paymaster-covered gas next to the sponsored label', () => { + renderWithIntl() + expect(screen.getByText('$ 0.05')).toHaveClass('line-through') + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + }) + + it('shows a dash when estimation failed', () => { + renderWithIntl() + expect(screen.getByText('-')).toBeInTheDocument() + }) +}) diff --git a/src/components/Global/NetworkFeeRow/index.tsx b/src/components/Global/NetworkFeeRow/index.tsx new file mode 100644 index 0000000000..746ebf347b --- /dev/null +++ b/src/components/Global/NetworkFeeRow/index.tsx @@ -0,0 +1,68 @@ +'use client' + +import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' +import { formatNetworkFee } from '@/utils/cross-chain-fee.utils' +import { useTranslations } from 'next-intl' + +/** + * The one network-fee row for confirm screens. Reads the quote's `feeUsd` + * verbatim (no arithmetic here) and renders the sponsored label, the + * struck-through sponsored gas, or the formatted fee — the same way on every + * screen (DS audit: "Fee row 'Sponsored by Peanut!' recipe"). + */ +interface NetworkFeeRowProps { + label: string + /** Rhino's quoted total fee, verbatim from the hook. Undefined before a + * quote resolves or on same-chain transfers. */ + feeUsd?: number + isCrossChain: boolean + loading?: boolean + moreInfoText?: string + /** Gas the paymaster covers on a same-chain send — shown struck through + * next to the sponsored label when it reaches a cent. */ + sponsoredGasUsd?: number + /** Route or fee estimation failed — shows a dash instead of a number. */ + estimationFailed?: boolean + hideBottomBorder?: boolean +} + +export default function NetworkFeeRow({ + label, + feeUsd, + isCrossChain, + loading, + moreInfoText, + sponsoredGasUsd, + estimationFailed, + hideBottomBorder, +}: NetworkFeeRowProps) { + const tCommon = useTranslations('common') + const fee = formatNetworkFee(feeUsd, isCrossChain) + + let value: React.ReactNode + if (estimationFailed) { + value = '-' + } else if (fee !== null) { + value = fee + } else if (sponsoredGasUsd !== undefined && sponsoredGasUsd >= 0.01) { + value = ( + <> + $ {sponsoredGasUsd.toFixed(2)} + {' - '} + {tCommon('sponsoredByPeanut')} + + ) + } else { + value = tCommon('sponsoredByPeanut') + } + + return ( + + ) +} diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx index 94a16c54bd..14dabee48b 100644 --- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx +++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx @@ -6,6 +6,7 @@ import AddressLink from '@/components/Global/AddressLink' import Card from '@/components/Global/Card' import DisplayIcon from '@/components/Global/DisplayIcon' import NavHeader from '@/components/Global/NavHeader' +import NetworkFeeRow from '@/components/Global/NetworkFeeRow' import PeanutActionDetailsCard from '@/components/Global/PeanutActionDetailsCard' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { useTokenChainIcons } from '@/hooks/useTokenChainIcons' @@ -21,6 +22,7 @@ interface WithdrawConfirmViewProps { token: ITokenPriceData chain: ChainWithTokens toAddress: string + /** Rhino's quoted total fee (USD), verbatim from `useCrossChainTransfer`. */ networkFee?: number peanutFee?: string onConfirm: () => void @@ -30,17 +32,20 @@ interface WithdrawConfirmViewProps { isCrossChain?: boolean /** True while the shared `useCrossChainTransfer` hook is provisioning the SDA / previewing fees. */ isCalculating?: boolean + /** The Rhino quote failed — the fee is unknown, not sponsored. */ + quoteFailed?: boolean /** - * Decimal receive amount from Rhino's public quote — e.g. "99.95". Nullable - * for same-chain (no bridge) or while the preview call is in flight. Under - * SDA there's no slippage on same-stablecoin bridges; this is the deterministic + * Decimal receive amount from Rhino's quote — e.g. "99.95". Nullable for + * same-chain (no bridge) or while the preview call is in flight. Under SDA + * there's no slippage on same-stablecoin bridges; this is the deterministic * "they'll receive X" number. */ receiveAmount?: string | null /** * The exact USDC the kernel spends (decimal string) — the honest "You pay". - * SDA (receive mode) = principal + fee; bridge (pay mode) = principal (the - * fee comes out of what the recipient receives). Nullable while calculating. + * SDA (receive mode) = principal + quoted fee; bridge (pay mode) = principal + * (any fee comes out of what the recipient receives). Nullable while + * calculating. */ payAmount?: string | null /** @@ -78,6 +83,7 @@ export default function ConfirmWithdrawView({ error, isCrossChain = false, isCalculating = false, + quoteFailed = false, receiveAmount, payAmount, showHighFeeWarning = false, @@ -101,21 +107,11 @@ export default function ConfirmWithdrawView({ return isStableCoin(resolvedTokenSymbol) ? `$${receiveAmount}` : `${receiveAmount} ${resolvedTokenSymbol}` }, [isCrossChain, receiveAmount, resolvedTokenSymbol]) - // Honest bridge fee. The Rhino fee (destination gas + 0.07%) is paid by the - // user on top of the amount — it is NOT sponsored. Only the kernel execution - // gas is sponsored by Peanut's paymaster (the "Peanut fee" row below). For - // same-chain (no bridge) there's no Rhino fee, so it stays sponsored. - const networkFeeDisplay = useMemo(() => { - if (!isCrossChain || networkFee <= 0) return tCommon('sponsoredByPeanut') - return networkFee < 0.01 ? '< $0.01' : `$${networkFee.toFixed(2)}` - }, [isCrossChain, networkFee, tCommon]) - // What actually leaves the wallet on a cross-chain withdraw — the exact USDC // the kernel spends (`payAmount`). This is authoritative for BOTH paths and - // avoids guessing: SDA (receive mode) = principal + fee, bridge (pay mode) = - // principal (the fee comes out of the recipient's amount, not on top). Using - // amount + fee would over-state the bridge path (showing principal + fee when - // the user only pays the principal). + // avoids guessing: SDA (receive mode) = principal + quoted fee, bridge (pay + // mode) = principal (any fee comes out of the recipient's amount, not on + // top). Using amount + fee would over-state the bridge path. const totalPayDisplay = useMemo(() => { if (!isCrossChain || !payAmount) return null const parsed = parseFloat(payAmount) @@ -191,10 +187,12 @@ export default function ConfirmWithdrawView({ /> } /> - {isCrossChain && (isCalculating || totalPayDisplay) && ( diff --git a/src/components/Withdraw/views/__tests__/Confirm.withdraw.view.test.tsx b/src/components/Withdraw/views/__tests__/Confirm.withdraw.view.test.tsx new file mode 100644 index 0000000000..dfe523a9f0 --- /dev/null +++ b/src/components/Withdraw/views/__tests__/Confirm.withdraw.view.test.tsx @@ -0,0 +1,63 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' +import ConfirmWithdrawView from '../Confirm.withdraw.view' + +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/PeanutActionDetailsCard', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/AddressLink', () => ({ + __esModule: true, + default: ({ address }: { address: string }) => {address}, +})) +jest.mock('@/components/Global/DisplayIcon', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ + children, + disabled, + onClick, + }: { + children: React.ReactNode + disabled?: boolean + onClick: () => void + }) => ( + + ), +})) +jest.mock('@/hooks/useTokenChainIcons', () => ({ + useTokenChainIcons: () => ({ resolvedChainName: 'Solana', resolvedTokenSymbol: 'USDC' }), +})) + +const baseProps = { + amount: '10', + token: { address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', symbol: 'USDC', decimals: 6, price: 1 } as never, + chain: { chainId: 'solana', networkName: 'Solana' } as never, + toAddress: '11111111111111111111111111111111', + onConfirm: jest.fn(), + onBack: jest.fn(), + isCrossChain: true, + receiveAmount: '10', + payAmount: '10', +} + +describe('ConfirmWithdrawView — network fee row', () => { + it('shows the sponsored label when the account quote carries no fee, and pay == receive', () => { + renderWithIntl() + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + // "Recipient receives" and "You pay" both read $10 — nothing on top. + expect(screen.getAllByText('$10')).toHaveLength(2) + }) + + it('shows a dash, not the sponsored label, when the quote failed', () => { + renderWithIntl() + expect(screen.getByText('-')).toBeInTheDocument() + expect(screen.queryByText('Sponsored by Peanut!')).not.toBeInTheDocument() + }) + + it('shows a quoted fee verbatim when Rhino quotes one', () => { + renderWithIntl() + expect(screen.getByText('$0.51')).toBeInTheDocument() + expect(screen.queryByText('Sponsored by Peanut!')).not.toBeInTheDocument() + }) +}) diff --git a/src/features/payments/flows/semantic-request/__tests__/useSemanticRequestFlow.expiry.test.ts b/src/features/payments/flows/semantic-request/__tests__/useSemanticRequestFlow.expiry.test.ts new file mode 100644 index 0000000000..01466188d9 --- /dev/null +++ b/src/features/payments/flows/semantic-request/__tests__/useSemanticRequestFlow.expiry.test.ts @@ -0,0 +1,164 @@ +/** + * The pay-request confirm shows Rhino's quote only until it expires. The tap + * decides: an expired quote is re-quoted (the user confirms the fresh + * numbers), a fresh one is broadcast. Mirrors the withdraw page test. + */ +import { act } from '@testing-library/react' +import { renderHookWithIntl } from '@/test-utils/intl' + +const ctx = { + amount: '10', + setAmount: jest.fn(), + usdAmount: '10', + setUsdAmount: jest.fn(), + currentView: 'CONFIRM', + setCurrentView: jest.fn(), + parsedUrl: null, + recipient: { + recipientType: 'USERNAME', + identifier: 'alice', + resolvedAddress: '0x1111111111111111111111111111111111111111', + }, + chargeIdFromUrl: null, + isAmountFromUrl: false, + isTokenFromUrl: false, + isChainFromUrl: false, + urlToken: null, + isTokenDenominated: false, + attachment: null, + setAttachment: jest.fn(), + charge: { + uuid: 'charge-1', + chainId: '8453', + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + tokenAmount: '10', + tokenDecimals: 6, + tokenType: '1', + tokenSymbol: 'USDC', + requestLink: { recipientAddress: '0x1111111111111111111111111111111111111111' }, + }, + setCharge: jest.fn(), + payment: null, + setPayment: jest.fn(), + txHash: null, + setTxHash: jest.fn(), + error: { showError: false, errorMessage: '' }, + setError: jest.fn(), + isLoading: false, + setIsLoading: jest.fn(), + isSuccess: false, + setIsSuccess: jest.fn(), + resetSemanticRequestFlow: jest.fn(), + isExternalWalletPayment: false, + setIsExternalWalletPayment: jest.fn(), +} +jest.mock('../SemanticRequestFlowContext', () => ({ useSemanticRequestFlowContext: () => ctx })) + +jest.mock('@/features/payments/shared/hooks/useChargeManager', () => ({ + useChargeManager: () => ({ createCharge: jest.fn(), fetchCharge: jest.fn(), isCreating: false, isFetching: false }), +})) +jest.mock('@/features/payments/shared/hooks/usePaymentRecorder', () => ({ + usePaymentRecorder: () => ({ + recordPayment: jest.fn().mockResolvedValue({ uuid: 'p1' }), + isRecording: false, + reset: jest.fn(), + }), +})) + +const mockCalculate = jest.fn() +const route = { + transactions: [{ to: '0x3333333333333333333333333333333333333333', data: '0x' }], + receiveAmount: '10', + payAmount: '10', + feeUsd: 0, + estimatedGasCostUsd: 0, + isCalculating: false, + isFeeEstimationError: false, + error: null, + quoteExpiresAt: null as string | null, + calculate: (...args: unknown[]) => mockCalculate(...args), + reset: jest.fn(), +} +jest.mock('@/features/payments/shared/hooks/useCrossChainTransfer', () => ({ useCrossChainTransfer: () => route })) + +const mockSendTransactions = jest.fn() +jest.mock('@/hooks/wallet/useWallet', () => ({ + useWallet: () => ({ + isConnected: true, + address: '0x2222222222222222222222222222222222222222', + sendMoney: jest.fn(), + sendTransactions: (...args: unknown[]) => mockSendTransactions(...args), + formattedSpendableBalance: '100', + hasSufficientSpendableBalance: () => true, + isFetchingSpendableBalance: false, + }), +})) +jest.mock('@/context/authContext', () => ({ useAuth: () => ({ user: { user: { userId: 'u1' } } }) })) +jest.mock('@/context/tokenSelector.context', () => { + const ReactActual = jest.requireActual('react') + return { + tokenSelectorContext: ReactActual.createContext({ + selectedChainID: '8453', + selectedTokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + selectedTokenData: { + address: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + chainId: '8453', + decimals: 6, + symbol: 'USDC', + }, + setSelectedChainID: jest.fn(), + setSelectedTokenAddress: jest.fn(), + }), + } +}) +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + PEANUT_WALLET_TOKEN_DECIMALS: 6, +})) +jest.mock('@/hooks/useFriendlyError', () => ({ useFriendlyError: () => (e: unknown) => String(e) })) +jest.mock('@tanstack/react-query', () => ({ useQueryClient: () => ({ invalidateQueries: jest.fn() }) })) +jest.mock('@/constants/query.consts', () => ({ TRANSACTIONS: 'transactions' })) +jest.mock('@/utils/settled-tx-hash.utils', () => ({ + resolveSettledTxHash: (r: { txHash?: string }) => ({ hash: r.txHash ?? '0xmined' }), +})) + +import { useSemanticRequestFlow } from '../useSemanticRequestFlow' + +describe('useSemanticRequestFlow — quote expiry is decided at the tap', () => { + beforeEach(() => { + jest.clearAllMocks() + mockSendTransactions.mockResolvedValue({ txHash: '0xmined', receipt: null, strategy: 'smart-only' }) + }) + afterEach(() => jest.useRealTimers()) + + it('re-quotes instead of broadcasting when the quote aged out while the confirm sat open', async () => { + jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') }) + route.quoteExpiresAt = new Date(Date.now() + 60_000).toISOString() + const { result } = renderHookWithIntl(() => useSemanticRequestFlow()) + const quotesBeforeTap = mockCalculate.mock.calls.length + + jest.setSystemTime(Date.now() + 120_000) + await act(async () => { + await result.current.executePayment() + }) + + expect(mockCalculate).toHaveBeenCalledTimes(quotesBeforeTap + 1) + expect(mockSendTransactions).not.toHaveBeenCalled() + }) + + it('broadcasts while the quote is still fresh', async () => { + jest.useFakeTimers({ now: new Date('2026-09-01T12:00:00Z') }) + route.quoteExpiresAt = new Date(Date.now() + 120_000).toISOString() + const { result } = renderHookWithIntl(() => useSemanticRequestFlow()) + const quotesBeforeTap = mockCalculate.mock.calls.length + + jest.setSystemTime(Date.now() + 10_000) + await act(async () => { + await result.current.executePayment() + }) + + expect(mockSendTransactions).toHaveBeenCalled() + expect(mockCalculate).toHaveBeenCalledTimes(quotesBeforeTap) + }) +}) diff --git a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts index 0442e41f4a..80d9d6a837 100644 --- a/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts +++ b/src/features/payments/flows/semantic-request/useSemanticRequestFlow.ts @@ -19,6 +19,7 @@ import { useSemanticRequestFlowContext } from './SemanticRequestFlowContext' import { useChargeManager } from '@/features/payments/shared/hooks/useChargeManager' import { usePaymentRecorder } from '@/features/payments/shared/hooks/usePaymentRecorder' import { useCrossChainTransfer } from '@/features/payments/shared/hooks/useCrossChainTransfer' +import { isQuoteNearExpiry } from '@/services/rhino-bridge' import { useWallet } from '@/hooks/wallet/useWallet' import { useAuth } from '@/context/authContext' import { tokenSelectorContext } from '@/context/tokenSelector.context' @@ -77,6 +78,7 @@ export function useSemanticRequestFlow() { receiveAmount: calculatedReceiveAmount, payAmount: calculatedPayAmount, feeUsd: calculatedFeeUsd, + quoteExpiresAt, calculate: calculateRoute, isCalculating: isCalculatingRoute, isFeeEstimationError, @@ -487,6 +489,15 @@ export function useSemanticRequestFlow() { return } + // The prepared route carries Rhino's quote only until it expires. + // Decided at the tap (a render-time flag goes stale on an open screen): + // past expiry, re-quote and let the user confirm the fresh numbers + // instead of broadcasting the stale route. + if (needsRoute && quoteExpiresAt && isQuoteNearExpiry(quoteExpiresAt)) { + await prepareRoute() + return + } + setIsLoading(true) clearError() @@ -586,6 +597,8 @@ export function useSemanticRequestFlow() { needsRoute, routeTransactions, calculatedPayAmount, + quoteExpiresAt, + prepareRoute, selectedChainID, selectedTokenAddress, selectedTokenData, diff --git a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx index 402666c887..6fa8182cfc 100644 --- a/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx +++ b/src/features/payments/flows/semantic-request/views/SemanticRequestConfirmView.tsx @@ -17,6 +17,7 @@ import { Button } from '@/components/0_Bruddle/Button' import { Notification } from '@/components/0_Bruddle/Notification' import Card from '@/components/Global/Card' import NavHeader from '@/components/Global/NavHeader' +import NetworkFeeRow from '@/components/Global/NetworkFeeRow' import Loading from '@/components/Global/Loading' import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import DisplayIcon from '@/components/Global/DisplayIcon' @@ -48,6 +49,7 @@ export function SemanticRequestConfirmView() { error, calculatedReceiveAmount, calculatedGasCost, + calculatedFeeUsd, isCalculatingRoute, isFeeEstimationError, routeError, @@ -108,24 +110,6 @@ export function SemanticRequestConfirmView() { return `${formatAmount(usdAmount || amount)}` }, [amount, usdAmount, isTokenDenominated]) - // get network fee display - const networkFee = useMemo(() => { - if (isFeeEstimationError) return '-' - if (calculatedGasCost === undefined) { - return tCommon('sponsoredByPeanut') - } - if (calculatedGasCost < 0.01) { - return tCommon('sponsoredByPeanut') - } - return ( - <> - $ {calculatedGasCost.toFixed(2)} - {' - '} - {tCommon('sponsoredByPeanut')} - - ) - }, [calculatedGasCost, isFeeEstimationError, tCommon]) - // Receive amount from Rhino preview. Same-token bridges are 1:1 minus flat // fee (no slippage) — the preview value is deterministic, not a "minimum". const minReceived = useMemo(() => { @@ -242,10 +226,13 @@ export function SemanticRequestConfirmView() { /> )} - diff --git a/src/features/payments/flows/semantic-request/views/__tests__/SemanticRequestConfirmView.test.tsx b/src/features/payments/flows/semantic-request/views/__tests__/SemanticRequestConfirmView.test.tsx new file mode 100644 index 0000000000..be5f8b7357 --- /dev/null +++ b/src/features/payments/flows/semantic-request/views/__tests__/SemanticRequestConfirmView.test.tsx @@ -0,0 +1,67 @@ +import React from 'react' +import { screen } from '@testing-library/react' +import { renderWithIntl } from '@/test-utils/intl' + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn() }), + useSearchParams: () => ({ get: () => null }), +})) +jest.mock('@/components/Global/NavHeader', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/Loading', () => ({ __esModule: true, default: () =>
})) +jest.mock('@/components/Global/DisplayIcon', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/Global/PeanutActionDetailsCard', () => ({ __esModule: true, default: () => null })) +jest.mock('@/features/payments/shared/components/SendWithPeanutCta', () => ({ __esModule: true, default: () => null })) +jest.mock('@/components/0_Bruddle/Button', () => ({ + Button: ({ children }: { children: React.ReactNode }) => , +})) +jest.mock('@/hooks/useTokenChainIcons', () => ({ + useTokenChainIcons: () => ({ resolvedChainName: 'Base', resolvedTokenSymbol: 'USDC' }), +})) +jest.mock('@/constants/zerodev.consts', () => ({ + PEANUT_WALLET_CHAIN: { id: 42161 }, + PEANUT_WALLET_TOKEN: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + PEANUT_WALLET_TOKEN_SYMBOL: 'USDC', +})) + +const flow = { + amount: '10', + usdAmount: '10', + recipient: { recipientType: 'USERNAME', identifier: 'alice' }, + charge: { chainId: '8453', tokenAddress: '0xusdc', tokenSymbol: 'USDC', tokenDecimals: 6, tokenAmount: '10' }, + attachment: null, + error: { showError: false, errorMessage: '' }, + calculatedReceiveAmount: '10', + calculatedGasCost: 0, + calculatedFeeUsd: 0, + isCalculatingRoute: false, + isFeeEstimationError: false, + routeError: null, + isXChain: true, + isDiffToken: false, + isLoading: false, + isFetchingCharge: false, + selectedChainID: '8453', + selectedTokenData: { symbol: 'USDC' }, + urlToken: null, + isTokenDenominated: false, + goBackToInitial: jest.fn(), + executePayment: jest.fn(), + prepareRoute: jest.fn(), +} +jest.mock('../../useSemanticRequestFlow', () => ({ useSemanticRequestFlow: () => flow })) + +import { SemanticRequestConfirmView } from '../SemanticRequestConfirmView' + +describe('SemanticRequestConfirmView — network fee row', () => { + it('cross-chain with a zero account quote shows the sponsored label', () => { + renderWithIntl() + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + }) + + it('same-chain strikes through the paymaster-covered gas', () => { + Object.assign(flow, { isXChain: false, calculatedGasCost: 0.05, calculatedFeeUsd: undefined }) + renderWithIntl() + expect(screen.getByText('$ 0.05')).toHaveClass('line-through') + expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument() + }) +}) diff --git a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts new file mode 100644 index 0000000000..7a7e4bc0c7 --- /dev/null +++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts @@ -0,0 +1,141 @@ +/** + * The hook exposes Rhino's quote verbatim. Regression net for the phantom fee: + * the bridge path used to set `feeUsd + gasFeeUsd` (double count) and the SDA + * preview went to the public quote (no depositor/recipient), which priced a fee + * our account never pays. + */ +import { renderHook, act } from '@testing-library/react' + +const mockPreviewSdaTransfer = jest.fn() +const mockProvisionSdaTransfer = jest.fn() +jest.mock('@/services/rhino-sda', () => ({ + previewSdaTransfer: (...args: unknown[]) => mockPreviewSdaTransfer(...args), + provisionSdaTransfer: (...args: unknown[]) => mockProvisionSdaTransfer(...args), +})) + +const mockGetBridgeQuote = jest.fn() +const mockCommitBridgeQuote = jest.fn() +jest.mock('@/services/rhino-bridge', () => ({ + getBridgeQuote: (...args: unknown[]) => mockGetBridgeQuote(...args), + commitBridgeQuote: (...args: unknown[]) => mockCommitBridgeQuote(...args), + getBridgeStatus: jest.fn(), + isQuoteNearExpiry: () => false, +})) + +jest.mock('@/constants/rhino.consts', () => ({ + chainIdToRhinoName: (chainId: string) => ({ '42161': 'ARBITRUM', '8453': 'BASE', '1': 'ETHEREUM' })[chainId], +})) +jest.mock('@/constants/chainRegistry.consts', () => ({ NON_EVM_WITHDRAW_CHAINS: {} })) +jest.mock('@/utils/general.utils', () => ({ + areEvmAddressesEqual: (a: string, b: string) => a.toLowerCase() === b.toLowerCase(), + getTokenSymbol: (address: string) => (address.startsWith('0xaf88') ? 'USDC' : 'ETH'), +})) +jest.mock('@/utils/peanut-claim.utils', () => ({ prepareRequestLinkFulfillmentTransaction: jest.fn() })) +jest.mock('@/app/actions/tokens', () => ({ estimateTransactionCostUsd: jest.fn() })) +jest.mock('@sentry/nextjs', () => ({ captureException: jest.fn() })) +jest.mock('@/interfaces/peanut-sdk-types', () => ({ EPeanutLinkType: { erc20: 1 } })) + +import { useCrossChainTransfer } from '../useCrossChainTransfer' + +const USDC_ARB = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' +const KERNEL = '0x2222222222222222222222222222222222222222' +const RECIPIENT = '0x1111111111111111111111111111111111111111' + +const quote = (feeUsd: number) => ({ + payAmount: (10 + feeUsd).toFixed(6), + payAmountUsd: 10 + feeUsd, + receiveAmount: '10', + receiveAmountUsd: 10, + feeUsd, + fees: { gasUsd: feeUsd, sourceGasUsd: 0, platformUsd: 0, percentageUsd: 0 }, + quoteId: 'q-1', + expiresAt: '2099-01-01T00:00:00.000Z', +}) + +const source = { + address: KERNEL as `0x${string}`, + tokenAddress: USDC_ARB as `0x${string}`, + chainId: '42161', + tokenAmount: '10', +} + +describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => { + beforeEach(() => { + jest.clearAllMocks() + mockProvisionSdaTransfer.mockResolvedValue({ + sdaAddress: '0x3333333333333333333333333333333333333333', + depositChain: 'ARBITRUM', + destinationChain: 'BASE', + destinationAddress: RECIPIENT, + tokenOut: 'USDC', + minDepositLimitUsd: 0.5, + maxDepositLimitUsd: 10000, + }) + mockCommitBridgeQuote.mockResolvedValue({ + commitmentId: 'ab', + calldata: { to: '', data: '', value: '' }, + contractAddress: '0x4444444444444444444444444444444444444444', + kind: 'deposit-with-id', + }) + }) + + it('SDA path: sends depositor/recipient to the preview and exposes feeUsd and payAmount as quoted', async () => { + mockPreviewSdaTransfer.mockResolvedValue(quote(0)) + const { result } = renderHook(() => useCrossChainTransfer()) + + await act(async () => { + await result.current.calculate({ + source, + destination: { + recipientAddress: RECIPIENT, + tokenAddress: USDC_ARB, + tokenAmount: '10', + tokenDecimals: 6, + tokenType: 1, + chainId: '8453', + tokenSymbol: 'USDC', + }, + context: 'withdraw', + contextId: 'charge-1', + }) + }) + + expect(mockPreviewSdaTransfer).toHaveBeenCalledWith( + expect.objectContaining({ depositor: KERNEL, recipient: RECIPIENT, mode: 'receive', amount: '10' }) + ) + expect(result.current.path).toBe('sda') + expect(result.current.feeUsd).toBe(0) + expect(result.current.payAmount).toBe('10.000000') + expect(result.current.receiveAmount).toBe('10') + expect(result.current.quoteExpiresAt).toBe('2099-01-01T00:00:00.000Z') + expect(result.current.error).toBeNull() + }) + + it('bridge path: feeUsd is the quote total, not feeUsd plus a gas component', async () => { + mockGetBridgeQuote.mockResolvedValue({ ...quote(1.51), isSwap: true }) + const { result } = renderHook(() => useCrossChainTransfer()) + + await act(async () => { + await result.current.calculate({ + source, + destination: { + recipientAddress: RECIPIENT, + tokenAddress: '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE', + tokenAmount: '0.004', + tokenDecimals: 18, + tokenType: 0, + chainId: '1', + tokenSymbol: 'ETH', + }, + context: 'withdraw', + contextId: 'charge-2', + }) + }) + + expect(result.current.path).toBe('bridge') + expect(result.current.feeUsd).toBe(1.51) + expect(result.current.payAmount).toBe('11.510000') + expect(result.current.receiveAmount).toBe('10') + expect(result.current.error).toBeNull() + }) +}) diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts index e142eefc07..4f387b6f5d 100644 --- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts +++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts @@ -32,7 +32,7 @@ import { previewSdaTransfer, type RhinoTransferContext, type RhinoSupportedToken, - type SdaPreviewResult, + type RhinoQuote, type SdaTransferResult, } from '@/services/rhino-sda' import { @@ -112,14 +112,17 @@ export interface UseCrossChainTransferReturn { transactions: PreparedTransaction[] | null sdaAddress: Address | null receiveAmount: string | null - /** USDC the kernel actually needs on-hand to execute `transactions[0]` — this - * is principal + Rhino fee on the SDA path (where mode='receive' and Rhino - * takes the fee at source) and equals principal on the bridge / same-chain - * paths. Callers that route through `sendTransactions({ requiredUsdcAmount })` - * MUST pass this, not the principal, or the kernel's collateral-sweep - * shortfall is under-funded and the subsequent transfer reverts with + /** USDC the kernel actually needs on-hand to execute `transactions[0]` — the + * quote's `payAmount` verbatim on the SDA path (mode='receive': principal + * plus whatever fee Rhino quotes at source, $0 under the current account + * config) and the principal on the bridge / same-chain paths. Callers that + * route through `sendTransactions({ requiredUsdcAmount })` MUST pass this, + * not the principal, or the kernel's collateral-sweep shortfall is + * under-funded and the subsequent transfer reverts with * `ERC20: transfer amount exceeds balance`. */ payAmount: string | null + /** Rhino's quoted total fee, verbatim from the quote (`feeUsd`). Never + * derived on the FE — no summing of components, no pay − receive. */ feeUsd: number | undefined estimatedGasCostUsd: number | undefined minDepositLimitUsd: number | undefined @@ -131,7 +134,9 @@ export interface UseCrossChainTransferReturn { error: string | null /** Which path produced the current `transactions` (null before calculate). */ path: CrossChainPath | null - /** Bridge-only: ISO expiry on Rhino quote. SDA / same-chain don't expire. */ + /** ISO expiry of the Rhino quote behind `payAmount`/`receiveAmount`/`feeUsd` + * (SDA and bridge paths). Callers re-quote past it instead of signing + * stale numbers. Null on same-chain. */ quoteExpiresAt: string | null /** Bridge-only: commitment id (for status polling after the user signs). */ commitmentId: string | null @@ -315,17 +320,19 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn { return } - // Preview first, then provision — provision now carries the quote + // Preview first, then provision — provision carries the quote // economics (feeUsd / payAmount / receiveAmount) so the backend can - // persist them onto the charge and book the FEE ledger entry at - // settlement (PRINCIPAL + FEE = real on-chain debit). Sequential - // because provision depends on preview's numbers. + // persist them onto the charge for audit (the FEE ledger entry is + // booked from Rhino's executed actuals, not from this quote). + // Sequential because provision depends on preview's numbers. const preview = await previewSdaTransfer({ chainIn: sourceRhinoChain, chainOut: destRhinoChain, token: tokenSymbol, amount: destination.tokenAmount, - mode: 'receive', // UI always asks "merchant gets X" — user pays X + fee + mode: 'receive', // UI always asks "merchant gets X" — user pays X + quoted fee + depositor: source.address, + recipient: destination.recipientAddress, }) const sda = await provisionSdaTransfer({ context, @@ -353,6 +360,7 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn { setMaxDepositLimitUsd, setEstimatedGasCostUsd, setIsFeeEstimationError, + setQuoteExpiresAt, }) setPath('sda') } catch (err) { @@ -462,7 +470,7 @@ async function runBridgePath({ } const STABLECOIN_DECIMALS = 6 - const approveAmount = parseUnits(quote.amountIn, STABLECOIN_DECIMALS) + const approveAmount = parseUnits(quote.payAmount, STABLECOIN_DECIMALS) // Approve USDC for Rhino's bridge contract — required for both same-chain // swap (the swap contract does transferFrom) and cross-chain depositWithId. const approveData = encodeFunctionData({ @@ -504,12 +512,14 @@ async function runBridgePath({ }, bridgeCall, ]) - setReceiveAmount(quote.amountOut) - // Bridge path: mode='pay' → user pays exactly `amountIn` at source; the - // Rhino fee + gas come out of the destination amount. So the kernel needs - // `amountIn` USDC on-hand (same as the principal). - setPayAmount(quote.amountIn) - setFeeUsd(quote.feeUsd + quote.gasFeeUsd) + setReceiveAmount(quote.receiveAmount) + // Bridge path: mode='pay' → user pays exactly `payAmount` at source; any + // Rhino fee comes out of the destination amount. So the kernel needs + // `payAmount` USDC on-hand (same as the principal). + setPayAmount(quote.payAmount) + // Verbatim. `feeUsd` is already Rhino's total — adding gas on top (the + // pre-2026-09 code) double-counted it. + setFeeUsd(quote.feeUsd) setEstimatedGasCostUsd(0) // gas paid by paymaster — same as SDA path setIsFeeEstimationError(false) setQuoteExpiresAt(quote.expiresAt) @@ -576,7 +586,7 @@ async function buildSameChainTx({ } interface RhinoResultParams { - preview: SdaPreviewResult + preview: RhinoQuote sda: SdaTransferResult source: CrossChainSourceInfo setTransactions: (tx: PreparedTransaction[] | null) => void @@ -588,6 +598,7 @@ interface RhinoResultParams { setMaxDepositLimitUsd: (v: number | undefined) => void setEstimatedGasCostUsd: (v: number | undefined) => void setIsFeeEstimationError: (v: boolean) => void + setQuoteExpiresAt: (v: string | null) => void } function applyRhinoResult({ @@ -603,6 +614,7 @@ function applyRhinoResult({ setMaxDepositLimitUsd, setEstimatedGasCostUsd, setIsFeeEstimationError, + setQuoteExpiresAt, }: RhinoResultParams): void { // USDC/USDT are both 6-decimal on every chain we support. const STABLECOIN_DECIMALS = 6 @@ -622,16 +634,19 @@ function applyRhinoResult({ ]) setSdaAddress(sda.sdaAddress) setReceiveAmount(preview.receiveAmount) - // SDA path uses mode='receive' (preview at line ~310) — Rhino takes the fee - // at source. `payAmount` IS `principal + fee` and matches the on-chain - // transfer amount we just encoded above. Callers routing through - // sendTransactions({ requiredUsdcAmount }) MUST pass this — not the - // principal — or the kernel's collateral-sweep under-funds and the - // transfer reverts with `ERC20: transfer amount exceeds balance`. + // SDA path uses mode='receive' — any fee Rhino quotes is taken at source, so + // `payAmount` IS `principal + quoted fee` (== principal under the current + // 1:1 account config) and matches the on-chain transfer amount we just + // encoded above. Callers routing through sendTransactions({ requiredUsdcAmount }) + // MUST pass this — not the principal — or the kernel's collateral-sweep + // under-funds and the transfer reverts with `ERC20: transfer amount exceeds balance`. setPayAmount(preview.payAmount) setFeeUsd(preview.feeUsd) setMinDepositLimitUsd(sda.minDepositLimitUsd) setMaxDepositLimitUsd(sda.maxDepositLimitUsd) + // The SDA deposit is not bound to the quote (no commit), so the numbers on + // screen are only Rhino's word until this expiry — callers re-quote past it. + setQuoteExpiresAt(preview.expiresAt) // Gas for a plain ERC20 transfer is absorbed by the kernel paymaster; // the user-visible cost is the Rhino bridge fee (already in preview). diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json index 471e43fe6c..623d13ae64 100644 --- a/src/i18n/app/messages/en.json +++ b/src/i18n/app/messages/en.json @@ -1859,14 +1859,14 @@ }, "confirm": { "recipientReceives": "Recipient receives", - "recipientReceivesInfo": "The full amount arrives on the destination chain. The cross-chain network fee is paid on top — see below.", + "recipientReceivesInfo": "Peanut covers the bridge cost. A small network cost may be deducted on delivery.", "tokenAndNetwork": "Token and network", "tokenOnChain": "{token} on {chain}", "tokenAlt": "token", "chainAlt": "chain", "to": "To", "networkFee": "Network fee", - "networkFeeInfo": "Cross-chain bridge fee (destination gas + Rhino's 0.07%). Paid on top of the amount withdrawn.", + "networkFeeInfo": "Peanut covers the bridge cost. A small network cost may be deducted on delivery.", "youPay": "You pay", "highFeeWarning": "Note: the network fee is a large share of this withdrawal. Withdrawing a larger amount or choosing a cheaper network reduces it." }, diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json index a50ee0165c..d6c2b6260c 100644 --- a/src/i18n/app/messages/es-419.json +++ b/src/i18n/app/messages/es-419.json @@ -1859,14 +1859,14 @@ }, "confirm": { "recipientReceives": "El destinatario recibe", - "recipientReceivesInfo": "El monto completo llega a la red de destino. La comisión de red entre cadenas se paga aparte: mira abajo.", + "recipientReceivesInfo": "Peanut cubre el costo del puente. Es posible que se descuente un pequeño costo de red al entregar.", "tokenAndNetwork": "Token y red", "tokenOnChain": "{token} en {chain}", "tokenAlt": "token", "chainAlt": "red", "to": "Para", "networkFee": "Comisión de red", - "networkFeeInfo": "Comisión del puente entre cadenas (gas de destino + 0.07% de Rhino). Se paga aparte del monto retirado.", + "networkFeeInfo": "Peanut cubre el costo del puente. Es posible que se descuente un pequeño costo de red al entregar.", "youPay": "Pagas", "highFeeWarning": "Nota: la comisión de red es una parte grande de este retiro. Retirar un monto mayor o elegir una red más barata la reduce." }, diff --git a/src/i18n/app/messages/es-AR.json b/src/i18n/app/messages/es-AR.json index 7ff6523588..48feacf202 100644 --- a/src/i18n/app/messages/es-AR.json +++ b/src/i18n/app/messages/es-AR.json @@ -919,7 +919,6 @@ "placeholderAddress": "Ingresá una dirección" }, "confirm": { - "recipientReceivesInfo": "El monto completo llega a la red de destino. La comisión de red entre cadenas se paga aparte: mirá abajo.", "youPay": "Pagás" }, "compatibilityModal": { diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json index b90202a9c1..1f4b76e117 100644 --- a/src/i18n/app/messages/pt-BR.json +++ b/src/i18n/app/messages/pt-BR.json @@ -1859,14 +1859,14 @@ }, "confirm": { "recipientReceives": "O destinatário recebe", - "recipientReceivesInfo": "O valor completo chega na rede de destino. A taxa de rede entre redes é paga à parte: veja abaixo.", + "recipientReceivesInfo": "Peanut cobre o custo da ponte. Um pequeno custo de rede pode ser descontado na entrega.", "tokenAndNetwork": "Token e rede", "tokenOnChain": "{token} na {chain}", "tokenAlt": "token", "chainAlt": "rede", "to": "Para", "networkFee": "Taxa de rede", - "networkFeeInfo": "Taxa da ponte entre redes (gas no destino + 0,07% da Rhino). Paga à parte do valor sacado.", + "networkFeeInfo": "Peanut cobre o custo da ponte. Um pequeno custo de rede pode ser descontado na entrega.", "youPay": "Você paga", "highFeeWarning": "Atenção: a taxa de rede é uma parte grande deste saque. Sacar um valor maior ou escolher uma rede mais barata reduz a taxa." }, diff --git a/src/services/rhino-bridge.ts b/src/services/rhino-bridge.ts index 2d3d0dcaa0..19c4396cca 100644 --- a/src/services/rhino-bridge.ts +++ b/src/services/rhino-bridge.ts @@ -12,6 +12,7 @@ */ import { apiFetch } from '@/utils/api-fetch' +import type { RhinoQuote } from '@/services/rhino-sda' export interface BridgeQuoteParams { amount: string @@ -25,15 +26,7 @@ export interface BridgeQuoteParams { mode: 'pay' | 'receive' } -export interface BridgeQuoteResponse { - quoteId: string - amountIn: string - amountOut: string - fee: string - feeUsd: number - gasFeeUsd: number - estimatedDuration?: number - expiresAt: string // ISO timestamp +export interface BridgeQuoteResponse extends RhinoQuote { /** Backend echoes this so the FE passes it back through commit — discriminates * the Rhino finalisation path (getSwapCalldata vs deposit-address). */ isSwap: boolean @@ -109,11 +102,14 @@ export function getBridgeChains(): Promise<{ chains: BridgeChainConfig[] }> { return getJson('/rhino/bridge/chains', 'Failed to get bridge chains') } +/** How long signing/broadcast needs: a quote closer to expiry than this is treated as expired everywhere. */ +export const QUOTE_SIGNING_LEAD_MS = 15_000 + /** * Returns true when the quote is within the near-expiry window (default 15s). * Hooks should re-quote before commit to avoid Rhino rejecting an expired ID. */ -export function isQuoteNearExpiry(expiresAt: string, leadTimeMs = 15_000): boolean { +export function isQuoteNearExpiry(expiresAt: string, leadTimeMs = QUOTE_SIGNING_LEAD_MS): boolean { const expires = new Date(expiresAt).getTime() return Number.isFinite(expires) && Date.now() + leadTimeMs >= expires } diff --git a/src/services/rhino-sda.ts b/src/services/rhino-sda.ts index ef9065188f..3f0b371b44 100644 --- a/src/services/rhino-sda.ts +++ b/src/services/rhino-sda.ts @@ -69,14 +69,38 @@ export interface SdaPreviewRequest { token: RhinoSupportedToken amount: string // decimal mode: 'pay' | 'receive' + /** The kernel wallet that will deposit. The quote is account- and + * address-bound (Rhino's authenticated quote), so both are required. */ + depositor: string + /** Where Rhino delivers on `chainOut` — 0x, base58 or TRC20 per chain. */ + recipient: string } -export interface SdaPreviewResult { +/** + * The one normalized Rhino quote, as returned by both + * `/rhino/sda-transfer/preview` and `/rhino/bridge/quote`. `feeUsd` is + * Rhino's TOTAL fee and equals `payAmount − receiveAmount` in USD; the + * components are for audit only. Consumers show these numbers verbatim — + * never add the components on top, never derive a fee from the amounts. + */ +export interface RhinoQuote { + /** Decimal string in `tokenIn` units — the USDC the kernel deposits. */ payAmount: string payAmountUsd: number + /** Decimal string in `tokenOut` units — what the recipient gets (USDC on + * the SDA path, ETH etc. on a cross-token bridge). */ receiveAmount: string receiveAmountUsd: number feeUsd: number + fees: { + gasUsd: number + sourceGasUsd: number + platformUsd: number + percentageUsd: number + } + quoteId: string + expiresAt: string // ISO timestamp + estimatedDuration?: number } async function postRhino(path: string, body: TReq, errorLabel: string): Promise { @@ -114,6 +138,6 @@ export async function provisionSdaTransfer(body: SdaTransferRequest): Promise { +export async function previewSdaTransfer(body: SdaPreviewRequest): Promise { return postRhino('/rhino/sda-transfer/preview', body, 'Failed to preview SDA transfer') } diff --git a/src/types/api.generated.ts b/src/types/api.generated.ts index 0eef34e18a..d73e8d20c4 100644 --- a/src/types/api.generated.ts +++ b/src/types/api.generated.ts @@ -9693,6 +9693,8 @@ export interface paths { chainOut: string; mode: "pay" | "receive"; token: string; + depositor: string; + recipient: string; }; }; }; diff --git a/src/types/api.openapi.json b/src/types/api.openapi.json index f232378767..a8fa3521b9 100644 --- a/src/types/api.openapi.json +++ b/src/types/api.openapi.json @@ -15988,6 +15988,14 @@ "token": { "minLength": 1, "type": "string" + }, + "depositor": { + "minLength": 1, + "type": "string" + }, + "recipient": { + "minLength": 1, + "type": "string" } }, "required": [ @@ -15995,7 +16003,9 @@ "chainOut", "token", "amount", - "mode" + "mode", + "depositor", + "recipient" ], "type": "object" } diff --git a/src/utils/__tests__/claim-route.utils.test.ts b/src/utils/__tests__/claim-route.utils.test.ts new file mode 100644 index 0000000000..2132641d84 --- /dev/null +++ b/src/utils/__tests__/claim-route.utils.test.ts @@ -0,0 +1,64 @@ +import { findClaimRoute, resolveClaimQuoteRecipient } from '@/utils/claim-route.utils' +import type { ClaimXChainPreview } from '@/components/Claim/Claim.consts' + +const A = '0x1111111111111111111111111111111111111111' +const B = '0x2222222222222222222222222222222222222222' +const SENDER = '0x9999999999999999999999999999999999999999' +const USDC = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as const + +describe('resolveClaimQuoteRecipient', () => { + it('prices for the external EVM address the claimer typed', () => { + expect(resolveClaimQuoteRecipient({ recipientAddress: A, walletAddress: B, senderAddress: SENDER })).toBe(A) + }) + + it('falls back to the Peanut wallet, then the link sender, for a bank claim (IBAN is not an address)', () => { + expect( + resolveClaimQuoteRecipient({ + recipientAddress: 'DE89370400440532013000', + walletAddress: B, + senderAddress: SENDER, + }) + ).toBe(B) + expect(resolveClaimQuoteRecipient({ recipientAddress: 'DE89370400440532013000', senderAddress: SENDER })).toBe( + SENDER + ) + }) +}) + +describe('findClaimRoute — a quote is only reusable for the recipient it was priced for, and only until it expires', () => { + const NOW = Date.parse('2026-09-01T12:00:00Z') + const routeForA: ClaimXChainPreview = { + chainId: '8453', + tokenAddress: USDC, + receiveAmount: '10', + feeUsd: 0, + quotedFor: A, + expiresAt: new Date(NOW + 60_000).toISOString(), + } + + it('reuses the cached route for the same chain, token and recipient while fresh', () => { + expect( + findClaimRoute([routeForA], { chainId: '8453', tokenAddress: USDC.toLowerCase(), quotedFor: A }, NOW) + ).toBe(routeForA) + }) + + it('does not reuse it inside the signing lead either (10 s left is a miss, not a bounce loop)', () => { + expect( + findClaimRoute([routeForA], { chainId: '8453', tokenAddress: USDC, quotedFor: A }, NOW + 50_000) + ).toBeUndefined() + }) + + it('does not reuse it once the quote behind it has expired', () => { + expect( + findClaimRoute([routeForA], { chainId: '8453', tokenAddress: USDC, quotedFor: A }, NOW + 61_000) + ).toBeUndefined() + }) + + it('does not reuse it after the recipient switches to another address', () => { + expect(findClaimRoute([routeForA], { chainId: '8453', tokenAddress: USDC, quotedFor: B }, NOW)).toBeUndefined() + }) + + it('does not reuse it for another chain or token', () => { + expect(findClaimRoute([routeForA], { chainId: '1', tokenAddress: USDC, quotedFor: A }, NOW)).toBeUndefined() + }) +}) diff --git a/src/utils/claim-route.utils.ts b/src/utils/claim-route.utils.ts new file mode 100644 index 0000000000..c884ede0ac --- /dev/null +++ b/src/utils/claim-route.utils.ts @@ -0,0 +1,40 @@ +import { isAddress } from 'viem' +import type { ClaimXChainPreview } from '@/components/Claim/Claim.consts' +import { QUOTE_SIGNING_LEAD_MS } from '@/services/rhino-bridge' + +/** + * The address a cross-chain claim quote is priced for. Rhino's quote is + * account- and address-bound, so it needs an EVM address on the destination + * chain: the external wallet the claimer typed, else the Peanut wallet, else + * the link sender (always an EVM address — a bank claim's `recipient.address` + * is an IBAN or account number, which the quote would reject). + */ +export function resolveClaimQuoteRecipient(input: { + recipientAddress: string + walletAddress?: string + senderAddress: string +}): string { + if (isAddress(input.recipientAddress)) return input.recipientAddress + return input.walletAddress ?? input.senderAddress +} + +/** + * A cached route is reusable only for the recipient it was quoted for — + * switching the external address invalidates it even on the same chain/token + * — and only while Rhino's quote outlives the signing lead: an entry the + * confirm screen would already refuse is a miss, so the caller re-quotes + * instead of bouncing the user between the two screens. + */ +export function findClaimRoute( + routes: ClaimXChainPreview[], + key: { chainId: string; tokenAddress: string; quotedFor: string }, + now: number = Date.now() +): ClaimXChainPreview | undefined { + return routes.find( + (route) => + route.chainId === key.chainId && + route.tokenAddress.toLowerCase() === key.tokenAddress.toLowerCase() && + route.quotedFor.toLowerCase() === key.quotedFor.toLowerCase() && + new Date(route.expiresAt).getTime() > now + QUOTE_SIGNING_LEAD_MS + ) +} diff --git a/src/utils/cross-chain-fee.utils.test.ts b/src/utils/cross-chain-fee.utils.test.ts index f588b0d4ff..0eb7d7e6f8 100644 --- a/src/utils/cross-chain-fee.utils.test.ts +++ b/src/utils/cross-chain-fee.utils.test.ts @@ -1,4 +1,5 @@ import { + formatNetworkFee, isWithdrawFeeDisproportionate, getMinWithdrawUsdForChain, HIGH_WITHDRAW_FEE_RATIO, @@ -83,3 +84,22 @@ describe('getMinWithdrawUsdForChain', () => { expect(getMinWithdrawUsdForChain('1')).toBe(ETHEREUM_MIN_WITHDRAW_USD) }) }) + +describe('formatNetworkFee', () => { + test('is sponsored (null) when the transfer is same-chain, unquoted, or quoted at zero', () => { + expect(formatNetworkFee(0.51, false)).toBeNull() + expect(formatNetworkFee(undefined, true)).toBeNull() + expect(formatNetworkFee(0, true)).toBeNull() + expect(formatNetworkFee(-0.01, true)).toBeNull() + expect(formatNetworkFee(NaN, true)).toBeNull() + }) + + test('shows a quoted fee verbatim, to the cent', () => { + expect(formatNetworkFee(0.51, true)).toBe('$0.51') + expect(formatNetworkFee(1.5, true)).toBe('$1.50') + }) + + test('shows sub-cent fees as < $0.01 instead of $0.00', () => { + expect(formatNetworkFee(0.004, true)).toBe('< $0.01') + }) +}) diff --git a/src/utils/cross-chain-fee.utils.ts b/src/utils/cross-chain-fee.utils.ts index 9b4fa421fa..150d80f03c 100644 --- a/src/utils/cross-chain-fee.utils.ts +++ b/src/utils/cross-chain-fee.utils.ts @@ -1,15 +1,26 @@ /** - * Cross-chain withdrawal fee heads-up. + * Cross-chain withdrawal fee display and heads-up. * - * Rhino's bridge fee is `flat destination gas + 0.07%`, and gas is flat per - * chain (~$0.01 on L2s, ~$1.50+ on Ethereum mainnet). Because gas is a fixed - * per-chain cost, a small mainnet withdrawal loses a large share to it (a $10 → - * mainnet withdraw is ~15%). We don't block it — the fee is shown honestly and - * the user decides — but we surface a non-blocking heads-up so a tiny mainnet - * withdrawal isn't a silent footgun. L2s and larger amounts stay below the - * threshold and show nothing. + * The app quotes with Rhino's authenticated (account-bound) quote, and + * Peanut's account is configured 1:1 with no on-chain fee on stablecoin + * routes — so `feeUsd` is normally 0 and the row shows the sponsored label. + * Rhino can still deduct a small network cost on delivery (1–3 bps seen on + * Solana) and the account config can change, so everything here reads the + * quote verbatim and never assumes zero: a non-zero quote is shown as-is, and + * when it is a large share of a small withdrawal we surface a non-blocking + * heads-up rather than block. */ +/** + * The network-fee row value for a quoted transfer. `null` when the user pays + * nothing on top (same-chain, no quote yet, or a zero quote) — the caller + * shows the sponsored label; '< $0.01' below a cent; otherwise '$X.XX'. + */ +export function formatNetworkFee(feeUsd: number | undefined, isCrossChain: boolean): string | null { + if (!isCrossChain || feeUsd === undefined || !Number.isFinite(feeUsd) || feeUsd <= 0) return null + return feeUsd < 0.01 ? '< $0.01' : `$${feeUsd.toFixed(2)}` +} + /** Surface the heads-up when the bridge fee exceeds this share of the amount. */ export const HIGH_WITHDRAW_FEE_RATIO = 0.05 // 5%