(
+ () =>
+ isCrossChainWithdrawal && isBelowRhinoMinDeposit(payAmount, minDepositLimitUsd)
+ ? `The minimum withdrawal to this network is $${minDepositLimitUsd}. Enter a larger amount.`
+ : null,
+ [isCrossChainWithdrawal, payAmount, minDepositLimitUsd]
+ )
+
if (!amountToWithdraw && currentView !== 'STATUS') {
// Redirect to main withdraw page for amount input
// Guard against STATUS view: resetWithdrawFlow() clears amountToWithdraw,
@@ -506,6 +519,7 @@ export default function WithdrawCryptoPage() {
payAmount={payAmount}
showHighFeeWarning={showHighFeeWarning}
insufficientBalance={insufficientForFee}
+ belowMinimumMessage={belowMinimumMessage}
/>
)}
diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx
index 6eb1a7f91..59b1bf14b 100644
--- a/src/app/(mobile-ui)/withdraw/page.tsx
+++ b/src/app/(mobile-ui)/withdraw/page.tsx
@@ -114,14 +114,22 @@ export default function WithdrawPage() {
return { countryIso2: '', rateAccountType: AccountType.US }
}, [selectedBankAccount, selectedMethod])
+ // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't
+ // apply. selectedMethod is the routing source of truth (a stale bank method
+ // from an abandoned withdraw still routes to the bank flow, so it must keep
+ // its minimum); the URL param only covers the first render before the mount
+ // effect commits the crypto method.
+ const isCryptoWithdraw = selectedMethod ? selectedMethod.type === 'crypto' : isCryptoFromSend
+
// fetch exchange rate for non-USD countries to convert local minimum to USD
const { exchangeRate } = useGetExchangeRate({
accountType: rateAccountType,
- enabled: rateAccountType !== AccountType.US && countryIso2 !== '',
+ enabled: !isCryptoWithdraw && rateAccountType !== AccountType.US && countryIso2 !== '',
})
// compute minimum withdrawal in USD using the exchange rate
const minUsdAmount = useMemo(() => {
+ if (isCryptoWithdraw) return 0 // any amount > 0 is valid, same as send-via-link
const localMin = getMinimumAmount(countryIso2)
// for US or unknown, minimum is already in USD
if (!countryIso2 || countryIso2 === 'US') return localMin
@@ -131,7 +139,7 @@ export default function WithdrawPage() {
const rate = parseFloat(exchangeRate || '0')
if (rate <= 0) return 1 // fallback while rate is loading
return Math.ceil(localMin / rate)
- }, [countryIso2, exchangeRate])
+ }, [isCryptoWithdraw, countryIso2, exchangeRate])
// validate against user's limits for bank withdrawals
// note: crypto withdrawals don't have fiat limits
@@ -375,8 +383,7 @@ export default function WithdrawPage() {
if (step === 'inputAmount') {
// only show limits card for bank/manteca withdrawals, not crypto
- const showLimitsCard =
- selectedMethod?.type !== 'crypto' && (limitsValidation.isBlocking || limitsValidation.isWarning)
+ const showLimitsCard = !isCryptoWithdraw && (limitsValidation.isBlocking || limitsValidation.isWarning)
return (
@@ -433,8 +440,7 @@ export default function WithdrawPage() {
onClick={handleAmountContinue}
disabled={
isContinueDisabled ||
- (selectedMethod?.type !== 'crypto' &&
- (limitsValidation.isLoading || limitsValidation.isBlocking))
+ (!isCryptoWithdraw && (limitsValidation.isLoading || limitsValidation.isBlocking))
}
className="w-full"
>
diff --git a/src/components/Badges/badge.utils.ts b/src/components/Badges/badge.utils.ts
index 3d5905471..f02a7bfa6 100644
--- a/src/components/Badges/badge.utils.ts
+++ b/src/components/Badges/badge.utils.ts
@@ -203,6 +203,12 @@ export const BADGES: Record = {
description: 'You danced the quadrilha with us. Arraiá unlocked.',
displayName: 'Arraiá Approved',
},
+ // Argentine superfan in-joke badge — look closer. IYKYK.
+ MANICERO: {
+ path: '/badges/manicero.svg',
+ description: 'Small maní. Big energy. Manicero.',
+ displayName: 'Manicero',
+ },
TOUCHED_GRASS: {
path: '/badges/touched_grass.svg',
description: 'You logged off and touched real grass with Peanut.',
diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts
index 1ec55e9ff..3faef6afc 100644
--- a/src/components/Invites/campaign-maps.ts
+++ b/src/components/Invites/campaign-maps.ts
@@ -25,6 +25,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record = {
cardalpha: 'CARD_ALPHA',
psyops: 'PSYOPS_DIVISION',
founding: 'FOUNDING_PIONEER',
+ manicero: 'MANICERO',
}
// Map inbound `utm_campaign` values to the badge codes the backend whitelists.
@@ -41,6 +42,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record = {
'festa-junina': 'FESTA_JUNINA_2026',
'card-alpha': 'CARD_ALPHA',
'irl-nomads': 'IRL_NOMADS',
+ manicero: 'MANICERO',
}
// Resolve the effective campaign (a badge code, or a raw passthrough tag) from
@@ -86,6 +88,7 @@ export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set([
'festa_junina_2026',
'irl_nomads',
'offramp_user',
+ 'manicero',
])
export type CampaignClassification = {
diff --git a/src/components/Withdraw/views/Confirm.withdraw.view.tsx b/src/components/Withdraw/views/Confirm.withdraw.view.tsx
index 4c2bc0e21..a411f9340 100644
--- a/src/components/Withdraw/views/Confirm.withdraw.view.tsx
+++ b/src/components/Withdraw/views/Confirm.withdraw.view.tsx
@@ -56,6 +56,12 @@ interface WithdrawConfirmViewProps {
* sign into the misleading "balance still settling" send error.
*/
insufficientBalance?: boolean
+ /**
+ * Set when the cross-chain deposit falls below Rhino's route minimum — the
+ * bridge would accept the funds on-chain but never deliver them. Blocks the
+ * CTA and shows the message.
+ */
+ belowMinimumMessage?: string | null
}
export default function ConfirmWithdrawView({
@@ -75,6 +81,7 @@ export default function ConfirmWithdrawView({
payAmount,
showHighFeeWarning = false,
insufficientBalance = false,
+ belowMinimumMessage = null,
}: WithdrawConfirmViewProps) {
const { tokenIconUrl, chainIconUrl, resolvedChainName, resolvedTokenSymbol } = useTokenChainIcons({
chainId: chain.chainId,
@@ -210,7 +217,7 @@ export default function ConfirmWithdrawView({
variant="purple"
shadowSize="4"
onClick={onConfirm}
- disabled={isProcessing || isCalculating || insufficientBalance}
+ disabled={isProcessing || isCalculating || insufficientBalance || !!belowMinimumMessage}
loading={isProcessing}
className="w-full"
>
@@ -219,6 +226,9 @@ export default function ConfirmWithdrawView({
)}
{insufficientBalance && !error && }
+ {belowMinimumMessage && !insufficientBalance && !error && (
+
+ )}
{error && }
diff --git a/src/utils/__tests__/withdraw.utils.test.ts b/src/utils/__tests__/withdraw.utils.test.ts
index 6512014b7..8a0755069 100644
--- a/src/utils/__tests__/withdraw.utils.test.ts
+++ b/src/utils/__tests__/withdraw.utils.test.ts
@@ -8,6 +8,7 @@ import {
validatePixKey,
getCountryCodeForWithdraw,
getCountryFromIban,
+ isBelowRhinoMinDeposit,
} from '@/utils/withdraw.utils'
jest.mock('@/assets', () => ({}))
@@ -392,4 +393,26 @@ describe('Withdraw Utilities', () => {
})
})
})
+
+ // Guards the confirm CTA on cross-chain withdrawals: a sub-minimum SDA
+ // deposit is accepted on-chain but never bridged (funds strand, uncredited),
+ // reachable since the amount step dropped its blanket $1 crypto minimum.
+ describe('isBelowRhinoMinDeposit', () => {
+ test('blocks when the deposit is below the route minimum', () => {
+ expect(isBelowRhinoMinDeposit('0.50', 5)).toBe(true)
+ })
+
+ test('allows at or above the minimum', () => {
+ expect(isBelowRhinoMinDeposit('5.00', 5)).toBe(false)
+ expect(isBelowRhinoMinDeposit('12.34', 5)).toBe(false)
+ })
+
+ test('stays permissive while values are unknown (CTA already gated by isCalculating)', () => {
+ expect(isBelowRhinoMinDeposit(null, 5)).toBe(false)
+ expect(isBelowRhinoMinDeposit(undefined, 5)).toBe(false)
+ expect(isBelowRhinoMinDeposit('0.50', null)).toBe(false)
+ expect(isBelowRhinoMinDeposit('0.50', undefined)).toBe(false)
+ expect(isBelowRhinoMinDeposit('not-a-number', 5)).toBe(false)
+ })
+ })
})
diff --git a/src/utils/withdraw.utils.ts b/src/utils/withdraw.utils.ts
index 14a264a69..af4f68d07 100644
--- a/src/utils/withdraw.utils.ts
+++ b/src/utils/withdraw.utils.ts
@@ -330,3 +330,20 @@ export const validatePixKey = (pixKey: string): { valid: boolean; message?: stri
message: 'Invalid PIX key format. Must be phone, CPF, CNPJ, email, random key, or QR code',
}
}
+
+/**
+ * True when a cross-chain (Rhino SDA) withdrawal's on-chain deposit would fall
+ * below the route's minimum. Rhino accepts sub-minimum SDA deposits on-chain
+ * but never bridges them — the funds strand at the SDA and the user is never
+ * credited — so the confirm CTA must block. `payAmount` is the actual SDA
+ * deposit (principal + fee, USD-stable). Unknown values (quote still loading,
+ * no limit reported) return false — the CTA is already gated by isCalculating.
+ */
+export const isBelowRhinoMinDeposit = (
+ payAmount: string | null | undefined,
+ minDepositLimitUsd: number | null | undefined
+): boolean => {
+ if (payAmount == null || minDepositLimitUsd == null) return false
+ const pay = parseFloat(payAmount)
+ return Number.isFinite(pay) && pay < minDepositLimitUsd
+}