Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0412ec7
feat: add limits page and related components for region-based navigation
kushagrasarathe Jan 13, 2026
4eb4f2d
feat: add limits page in profile page list
kushagrasarathe Jan 13, 2026
5207ca4
feat: add useLimits hook for fetching user fiat txn limits
kushagrasarathe Jan 13, 2026
ac7edc5
feat: implement bridge limits page with url based state
kushagrasarathe Jan 13, 2026
4cc8266
feat: manteca limits page
kushagrasarathe Jan 13, 2026
a48b55f
fix: resolve ai review comments
kushagrasarathe Jan 13, 2026
d767af5
feat: add info card in limits page and increase limits button
kushagrasarathe Jan 14, 2026
55c8035
fix: cr comments
kushagrasarathe Jan 14, 2026
8df9bc1
fix: dir structure
kushagrasarathe Jan 14, 2026
c666f41
fix: DRY
kushagrasarathe Jan 14, 2026
9bfaeab
feat: LimitsWarningCard component for displaying limit warnings and e…
kushagrasarathe Jan 15, 2026
f35d994
feat: add manteca withdraw limits and useLimitsValidation hook for tx…
kushagrasarathe Jan 15, 2026
3bf6517
feat: implement limits validation on payment flows
kushagrasarathe Jan 15, 2026
2170000
fix: cr dry suggestion
kushagrasarathe Jan 15, 2026
2ec238d
fix: resolve review comments and dry violations
kushagrasarathe Jan 20, 2026
f9c8ed2
fix: improv limits currency handling
kushagrasarathe Jan 20, 2026
269626b
fix: improve limits validation logic
kushagrasarathe Jan 20, 2026
252c8e6
fix: update limits validation to use currency directly from context
kushagrasarathe Jan 20, 2026
8107d89
fix: currency conversion for limits validation using exchange rates o…
kushagrasarathe Jan 20, 2026
7388ce2
Merge pull request #1621 from peanutprotocol/feat/limits-integration
kushagrasarathe Jan 20, 2026
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
74 changes: 67 additions & 7 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ import { OnrampConfirmationModal } from '@/components/AddMoney/components/Onramp
import { InitiateBridgeKYCModal } from '@/components/Kyc/InitiateBridgeKYCModal'
import InfoCard from '@/components/Global/InfoCard'
import { useQueryStates, parseAsString, parseAsStringEnum } from 'nuqs'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'
import { useExchangeRate } from '@/hooks/useExchangeRate'

// Step type for URL state
type BridgeBankStep = 'inputAmount' | 'kyc' | 'collectUserDetails' | 'showDetails'
Expand Down Expand Up @@ -97,6 +101,42 @@ export default function OnrampBankPage() {
return getMinimumAmount(selectedCountry.id)
}, [selectedCountry?.id])

// get local currency for the selected country (EUR, MXN, USD)
const localCurrency = useMemo(() => {
if (!selectedCountry?.id) return 'USD'
return getCurrencyConfig(selectedCountry.id, 'onramp').currency.toUpperCase()
}, [selectedCountry?.id])

// get exchange rate: local currency → USD (for limits validation)
// skip for USD since it's 1:1
const { exchangeRate, isLoading: isRateLoading } = useExchangeRate({
sourceCurrency: localCurrency,
destinationCurrency: 'USD',
enabled: localCurrency !== 'USD',
})

// convert input amount to USD for limits validation
// bridge limits are always in USD, but user inputs in local currency
const usdEquivalent = useMemo(() => {
if (!rawTokenAmount) return 0
const numericAmount = parseFloat(rawTokenAmount.replace(/,/g, ''))
if (isNaN(numericAmount)) return 0

// for USD, no conversion needed
if (localCurrency === 'USD') return numericAmount

// convert local currency to USD
return exchangeRate > 0 ? numericAmount * exchangeRate : 0
}, [rawTokenAmount, localCurrency, exchangeRate])

// validate against user's bridge limits
// uses USD equivalent to correctly compare against USD-denominated limits
const limitsValidation = useLimitsValidation({
flowType: 'onramp',
amount: usdEquivalent,
currency: 'USD',
})

// Determine initial step based on KYC status (only when URL has no step)
useEffect(() => {
// If URL already has a step, respect it (allows deep linking)
Expand Down Expand Up @@ -341,6 +381,8 @@ export default function OnrampBankPage() {
}

if (urlState.step === 'inputAmount') {
const showLimitsCard = limitsValidation.isBlocking || limitsValidation.isWarning

return (
<div className="flex flex-col justify-start space-y-8">
<NavHeader title="Add Money" onPrev={handleBack} />
Expand All @@ -364,11 +406,24 @@ export default function OnrampBankPage() {
hideBalance
/>

<InfoCard
variant="warning"
icon="alert"
description="This must match what you send from your bank!"
/>
{/* limits warning/error card */}
{showLimitsCard &&
(() => {
const limitsCardProps = getLimitsWarningCardProps({
validation: limitsValidation,
flowType: 'onramp',
currency: 'USD',
})
return limitsCardProps ? <LimitsWarningCard {...limitsCardProps} /> : null
})()}

{!limitsValidation.isBlocking && (
<InfoCard
variant="warning"
icon="alert"
description="This must match what you send from your bank!"
/>
)}
<Button
variant="purple"
shadowSize="4"
Expand All @@ -377,14 +432,19 @@ export default function OnrampBankPage() {
!parseFloat(rawTokenAmount) ||
parseFloat(rawTokenAmount) < minimumAmount ||
error.showError ||
isCreatingOnramp
isCreatingOnramp ||
limitsValidation.isBlocking ||
(localCurrency !== 'USD' && isRateLoading)
}
className="w-full"
loading={isCreatingOnramp}
>
Continue
</Button>
{error.showError && !!error.errorMessage && <ErrorAlert description={error.errorMessage} />}
{/* only show error if limits blocking card is not displayed (warnings can coexist) */}
{error.showError && !!error.errorMessage && !limitsValidation.isBlocking && (
<ErrorAlert description={error.errorMessage} />
)}
</div>

<OnrampConfirmationModal
Expand Down
25 changes: 25 additions & 0 deletions src/app/(mobile-ui)/limits/[provider]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import PageContainer from '@/components/0_Bruddle/PageContainer'
import { LIMITS_PROVIDERS, type LimitsProvider } from '@/features/limits/consts'
import BridgeLimitsView from '@/features/limits/views/BridgeLimitsView'
import MantecaLimitsView from '@/features/limits/views/MantecaLimitsView'
import { notFound } from 'next/navigation'

interface ProviderLimitsPageProps {
params: Promise<{ provider: string }>
}

export default async function ProviderLimitsPage({ params }: ProviderLimitsPageProps) {
const { provider } = await params

// validate provider - notFound() is safe in server components
if (!LIMITS_PROVIDERS.includes(provider as LimitsProvider)) {
notFound()
}

return (
<PageContainer>
{provider === 'bridge' && <BridgeLimitsView />}
{provider === 'manteca' && <MantecaLimitsView />}
</PageContainer>
)
}
10 changes: 10 additions & 0 deletions src/app/(mobile-ui)/limits/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import PageContainer from '@/components/0_Bruddle/PageContainer'
import LimitsPageView from '@/features/limits/views/LimitsPageView'

export default function LimitsPageRoute() {
return (
<PageContainer>
<LimitsPageView />
</PageContainer>
)
}
31 changes: 29 additions & 2 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ import { useModalsContext } from '@/context/ModalsContext'
import maintenanceConfig from '@/config/underMaintenance.config'
import PointsCard from '@/components/Common/PointsCard'
import { TRANSACTIONS } from '@/constants/query.consts'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'
import useKycStatus from '@/hooks/useKycStatus'

const MAX_QR_PAYMENT_AMOUNT = '2000'
const MIN_QR_PAYMENT_AMOUNT = '0.1'
Expand Down Expand Up @@ -118,6 +122,7 @@ export default function QRPayPage() {
}, [paymentProcessor])

const { shouldBlockPay, kycGateState } = useQrKycGate(paymentProcessor)
const { isUserMantecaKycApproved } = useKycStatus()
const queryClient = useQueryClient()
const [isShaking, setIsShaking] = useState(false)
const [shakeIntensity, setShakeIntensity] = useState<ShakeIntensity>('none')
Expand Down Expand Up @@ -383,6 +388,14 @@ export default function QRPayPage() {
}
}, [paymentProcessor, simpleFiPayment, paymentLock?.code, paymentLock?.paymentAgainstAmount, amount])

// validate payment against user's limits
// currency comes from payment lock/simplefi - hook normalizes it internally
const limitsValidation = useLimitsValidation({
flowType: 'qr-payment',
amount: usdAmount,
currency: currency?.code,
})

// Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount
// This way points are cached by the time success view shows
// Only Manteca QR payments give points (SimpleFi does not)
Expand Down Expand Up @@ -1535,7 +1548,20 @@ export default function QRPayPage() {
hideBalance
/>
)}
{balanceErrorMessage && <ErrorAlert description={balanceErrorMessage} />}
{/* only show balance error if limits blocking card is not displayed (warnings can coexist) */}
{balanceErrorMessage && !limitsValidation.isBlocking && (
<ErrorAlert description={balanceErrorMessage} />
)}

{/* Limits Warning/Error Card */}
{(() => {
const limitsCardProps = getLimitsWarningCardProps({
validation: limitsValidation,
flowType: 'qr-payment',
currency: limitsValidation.currency,
})
return limitsCardProps ? <LimitsWarningCard {...limitsCardProps} /> : null
})()}

{/* Information Card */}
<Card className="space-y-0 px-4">
Expand All @@ -1560,7 +1586,8 @@ export default function QRPayPage() {
shouldBlockPay ||
!usdAmount ||
usdAmount === '0.00' ||
isWaitingForWebSocket
isWaitingForWebSocket ||
limitsValidation.isBlocking
}
>
{isLoading || isWaitingForWebSocket
Expand Down
41 changes: 31 additions & 10 deletions src/app/(mobile-ui)/withdraw/manteca/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,13 @@ import {
} from '@/constants/manteca.consts'
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts'
import { TRANSACTIONS } from '@/constants/query.consts'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import { MIN_MANTECA_WITHDRAW_AMOUNT } from '@/constants/payment.consts'
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'

type MantecaWithdrawStep = 'amountInput' | 'bankDetails' | 'review' | 'success' | 'failure'

const MAX_WITHDRAW_AMOUNT = '2000'
const MIN_WITHDRAW_AMOUNT = '1'

export default function MantecaWithdrawFlow() {
const flowId = useId() // Unique ID per flow instance to prevent cache collisions
const [currencyAmount, setCurrencyAmount] = useState<string | undefined>(undefined)
Expand Down Expand Up @@ -94,14 +95,21 @@ export default function MantecaWithdrawFlow() {

const {
code: currencyCode,
symbol: currencySymbol,
price: currencyPrice,
isLoading: isCurrencyLoading,
} = useCurrency(selectedCountry?.currency!)

// Initialize KYC flow hook
const { isMantecaKycRequired } = useMantecaKycFlow({ country: selectedCountry })

// validates withdrawal against user's limits
// currency comes from country config - hook normalizes it internally
const limitsValidation = useLimitsValidation({
flowType: 'offramp',
amount: usdAmount,
currency: selectedCountry?.currency,
})

// WebSocket listener for KYC status updates
useWebSocket({
username: user?.user.username ?? undefined,
Expand Down Expand Up @@ -290,10 +298,9 @@ export default function MantecaWithdrawFlow() {
return
}
const paymentAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS)
if (paymentAmount < parseUnits(MIN_WITHDRAW_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) {
setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_WITHDRAW_AMOUNT}`)
} else if (paymentAmount > parseUnits(MAX_WITHDRAW_AMOUNT, PEANUT_WALLET_TOKEN_DECIMALS)) {
setBalanceErrorMessage(`Withdraw amount exceeds maximum limit of $${MAX_WITHDRAW_AMOUNT}`)
// only check min amount and balance here - max amount is handled by limits validation
if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) {
setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`)
} else if (paymentAmount > balance) {
setBalanceErrorMessage('Not enough balance to complete withdrawal.')
} else {
Expand Down Expand Up @@ -429,6 +436,17 @@ export default function MantecaWithdrawFlow() {
balance ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : undefined
}
/>

{/* limits warning/error card - uses centralized helper for props */}
{(() => {
const limitsCardProps = getLimitsWarningCardProps({
validation: limitsValidation,
flowType: 'offramp',
currency: limitsValidation.currency,
})
return limitsCardProps ? <LimitsWarningCard {...limitsCardProps} /> : null
})()}

<Button
variant="purple"
shadowSize="4"
Expand All @@ -442,12 +460,15 @@ export default function MantecaWithdrawFlow() {
}
}
}}
disabled={!Number(usdAmount) || !!balanceErrorMessage}
disabled={!Number(usdAmount) || !!balanceErrorMessage || limitsValidation.isBlocking}
className="w-full"
>
Continue
</Button>
{balanceErrorMessage && <ErrorAlert description={balanceErrorMessage} />}
{/* only show balance error if limits blocking card is not displayed (warnings can coexist) */}
{balanceErrorMessage && !limitsValidation.isBlocking && (
<ErrorAlert description={balanceErrorMessage} />
)}
</div>
)}

Expand Down
38 changes: 36 additions & 2 deletions src/app/(mobile-ui)/withdraw/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import { getCountryFromAccount } from '@/utils/bridge.utils'
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState, useRef, useContext } from 'react'
import { formatUnits } from 'viem'
import { useLimitsValidation } from '@/features/limits/hooks/useLimitsValidation'
import LimitsWarningCard from '@/features/limits/components/LimitsWarningCard'
import { getLimitsWarningCardProps } from '@/features/limits/utils'

type WithdrawStep = 'inputAmount' | 'selectMethod'

Expand Down Expand Up @@ -79,6 +82,14 @@ export default function WithdrawPage() {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
}, [balance])

// validate against user's limits for bank withdrawals
// note: crypto withdrawals don't have fiat limits
const limitsValidation = useLimitsValidation({
flowType: 'offramp',
amount: rawTokenAmount,
currency: 'USD',
})

// clear errors and reset any persisted state when component mounts to ensure clean state
useEffect(() => {
setError({ showError: false, errorMessage: '' })
Expand Down Expand Up @@ -247,6 +258,10 @@ export default function WithdrawPage() {
}, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price])

if (step === 'inputAmount') {
// only show limits card for bank/manteca withdrawals, not crypto
const showLimitsCard =
selectedMethod?.type !== 'crypto' && (limitsValidation.isBlocking || limitsValidation.isWarning)

return (
<div className="flex min-h-[inherit] flex-col justify-start space-y-8">
<NavHeader
Expand Down Expand Up @@ -280,16 +295,35 @@ export default function WithdrawPage() {
walletBalance={peanutWalletBalance}
hideCurrencyToggle
/>

{/* limits warning/error card for bank withdrawals */}
{showLimitsCard &&
(() => {
const limitsCardProps = getLimitsWarningCardProps({
validation: limitsValidation,
flowType: 'offramp',
currency: 'USD',
})
return limitsCardProps ? <LimitsWarningCard {...limitsCardProps} /> : null
})()}

<Button
variant="purple"
shadowSize="4"
onClick={handleAmountContinue}
disabled={isContinueDisabled}
disabled={
isContinueDisabled ||
(selectedMethod?.type !== 'crypto' &&
(limitsValidation.isLoading || limitsValidation.isBlocking))
}
className="w-full"
>
Continue
</Button>
{error.showError && !!error.errorMessage && <ErrorAlert description={error.errorMessage} />}
{/* only show error if limits blocking card is not displayed (warnings can coexist) */}
{error.showError && !!error.errorMessage && !limitsValidation.isBlocking && (
<ErrorAlert description={error.errorMessage} />
)}
</div>
</div>
)
Expand Down
4 changes: 2 additions & 2 deletions src/app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ export default function NotFound() {
return (
<PageContainer className="min-h-[100dvh]">
<div className="my-auto flex h-full flex-col justify-center space-y-4">
<div className="shadow-4 flex w-full flex-col items-center space-y-2 bg-white px-4 py-2">
<div className="shadow-4 flex w-full flex-col items-center space-y-2 border border-n-1 bg-white p-4">
<h1 className="text-3xl font-extrabold">Not found</h1>
<Image src={PEANUTMAN_CRY.src} className="" alt="Peanutman crying 😭" width={96} height={96} />
<p>Woah there buddy, you're not supposed to be here.</p>
<Link href="/" className="btn btn-purple">
<Link href="/" className="btn btn-purple btn-medium shadow-4">
Take me home, I'm scared
</Link>
</div>
Expand Down
Loading
Loading