- )}
+ {walletBalance &&
+ !hideBalance &&
+ (() => {
+ // A symbol sits against the number ($10.12), an ISO code
+ // takes a space (USD 10.12) — the CLDR rule for en-US,
+ // which is how the amount itself is formatted.
+ const balanceAmount = `${secondaryDenomination ? 'USD ' : '$'}${walletBalance}`
+ if (!fillValue) {
+ return (
+
+ )
+ }
+ // Only the amount is the action — "Balance:" stays a label,
+ // so the underline marks exactly what the tap fills in.
+ return (
+
+ {t('amountInput.balance')}
+
+
+ )
+ })()}
{/* Conversion toggle */}
{showConversion && (
diff --git a/src/context/WithdrawFlowContext.tsx b/src/context/WithdrawFlowContext.tsx
index 44d564b8d2..abcb1c030b 100644
--- a/src/context/WithdrawFlowContext.tsx
+++ b/src/context/WithdrawFlowContext.tsx
@@ -36,6 +36,14 @@ export interface RecipientState {
interface WithdrawFlowContextType {
amountToWithdraw: string
setAmountToWithdraw: (amount: string) => void
+ /**
+ * The user filled the amount by tapping their balance and has not edited it
+ * since, so they asked for "everything" rather than for the rounded number
+ * on screen. `amountToWithdraw` stays at the 2 decimals they saw; the crypto
+ * path reads this to settle the sub-cent remainder too (TASK-21899).
+ */
+ isMaxWithdrawal: boolean
+ setIsMaxWithdrawal: (isMax: boolean) => void
usdAmount: string
setUsdAmount: (amount: string) => void
currentView: WithdrawView
@@ -76,6 +84,7 @@ const WithdrawFlowContext = createContext(u
export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const [amountToWithdraw, setAmountToWithdraw] = useState('')
+ const [isMaxWithdrawal, setIsMaxWithdrawal] = useState(false)
const [usdAmount, setUsdAmount] = useState('')
const [currentView, setCurrentView] = useState('INITIAL')
const [withdrawData, setWithdrawData] = useState(null)
@@ -101,6 +110,7 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
const resetWithdrawFlow = useCallback(() => {
setAmountToWithdraw('')
+ setIsMaxWithdrawal(false)
// browser-back with the compatibility modal open leaves it armed for the
// next /withdraw/crypto entry — reset must close it like everything else
setShowCompatibilityModal(false)
@@ -123,6 +133,8 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
() => ({
amountToWithdraw,
setAmountToWithdraw,
+ isMaxWithdrawal,
+ setIsMaxWithdrawal,
usdAmount,
setUsdAmount,
currentView,
@@ -159,6 +171,7 @@ export const WithdrawFlowContextProvider: React.FC<{ children: ReactNode }> = ({
}),
[
amountToWithdraw,
+ isMaxWithdrawal,
currentView,
withdrawData,
showCompatibilityModal,
diff --git a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
index 7a7e4bc0c7..8b413d7fae 100644
--- a/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
+++ b/src/features/payments/shared/hooks/__tests__/useCrossChainTransfer.test.ts
@@ -79,7 +79,7 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
})
})
- it('SDA path: sends depositor/recipient to the preview and exposes feeUsd and payAmount as quoted', async () => {
+ it('SDA withdraw: quotes pay mode by the source amount, so a full-balance withdraw never needs more than the balance', async () => {
mockPreviewSdaTransfer.mockResolvedValue(quote(0))
const { result } = renderHook(() => useCrossChainTransfer())
@@ -101,7 +101,7 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
})
expect(mockPreviewSdaTransfer).toHaveBeenCalledWith(
- expect.objectContaining({ depositor: KERNEL, recipient: RECIPIENT, mode: 'receive', amount: '10' })
+ expect.objectContaining({ depositor: KERNEL, recipient: RECIPIENT, mode: 'pay', amount: '10' })
)
expect(result.current.path).toBe('sda')
expect(result.current.feeUsd).toBe(0)
@@ -111,6 +111,31 @@ describe('useCrossChainTransfer — feeUsd is the quote, verbatim', () => {
expect(result.current.error).toBeNull()
})
+ it('SDA pay-request: quotes receive mode by the destination amount (the payer covers any fee)', async () => {
+ mockPreviewSdaTransfer.mockResolvedValue(quote(0))
+ const { result } = renderHook(() => useCrossChainTransfer())
+
+ await act(async () => {
+ await result.current.calculate({
+ source: { ...source, tokenAmount: undefined },
+ destination: {
+ recipientAddress: RECIPIENT,
+ tokenAddress: USDC_ARB,
+ tokenAmount: '10',
+ tokenDecimals: 6,
+ tokenType: 1,
+ chainId: '8453',
+ tokenSymbol: 'USDC',
+ },
+ context: 'pay-request',
+ contextId: 'charge-3',
+ })
+ })
+
+ expect(mockPreviewSdaTransfer).toHaveBeenCalledWith(expect.objectContaining({ mode: 'receive', amount: '10' }))
+ 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())
diff --git a/src/features/payments/shared/hooks/useCrossChainTransfer.ts b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
index 4f387b6f5d..1730ae8459 100644
--- a/src/features/payments/shared/hooks/useCrossChainTransfer.ts
+++ b/src/features/payments/shared/hooks/useCrossChainTransfer.ts
@@ -325,12 +325,22 @@ export function useCrossChainTransfer(): UseCrossChainTransferReturn {
// 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.
+ // A withdraw is sized by what the user spends (pay mode, the
+ // source amount): whatever Rhino quotes as a fee comes out of
+ // the delivery, never on top, so a full-balance withdraw always
+ // fits the balance. A pay-request / claim is sized by what the
+ // recipient must get (receive mode): the payer covers any fee.
+ // Under the 1:1 account config both give the same numbers.
+ const withdraw = context === 'withdraw'
+ if (withdraw && !source.tokenAmount) {
+ throw new Error('Withdraw requires source.tokenAmount (the USDC amount the user is spending)')
+ }
const preview = await previewSdaTransfer({
chainIn: sourceRhinoChain,
chainOut: destRhinoChain,
token: tokenSymbol,
- amount: destination.tokenAmount,
- mode: 'receive', // UI always asks "merchant gets X" — user pays X + quoted fee
+ amount: withdraw ? source.tokenAmount! : destination.tokenAmount,
+ mode: withdraw ? 'pay' : 'receive',
depositor: source.address,
recipient: destination.recipientAddress,
})
@@ -634,12 +644,13 @@ function applyRhinoResult({
])
setSdaAddress(sda.sdaAddress)
setReceiveAmount(preview.receiveAmount)
- // 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`.
+ // `payAmount` is the quote's pay side and matches the on-chain transfer
+ // amount we just encoded above: the source amount on a withdraw (pay
+ // mode), principal + quoted fee on a pay-request (receive mode) — the same
+ // number under the current 1:1 account config. 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)
diff --git a/src/i18n/app/messages/en.json b/src/i18n/app/messages/en.json
index f58c0f71da..7392d38675 100644
--- a/src/i18n/app/messages/en.json
+++ b/src/i18n/app/messages/en.json
@@ -2971,7 +2971,8 @@
},
"global": {
"amountInput": {
- "balance": "Balance:"
+ "balance": "Balance:",
+ "useFullBalance": "Use full balance: {balance}"
},
"tokenSelector": {
"selectAToken": "Select a token",
diff --git a/src/i18n/app/messages/es-419.json b/src/i18n/app/messages/es-419.json
index 2248ebd98d..ded23179c8 100644
--- a/src/i18n/app/messages/es-419.json
+++ b/src/i18n/app/messages/es-419.json
@@ -2971,7 +2971,8 @@
},
"global": {
"amountInput": {
- "balance": "Saldo:"
+ "balance": "Saldo:",
+ "useFullBalance": "Usar saldo completo: {balance}"
},
"tokenSelector": {
"selectAToken": "Elige un token",
diff --git a/src/i18n/app/messages/pt-BR.json b/src/i18n/app/messages/pt-BR.json
index a01529b31b..5f82196557 100644
--- a/src/i18n/app/messages/pt-BR.json
+++ b/src/i18n/app/messages/pt-BR.json
@@ -2971,7 +2971,8 @@
},
"global": {
"amountInput": {
- "balance": "Saldo:"
+ "balance": "Saldo:",
+ "useFullBalance": "Usar saldo total: {balance}"
},
"tokenSelector": {
"selectAToken": "Escolha um token",
diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts
index 8abb3f0153..48b1fd71ec 100644
--- a/src/utils/__tests__/withdraw.utils.test.ts
+++ b/src/utils/__tests__/withdraw.utils.test.ts
@@ -9,7 +9,9 @@ import {
getCountryCodeForWithdraw,
getCountryFromIban,
isBelowRhinoMinDeposit,
+ resolveWithdrawAmount,
} from '@/utils/withdraw.utils'
+import { parseUnits } from 'viem'
jest.mock('@/assets', () => ({}))
@@ -434,3 +436,51 @@ describe('Withdraw Utilities', () => {
})
})
})
+
+describe('resolveWithdrawAmount', () => {
+ const USDC = 6
+ const balance = (v: string) => parseUnits(v, USDC)
+
+ it('returns the typed amount untouched when the user did not tap the balance', () => {
+ expect(resolveWithdrawAmount('10.12', balance('10.126123'), false, USDC)).toBe('10.12')
+ })
+
+ it('settles the sub-cent remainder after a full-balance tap', () => {
+ // the point of the flag: the wallet reaches a true zero instead of
+ // stranding 0.006123 that displays as $0.00 and can never be withdrawn
+ expect(resolveWithdrawAmount('10.12', balance('10.126123'), true, USDC)).toBe('10.126123')
+ })
+
+ it('ignores a deposit that lands between the tap and the confirm', () => {
+ // the user agreed to withdraw 10.12, not the 50 that just arrived
+ expect(resolveWithdrawAmount('10.12', balance('50.00'), true, USDC)).toBe('10.12')
+ })
+
+ it('returns the amount on screen when the balance dropped — it does not clamp', () => {
+ // Not an overdraw guard: shrinking the amount under the user would send
+ // less than they confirmed. The shortfall is caught downstream instead
+ // (cross-chain blocks the CTA, a same-chain send fails), exactly as it
+ // always has for a typed amount.
+ expect(resolveWithdrawAmount('10.12', balance('3.00'), true, USDC)).toBe('10.12')
+ })
+
+ it('holds the line when the balance moved by a sub-cent amount', () => {
+ // still floors to 10.12, so the remainder is the user's to take
+ expect(resolveWithdrawAmount('10.12', balance('10.129999'), true, USDC)).toBe('10.129999')
+ // dropped below the cent the user saw — return what they saw, unchanged
+ expect(resolveWithdrawAmount('10.12', balance('10.119999'), true, USDC)).toBe('10.12')
+ })
+
+ it('is a no-op on an exact-cent balance', () => {
+ expect(resolveWithdrawAmount('10.12', balance('10.12'), true, USDC)).toBe('10.12')
+ })
+
+ it('falls back while the balance is still loading', () => {
+ expect(resolveWithdrawAmount('10.12', undefined, true, USDC)).toBe('10.12')
+ })
+
+ it('falls back on an empty or unparseable amount', () => {
+ expect(resolveWithdrawAmount('', balance('10.126123'), true, USDC)).toBe('')
+ expect(resolveWithdrawAmount('abc', balance('10.126123'), true, USDC)).toBe('abc')
+ })
+})
diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts
index 6a3aca65b6..6ba24f6297 100644
--- a/src/utils/withdraw.utils.ts
+++ b/src/utils/withdraw.utils.ts
@@ -1,5 +1,6 @@
import { countryData, ALL_COUNTRIES_ALPHA3_TO_ALPHA2 } from '@/components/AddMoney/consts'
import { isValidEmail } from '@/utils/format.utils'
+import { formatUnits } from 'viem'
/**
* Extracts the country name from an IBAN by parsing the first 2 characters (country code)
@@ -357,3 +358,42 @@ export const isBelowRhinoMinDeposit = (
const pay = parseFloat(payAmount)
return Number.isFinite(pay) && pay < minDepositLimitUsd
}
+
+/**
+ * The amount a crypto withdrawal should actually move.
+ *
+ * "Use full balance" fills the balance rounded down to cents, and that rounded
+ * number is what the user reads on every screen of the flow. When they have not
+ * edited it since, they asked for everything — so the withdrawal settles the
+ * sub-cent remainder too and the wallet reaches a true zero, rather than
+ * stranding dust that displays as $0.00 and can never be withdrawn.
+ *
+ * The live balance is only used while it still floors to the amount on screen.
+ * Otherwise — a deposit landed, or the balance dropped — this returns the amount
+ * the user saw, unchanged.
+ *
+ * It deliberately does NOT clamp to the live balance. This function's only job is
+ * to decide whether the sub-cent remainder rides along; it never enlarges or
+ * shrinks what the user agreed to withdraw. Silently sending less than the
+ * confirmed amount would be worse than failing, and an amount that now exceeds
+ * the balance is caught downstream: cross-chain withdrawals block the confirm CTA
+ * on `insufficientForFee`, and a same-chain send fails rather than overdrawing.
+ * That is the same outcome a typed amount has always had when the balance moves.
+ *
+ * @param amount the amount on screen, as filled or typed (USD)
+ * @param spendableBalance live spendable balance in token units
+ * @param isMaxWithdrawal the amount came from the balance tap, unedited
+ */
+export const resolveWithdrawAmount = (
+ amount: string,
+ spendableBalance: bigint | undefined,
+ isMaxWithdrawal: boolean,
+ decimals: number
+): string => {
+ if (!isMaxWithdrawal || spendableBalance === undefined || !amount) return amount
+ const live = formatUnits(spendableBalance, decimals)
+ const liveNum = Number(live)
+ const amountNum = Number(amount)
+ if (!Number.isFinite(liveNum) || !Number.isFinite(amountNum)) return amount
+ return Math.floor(liveNum * 100) / 100 === amountNum ? live : amount
+}