From 3a0d429fd03a84d74c49395981f8810903837b42 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 16 Jul 2026 14:57:17 +0200 Subject: [PATCH 1/4] feat(badges): add MANICERO badge asset + claim maps FE half of the Manicero badge (Argentine superfan in-joke, IYKYK). Claimable by existing users via bare ?campaign=manicero link (vanity, no waitlist skip) and by new users via ?code=manicero invite flow. Art + copy from Konrad's pick in mono inbox/manicero-badge/. --- public/badges/manicero.svg | 33 +++++++++++++++++++++++++ src/components/Badges/badge.utils.ts | 6 +++++ src/components/Invites/campaign-maps.ts | 2 ++ 3 files changed, 41 insertions(+) create mode 100644 public/badges/manicero.svg 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/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..e64cea262 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. @@ -86,6 +87,7 @@ export const BARE_VANITY_CAMPAIGNS: ReadonlySet = new Set([ 'festa_junina_2026', 'irl_nomads', 'offramp_user', + 'manicero', ]) export type CampaignClassification = { From 7936968b8afbb443e290cf9ed7f4a8cdd65709a5 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 16 Jul 2026 15:27:11 +0200 Subject: [PATCH 2/4] feat(badges): add manicero UTM alias Review finding: every sibling event badge has a UTM_CAMPAIGN_TO_BADGE_MAP alias; without it a ?utm_campaign=manicero link silently grants nothing. Also feeds PostHog's utm_* auto-capture for the campaign funnel. --- src/components/Invites/campaign-maps.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/Invites/campaign-maps.ts b/src/components/Invites/campaign-maps.ts index e64cea262..3faef6afc 100644 --- a/src/components/Invites/campaign-maps.ts +++ b/src/components/Invites/campaign-maps.ts @@ -42,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 From 9722998872c95d007c3efca2473a2b8db861b520 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 16 Jul 2026 17:48:51 +0200 Subject: [PATCH 3/4] fix: exempt crypto withdrawals from the fiat $1 minimum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared withdraw amount step applied getMinimumAmount() to every method; crypto has no country so it fell into the $1 default — blocking sub-$1 on-chain sends that send-via-link already allows. The minimum was introduced for bank offramps (295c8ec11, Bridge's $1 floor) and crypto only inherited it by sharing the page. Bank rails keep their country minimums. --- .../__tests__/withdraw-states.test.tsx | 33 +++++++++++++++++++ src/app/(mobile-ui)/withdraw/page.tsx | 6 +++- 2 files changed, 38 insertions(+), 1 deletion(-) 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..5d9f60c6e 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,39 @@ 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.', + }) + ) + expect(mockRouterPush).not.toHaveBeenCalled() + }) }) // ============================================================ diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 6eb1a7f91..5555657b8 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -120,8 +120,12 @@ export default function WithdrawPage() { enabled: rateAccountType !== AccountType.US && countryIso2 !== '', }) + // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't apply + const isCryptoWithdraw = selectedMethod?.type === 'crypto' || isCryptoFromSend + // 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 +135,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 From 7edcf0919a247e8ecf0e554f5deaa1a62f66b461 Mon Sep 17 00:00:00 2001 From: Aleksandar Balinda Date: Thu, 16 Jul 2026 18:06:02 +0200 Subject: [PATCH 4/4] fix: follow selectedMethod for the crypto exemption + gate sub-minimum Rhino deposits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review findings on the initial cut: - The exemption keyed off the URL param independently of routing: a stale bank selectedMethod (app-wide context) entering via /withdraw?method=crypto got min=$0 while Continue still routed to the bank flow. The predicate now follows selectedMethod when set; the URL only covers the pre-effect frame. - Removing the blanket $1 floor exposed the cross-chain path to sub-minimum Rhino SDA deposits, which are accepted on-chain but never bridged (stranded, uncredited — known incident class). The confirm CTA now blocks below the route's minDepositLimitUsd, which the page previously discarded. - Consolidated the two other inline is-crypto checks onto the same predicate, stopped the exchange-rate poll when crypto is selected, dropped a vacuous test assertion. --- .../__tests__/withdraw-states.test.tsx | 14 ++++++++++- src/app/(mobile-ui)/withdraw/crypto/page.tsx | 14 +++++++++++ src/app/(mobile-ui)/withdraw/page.tsx | 18 ++++++++------- .../Withdraw/views/Confirm.withdraw.view.tsx | 12 +++++++++- src/utils/__tests__/withdraw.utils.test.ts | 23 +++++++++++++++++++ src/utils/withdraw.utils.ts | 17 ++++++++++++++ 6 files changed, 88 insertions(+), 10 deletions(-) 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 5d9f60c6e..cb09a9589 100644 --- a/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx +++ b/src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx @@ -425,7 +425,19 @@ describe('GROUP 3: Amount Validation', () => { errorMessage: 'Minimum withdrawal is $1.', }) ) - expect(mockRouterPush).not.toHaveBeenCalled() + }) + + 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 07b8d9ee5..8da2a9096 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' @@ -72,6 +73,7 @@ export default function WithdrawCryptoPage() { receiveAmount, payAmount, feeUsd, + minDepositLimitUsd, isCalculating, isXChain, isDiffToken, @@ -465,6 +467,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, @@ -501,6 +514,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 5555657b8..59b1bf14b 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -114,15 +114,19 @@ 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 !== '', }) - // crypto withdrawals are plain on-chain transfers — fiat-rail minimums don't apply - const isCryptoWithdraw = selectedMethod?.type === 'crypto' || isCryptoFromSend - // 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 @@ -379,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 (
@@ -437,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/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 +}