diff --git a/public/badges/manicero.svg b/public/badges/manicero.svg new file mode 100644 index 000000000..15d2fb375 --- /dev/null +++ b/public/badges/manicero.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx index bb51cd3c8..cb09a9589 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -394,6 +394,51 @@ describe('GROUP 3: Amount Validation', () => { expect(screen.queryByTestId('error-alert')).not.toBeInTheDocument() expect(screen.getByTestId('limits-warning-card')).toBeInTheDocument() }) + + test('Crypto withdrawal allows sub-$1 amounts (no fiat-rail minimum)', () => { + // Regression: the shared amount step applied the bank $1 minimum to + // crypto (getMinimumAmount('') → 1), blocking sub-$1 on-chain sends + // that send-via-link already allows. + mockWithdrawFlow.selectedMethod = { type: 'crypto' } + mockWithdrawFlow.amountToWithdraw = '0.5' + + renderWithdraw() + + const continueBtn = screen.getByText('Continue') + expect(continueBtn).not.toBeDisabled() + + fireEvent.click(continueBtn) + expect(mockRouterPush).toHaveBeenCalledWith('/withdraw/crypto') + }) + + test('Bank withdrawal keeps the $1 minimum for sub-$1 amounts', async () => { + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + mockWithdrawFlow.amountToWithdraw = '0.5' + + renderWithdraw() + + expect(screen.getByText('Continue')).toBeDisabled() + // validation is debounced 300ms behind typing + await waitFor(() => + expect(mockSetError).toHaveBeenCalledWith({ + showError: true, + errorMessage: 'Minimum withdrawal is $1.', + }) + ) + }) + + test('Stale bank method entering via ?method=crypto keeps the bank minimum', () => { + // Regression: the crypto exemption must follow selectedMethod (the + // routing source of truth), not the URL param. A leftover bank method + // from an abandoned withdraw survives in the app-wide context and + // still routes Continue to the bank flow — so sub-$1 must stay blocked. + mockWithdrawFlow.selectedMethod = { type: 'bridge', countryPath: 'us' } + mockWithdrawFlow.amountToWithdraw = '0.5' + + renderWithdraw({ method: 'crypto' }) + + expect(screen.getByText('Continue')).toBeDisabled() + }) }) // ============================================================ diff --git a/src/app/(mobile-ui)/withdraw/crypto/page.tsx b/src/app/(mobile-ui)/withdraw/crypto/page.tsx index bb10dec6f..685153e04 100644 --- a/src/app/(mobile-ui)/withdraw/crypto/page.tsx +++ b/src/app/(mobile-ui)/withdraw/crypto/page.tsx @@ -19,6 +19,7 @@ import type { import { NATIVE_TOKEN_ADDRESS } from '@/utils/token.utils' import { isWithdrawFeeDisproportionate } from '@/utils/cross-chain-fee.utils' import { isAmountWithinBalance } from '@/utils/balance.utils' +import { isBelowRhinoMinDeposit } from '@/utils/withdraw.utils' import * as peanutInterfaces from '@/interfaces/peanut-sdk-types' import { useRouter } from 'next/navigation' import { useCallback, useContext, useEffect, useMemo, useState } from 'react' @@ -73,6 +74,7 @@ export default function WithdrawCryptoPage() { receiveAmount, payAmount, feeUsd, + minDepositLimitUsd, isCalculating, isXChain, isDiffToken, @@ -470,6 +472,17 @@ export default function WithdrawCryptoPage() { [isCrossChainWithdrawal, payAmount, spendableBalance] ) + // Rhino accepts SDA deposits below the route minimum on-chain but never + // bridges them — funds strand at the SDA, uncredited. Block the CTA before + // the user signs. Same-chain USDC transfers have no minimum. + const belowMinimumMessage = useMemo( + () => + 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 +}