Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,8 @@ const mockCrossChainTransfer = {
isCalculating: false,
isXChain: false,
isDiffToken: false,
isFeeEstimationError: false,
isQuoteExpired: false,
error: null,
calculate: jest.fn(),
reset: jest.fn(),
Expand Down Expand Up @@ -274,7 +276,20 @@ const confirm = async () => {
beforeEach(() => {
jest.clearAllMocks()
mockRecordPayment.mockResolvedValue(PAYMENT_RESULT)
Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false })
Object.assign(mockCrossChainTransfer, { isXChain: false, isDiffToken: false, isQuoteExpired: false })
})

describe('crypto withdraw confirm — expired Rhino quote', () => {
it('re-quotes instead of signing when the quote on screen has expired', async () => {
Object.assign(mockCrossChainTransfer, { isXChain: true, isQuoteExpired: true })

await confirm()

await waitFor(() => expect(mockCrossChainTransfer.calculate).toHaveBeenCalled())
expect(mockSendTransactions).not.toHaveBeenCalled()
expect(mockSendMoney).not.toHaveBeenCalled()
expect(mockSetCurrentView).not.toHaveBeenCalledWith('STATUS')
})
})

// ---------- tests ----------
Expand Down
69 changes: 44 additions & 25 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@
isXChain,
isDiffToken,
error: routeError,
isFeeEstimationError,
isQuoteExpired,
calculate: calculateRoute,
reset: resetRouteCalculation,
} = useCrossChainTransfer()
Expand Down Expand Up @@ -164,33 +166,39 @@
}
}, [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<WithdrawData, 'amount'>) => {
Expand Down Expand Up @@ -343,6 +351,16 @@
return
}

// The numbers on screen are Rhino's quote only until it expires. Past
// that, refresh them 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
if (isQuoteExpired && !alreadySpent) {
Comment thread
abalinda marked this conversation as resolved.
Outdated
await quoteRoute()
Comment thread
abalinda marked this conversation as resolved.
return
}

clearErrors()
setIsSendingTx(true)

Expand Down Expand Up @@ -523,7 +541,7 @@
} finally {
setIsSendingTx(false)
}
}, [

Check warning on line 544 in src/app/(mobile-ui)/withdraw/crypto/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useCallback has missing dependencies: 'isQuoteExpired' and 'quoteRoute'. Either include them or remove the dependency array
chargeDetails,
withdrawData,
amountToWithdraw,
Expand Down Expand Up @@ -648,6 +666,7 @@
networkFee={networkFee}
isCrossChain={isCrossChainWithdrawal}
isCalculating={isCalculating}
quoteFailed={isFeeEstimationError}
receiveAmount={receiveAmount}
payAmount={payAmount}
showHighFeeWarning={showHighFeeWarning}
Expand Down
3 changes: 3 additions & 0 deletions src/components/Claim/Claim.consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ 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
}
export type ClaimType = 'claim' | 'claimxchain'

Expand Down
46 changes: 42 additions & 4 deletions src/components/Claim/Link/Initial.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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 } from '@/utils/general.utils'
import { Button } from '@/components/0_Bruddle/Button'
Expand Down Expand Up @@ -608,6 +609,22 @@ 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
setSelectedRoute(undefined)
setHasFetchedRoute(false)
setRefetchXchainRoute(true)
}, [recipient.address, address, claimLinkData.senderAddress, selectedRoute, setSelectedRoute, setHasFetchedRoute])

useEffect(() => {
if (!selectedTokenData) return
if (
Expand Down Expand Up @@ -644,11 +661,16 @@ 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,
})

Comment thread
abalinda marked this conversation as resolved.
try {
const existingRoute = routes.find(
(route) => route.chainId === chainId && areEvmAddressesEqual(route.tokenAddress, tokenAddress)
)
const existingRoute = findClaimRoute(routes, { chainId, tokenAddress, quotedFor })
Comment thread
abalinda marked this conversation as resolved.

if (existingRoute) {
setSelectedRoute(existingRoute)
Expand Down Expand Up @@ -676,19 +698,25 @@ 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,
Comment thread
abalinda marked this conversation as resolved.
recipient: quotedFor,
})

const route: ClaimXChainPreview = {
chainId,
tokenAddress: tokenAddress as Address,
receiveAmount: preview.receiveAmount,
feeUsd: preview.feeUsd,
quotedFor,
}

setRoutes([...routes, route])
Expand All @@ -714,7 +742,17 @@ export const InitialClaimLinkView = (props: IClaimScreenProps) => {
setLoadingState('Idle')
}
},
[claimLinkData, isXChain, selectedTokenData, setLoadingState, routes, setHasFetchedRoute, setSelectedRoute]
[
claimLinkData,
isXChain,
selectedTokenData,
setLoadingState,
routes,
setHasFetchedRoute,
setSelectedRoute,
recipient.address,
address,
]
)

useEffect(() => {
Expand Down
12 changes: 7 additions & 5 deletions src/components/Claim/Link/Onchain/Confirm.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -78,9 +79,6 @@ 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
Expand Down Expand Up @@ -235,8 +233,12 @@ export const ConfirmClaimLinkView = ({
/>
}

{/* Max network fee row */}
<PaymentInfoRow label={t('confirm.maxNetworkFee')} value={networkFeeDisplay} />
{/* Max network fee row — the route preview's quoted fee, verbatim */}
<NetworkFeeRow
label={t('confirm.maxNetworkFee')}
feeUsd={selectedRoute?.feeUsd}
Comment thread
abalinda marked this conversation as resolved.
isCrossChain={!!selectedRoute}
/>

{/* Peanut fee row */}
<PaymentInfoRow label={tCommon('peanutFee')} value={'$ 0.00'} hideBottomBorder />
Expand Down
102 changes: 102 additions & 0 deletions src/components/Claim/Link/Onchain/__tests__/Confirm.view.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
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 }: { children: React.ReactNode }) => <button>{children}</button>,
}))
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 }) }))
jest.mock('../../../useClaimLink', () => ({
__esModule: true,
default: () => ({ claimLinkXchain: jest.fn(), 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 { ConfirmClaimLinkView } from '../Confirm.view'

const props = {
onNext: jest.fn(),
onPrev: 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<typeof ConfirmClaimLinkView>

describe('ConfirmClaimLinkView — max network fee row', () => {
it('shows the sponsored label for a zero-fee cross-chain route', () => {
renderWithIntl(
<ConfirmClaimLinkView
{...props}
selectedRoute={{
chainId: '8453',
tokenAddress: '0xusdc',
receiveAmount: '10',
feeUsd: 0,
quotedFor: '0x2222',
}}
/>
)
expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument()
})

it('shows a quoted route fee verbatim', () => {
renderWithIntl(
<ConfirmClaimLinkView
{...props}
selectedRoute={{
chainId: '8453',
tokenAddress: '0xusdc',
receiveAmount: '9.5',
feeUsd: 0.5,
quotedFor: '0x2222',
}}
/>
)
expect(screen.getByText('$0.50')).toBeInTheDocument()
})
})
Loading
Loading