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%