Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions public/badges/manicero.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions src/app/(mobile-ui)/withdraw/__tests__/withdraw-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})

// ============================================================
Expand Down
14 changes: 14 additions & 0 deletions src/app/(mobile-ui)/withdraw/crypto/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -73,6 +74,7 @@ export default function WithdrawCryptoPage() {
receiveAmount,
payAmount,
feeUsd,
minDepositLimitUsd,
isCalculating,
isXChain,
isDiffToken,
Expand Down Expand Up @@ -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<string | null>(
() =>
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,
Expand Down Expand Up @@ -506,6 +519,7 @@ export default function WithdrawCryptoPage() {
payAmount={payAmount}
showHighFeeWarning={showHighFeeWarning}
insufficientBalance={insufficientForFee}
belowMinimumMessage={belowMinimumMessage}
/>
)}

Expand Down
18 changes: 12 additions & 6 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 (
<div className="flex min-h-[inherit] flex-col justify-start space-y-8">
Expand Down Expand Up @@ -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"
>
Expand Down
6 changes: 6 additions & 0 deletions src/components/Badges/badge.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@ export const BADGES: Record<string, BadgeMeta> = {
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.',
Expand Down
3 changes: 3 additions & 0 deletions src/components/Invites/campaign-maps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const INVITE_CODE_TO_CAMPAIGN_MAP: Record<string, string> = {
cardalpha: 'CARD_ALPHA',
psyops: 'PSYOPS_DIVISION',
founding: 'FOUNDING_PIONEER',
manicero: 'MANICERO',
}

// Map inbound `utm_campaign` values to the badge codes the backend whitelists.
Expand All @@ -41,6 +42,7 @@ export const UTM_CAMPAIGN_TO_BADGE_MAP: Record<string, string> = {
'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
Expand Down Expand Up @@ -86,6 +88,7 @@ export const BARE_VANITY_CAMPAIGNS: ReadonlySet<string> = new Set([
'festa_junina_2026',
'irl_nomads',
'offramp_user',
'manicero',
])

export type CampaignClassification = {
Expand Down
12 changes: 11 additions & 1 deletion src/components/Withdraw/views/Confirm.withdraw.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
Expand Down Expand Up @@ -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"
>
Expand All @@ -219,6 +226,9 @@ export default function ConfirmWithdrawView({
)}

{insufficientBalance && !error && <ErrorAlert description={INSUFFICIENT_BALANCE_MESSAGE} />}
{belowMinimumMessage && !insufficientBalance && !error && (
<ErrorAlert description={belowMinimumMessage} />
)}
{error && <ErrorAlert description={error} />}
</div>
</div>
Expand Down
23 changes: 23 additions & 0 deletions src/utils/__tests__/withdraw.utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
validatePixKey,
getCountryCodeForWithdraw,
getCountryFromIban,
isBelowRhinoMinDeposit,
} from '@/utils/withdraw.utils'

jest.mock('@/assets', () => ({}))
Expand Down Expand Up @@ -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)
})
})
})
17 changes: 17 additions & 0 deletions src/utils/withdraw.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading