Skip to content
Merged
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])
Comment on lines +118 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle exchange rate fetch errors.

When exchangeRate is 0 due to a fetch error (not just loading), usdEquivalent will be 0, potentially bypassing limits validation for non-USD currencies. The button's disabled state only guards against isRateLoading, not isError.

Consider also destructuring isError from useExchangeRate and either disabling the button or showing an error when the rate fetch fails.

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

Then in the button disabled state (line 437):

-                            (localCurrency !== 'USD' && isRateLoading)
+                            (localCurrency !== 'USD' && (isRateLoading || isRateError))
🤖 Prompt for AI Agents
In `@src/app/`(mobile-ui)/add-money/[country]/bank/page.tsx around lines 118 -
130, The usdEquivalent calculation and the payment button need to account for
exchange-rate fetch failures: destructure isError from useExchangeRate where
exchangeRate is obtained, update the useMemo for usdEquivalent (function named
usdEquivalent that reads rawTokenAmount, localCurrency, exchangeRate) to treat a
failed fetch (isError true) as an error case (e.g., return NaN or throw/flag)
instead of silently returning 0, and update the button disabled logic to include
isError alongside isRateLoading so the button is disabled (or an error UI is
shown) when the rate fetch failed; reference the useExchangeRate hook, the
usdEquivalent useMemo, rawTokenAmount, localCurrency, exchangeRate, and the
button's disabled prop to find all places to change.


// 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',
})
Comment on lines +132 to +138
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Guard against limits loading state.

The limitsValidation.isLoading state is not used to disable the Continue button. This creates a race condition where users could proceed before limits validation completes, potentially bypassing blocking limits.

Per the PR review comments recommendation: "Disable actions while limitsValidation.isLoading is true."

Suggested fix in button disabled state (around line 431)
                         disabled={
                             !parseFloat(rawTokenAmount) ||
                             parseFloat(rawTokenAmount) < minimumAmount ||
                             error.showError ||
                             isCreatingOnramp ||
                             limitsValidation.isBlocking ||
-                            (localCurrency !== 'USD' && isRateLoading)
+                            (localCurrency !== 'USD' && isRateLoading) ||
+                            limitsValidation.isLoading
                         }
🤖 Prompt for AI Agents
In `@src/app/`(mobile-ui)/add-money/[country]/bank/page.tsx around lines 132 -
138, The Continue button can be clicked before limits validation finishes;
update the button's disabled condition to also check limitsValidation.isLoading
so actions are disabled while validation is pending. Locate the Continue button
render (the element that currently checks things like canContinue or any
existing disabled flag around the Continue/submit action) and add
limitsValidation.isLoading to the boolean expression (e.g., disabled={... ||
limitsValidation.isLoading}), ensuring you reference the useLimitsValidation
result (limitsValidation) used earlier in this file; no other behavior changes
needed besides preventing clicks while loading.


// 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
2 changes: 1 addition & 1 deletion src/app/(mobile-ui)/limits/[provider]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 { LIMITS_PROVIDERS, type LimitsProvider } from '@/features/limits/consts'
import { notFound } from 'next/navigation'

interface ProviderLimitsPageProps {
Expand Down
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
Comment on lines +1589 to +1590
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Consider disabling button while limits are loading.

Similar to the concern raised in the withdraw flow, the disabled condition doesn't account for limitsValidation.isLoading. If limits data hasn't loaded yet, isBlocking will be false, potentially allowing the user to proceed before validation completes.

🔧 Suggested fix
                         isWaitingForWebSocket ||
-                        limitsValidation.isBlocking
+                        limitsValidation.isBlocking ||
+                        limitsValidation.isLoading
🤖 Prompt for AI Agents
In `@src/app/`(mobile-ui)/qr-pay/page.tsx around lines 1589 - 1590, The button's
disabled logic currently uses isWaitingForWebSocket ||
limitsValidation.isBlocking but misses the loading state, so add
limitsValidation.isLoading to the condition (i.e., disable when
limitsValidation.isLoading is true) so users can't proceed while limits are
still loading; update the component that renders the button (referencing
isWaitingForWebSocket, limitsValidation.isBlocking, limitsValidation.isLoading)
to include the loading flag in the disabled expression and any related aria-busy
or spinner logic if present.

}
>
{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
Loading
Loading