Skip to content
Open
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()
})
})
Original file line number Diff line number Diff line change
@@ -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(<NetworkFeeRow label="Network fee" feeUsd={0} isCrossChain />)
expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument()
})

it('shows a non-zero quote verbatim', () => {
renderWithIntl(<NetworkFeeRow label="Network fee" feeUsd={0.51} isCrossChain />)
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(<NetworkFeeRow label="Network fee" feeUsd={0.51} isCrossChain={false} />)
expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument()
})

it('strikes through paymaster-covered gas next to the sponsored label', () => {
renderWithIntl(<NetworkFeeRow label="Network fee" isCrossChain={false} sponsoredGasUsd={0.05} />)
expect(screen.getByText('$ 0.05')).toHaveClass('line-through')
expect(screen.getByText('Sponsored by Peanut!')).toBeInTheDocument()
})

it('shows a dash when estimation failed', () => {
renderWithIntl(<NetworkFeeRow label="Network fee" isCrossChain estimationFailed />)
expect(screen.getByText('-')).toBeInTheDocument()
})
})
68 changes: 68 additions & 0 deletions src/components/Global/NetworkFeeRow/index.tsx
Original file line number Diff line number Diff line change
@@ -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 = (
<>
<span className="line-through">$ {sponsoredGasUsd.toFixed(2)}</span>
{' - '}
<span className="font-medium text-foreground-secondary">{tCommon('sponsoredByPeanut')}</span>
</>
)
} else {
value = tCommon('sponsoredByPeanut')
}

return (
<PaymentInfoRow
label={label}
value={value}
loading={loading}
moreInfoText={moreInfoText}
hideBottomBorder={hideBottomBorder}
/>
)
}
Loading
Loading