Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2591d32
fix: bridge KYC state machine — unified gate, inline ToS, rejection h…
kushagrasarathe May 6, 2026
7cb7217
chore: format with prettier
kushagrasarathe May 6, 2026
e999024
chore: format manteca page and sumsub action
kushagrasarathe May 6, 2026
9321fcd
fix: address review comments — try/catch in BridgeTosStep, safe metad…
kushagrasarathe May 6, 2026
5868e00
fix: add error for missing bank account id in claim flow
kushagrasarathe May 6, 2026
fa45dff
fix: address hugo's review — prevStatusRef restore, typed silent resu…
kushagrasarathe May 7, 2026
bb91a2b
test: add unit tests for useBridgeTransferReadiness hook
kushagrasarathe May 7, 2026
4bd359e
fix: strengthen fetchCurrentStatus guard with userInitiatedRef to eli…
kushagrasarathe May 7, 2026
aac55c6
fix: revert temp balance check bypasses for withdraw and manteca pages
kushagrasarathe May 7, 2026
5689481
chore: format manteca withdraw page
kushagrasarathe May 7, 2026
e47cd8b
fix(qr-pay): Pix-specific copy on QR decoding error + capture event
jjramirezn May 7, 2026
e952596
fix(qr-pay): rail-specific decoding-error copy for QR3 too
jjramirezn May 7, 2026
f0427ad
revert(qr-pay): keep MP fallback copy for ARGENTINA_QR3
jjramirezn May 7, 2026
3373423
Merge pull request #1943 from peanutprotocol/fix/bridge-kyc-state-mac…
Hugo0 May 7, 2026
5b16daf
fix: format
jjramirezn May 7, 2026
d789048
Merge branch 'main' into hotfix/qr-pix-decoding-error-message
jjramirezn May 7, 2026
4506b76
Merge pull request #1948 from peanutprotocol/hotfix/qr-pix-decoding-e…
Hugo0 May 7, 2026
53cec87
Merge branch 'main' into sync/main-to-dev-kyc-state-machine
kushagrasarathe May 7, 2026
c248f9e
fix: re-apply qr-pay inline KYC changes on dev's simplefi-free version
kushagrasarathe May 7, 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
70 changes: 31 additions & 39 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,14 @@ import { useWallet } from '@/hooks/wallet/useWallet'
import { formatAmount } from '@/utils/general.utils'
import { countryData } from '@/components/AddMoney/consts'
import { useAuth } from '@/context/authContext'
import useKycStatus from '@/hooks/useKycStatus'
import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus'
import {
useBridgeTransferReadiness,
getKycModalVariant,
getGateProviderMessage,
} from '@/hooks/useBridgeTransferReadiness'
import { useModalsContext } from '@/context/ModalsContext'
import { useCreateOnramp } from '@/hooks/useCreateOnramp'
import { useRouter, useParams, useSearchParams } from 'next/navigation'
import { useRouter, useParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping'
import { formatUnits } from 'viem'
Expand All @@ -35,15 +39,13 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl } from '@/utils/native-routes'

// Step type for URL state
type BridgeBankStep = 'inputAmount' | 'showDetails'

export default function OnrampBankPage() {
const router = useRouter()
const params = useParams()
const _searchParams = useSearchParams()

// URL state - persisted in query params
// Example: /add-money/mexico/bank?step=inputAmount&amount=500
Expand Down Expand Up @@ -72,14 +74,12 @@ export default function OnrampBankPage() {
// regionIntent is NOT passed here to avoid creating a backend record on mount.
// intent is passed at call time: handleInitiateKyc('STANDARD')
const sumsubFlow = useMultiPhaseKycFlow({
onKycSuccess: async () => {
await fetchUser()
onKycSuccess: () => {
setUrlState({ step: 'inputAmount' })
},
})

// read country from path params (web) or query params (native/capacitor)
const selectedCountryPath = (params.country as string) || _searchParams.get('country') || ''
const selectedCountryPath = params.country as string

const selectedCountry = useMemo(() => {
if (!selectedCountryPath) return null
Expand All @@ -98,10 +98,14 @@ export default function OnrampBankPage() {
// uk-specific check
const isUK = isUKCountry(selectedCountryPath)

const { isUserKycApproved, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } =
useKycStatus()
const { bridge: bridgeRejection } = useProviderRejectionStatus()
const { gate } = useBridgeTransferReadiness()
const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard()
const { setIsSupportModalOpen } = useModalsContext()

// close kyc modal when sumsub sdk opens
useEffect(() => {
if (sumsubFlow.showWrapper) setShowKycModal(false)
}, [sumsubFlow.showWrapper])

useEffect(() => {
fetchUser()
Expand Down Expand Up @@ -198,18 +202,15 @@ export default function OnrampBankPage() {
}
}, [rawTokenAmount, validateAmount, setError])

const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview

const handleAmountContinue = () => {
if (!validateAmount(rawTokenAmount)) return

if (
needsBridgeEnrollment ||
!isUserKycApproved ||
bridgeRejection.state === 'fixable' ||
bridgeRejection.state === 'blocked'
) {
setShowKycModal(true)
if (gate.type !== 'ready') {
if (gate.type === 'accept_tos') {
guardWithTos()
} else {
setShowKycModal(true)
}
return
}

Expand All @@ -230,11 +231,6 @@ export default function OnrampBankPage() {
return
}

if (guardWithTos()) {
setShowWarningModal(false)
return
}

setShowWarningModal(false)
setIsRiskAccepted(false)
try {
Expand Down Expand Up @@ -280,7 +276,7 @@ export default function OnrampBankPage() {

const handleBack = () => {
if (selectedCountry) {
router.push(addMoneyCountryUrl(selectedCountry.path))
router.push(`/add-money/${selectedCountry.path}`)
} else {
router.push('/add-money')
}
Expand Down Expand Up @@ -409,28 +405,24 @@ export default function OnrampBankPage() {
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onVerify={async () => {
// needsBridgeEnrollment takes priority: user has no bridge customer,
// so rejection state from a stale/deleted customer is irrelevant
if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') {
if (gate.type === 'fixable_rejection') {
await sumsubFlow.handleSelfHealResubmit('BRIDGE')
} else {
await sumsubFlow.handleInitiateKyc(
'STANDARD',
undefined,
needsBridgeEnrollment || undefined
gate.type === 'needs_enrollment' || undefined
)
}
}}
onContactSupport={() => {
setShowKycModal(false)
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
variant={
needsBridgeEnrollment
? 'cross_region'
: bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked'
? 'provider_rejection'
: 'default'
}
providerMessage={bridgeRejection.userMessage ?? undefined}
error={sumsubFlow.error}
variant={getKycModalVariant(gate.type)}
providerMessage={getGateProviderMessage(gate)}
regionName={selectedCountry?.title}
/>

Expand Down
42 changes: 21 additions & 21 deletions src/app/(mobile-ui)/qr-pay/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle'
import { useRainCardOverview } from '@/hooks/useRainCardOverview'
import { rainSpendingPowerToWei } from '@/utils/balance.utils'
import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils'
import { isTxReverted, formatNumberForDisplay } from '@/utils/general.utils'
import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils'
import { calculateSavingsInCents, isArgentinaMantecaQrPayment, getSavingsMessage } from '@/utils/qr-payment.utils'
import ErrorAlert from '@/components/Global/ErrorAlert'
Expand Down Expand Up @@ -61,6 +61,8 @@
import { initiateIncreaseLimits } from '@/app/actions/increase-limits'
import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper'
import { useLimits } from '@/hooks/useLimits'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'

const MAX_QR_PAYMENT_AMOUNT = '2000'
const MIN_QR_PAYMENT_AMOUNT = '0.1'
Expand Down Expand Up @@ -108,7 +110,8 @@
}, [paymentProcessor])

const { shouldBlockPay, kycGateState } = useQrKycGate()
const { isUserMantecaKycApproved } = useKycStatus()
const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus()
const sumsubFlow = useMultiPhaseKycFlow({})
const queryClient = useQueryClient()
const [isShaking, setIsShaking] = useState(false)
const [shakeIntensity, setShakeIntensity] = useState<ShakeIntensity>('none')
Expand Down Expand Up @@ -355,7 +358,12 @@
}
return mantecaApi.initiateQrPayment({ qrCode, qrType: qrType ?? undefined })
},
enabled: paymentProcessor === 'MANTECA' && !!qrCode && isPaymentProcessorQR(qrCode) && !paymentLock,
enabled:
paymentProcessor === 'MANTECA' &&
!!qrCode &&
isPaymentProcessorQR(qrCode) &&
!paymentLock &&
!shouldBlockPay,
retry: (failureCount, error: any) => {
// Don't retry provider-specific errors
if (error?.message?.includes('PAYMENT_DESTINATION_DECODING_ERROR')) {
Expand Down Expand Up @@ -841,25 +849,19 @@
isFixable
? {
text: 'Upload document',
onClick: () => {
saveRedirectUrl()
router.push('/profile/identity-verification')
},
onClick: () => sumsubFlow.handleSelfHealResubmit('MANTECA'),
variant: 'purple' as const,
shadowSize: '4' as const,
icon: 'upload',
}
: {
text: 'Contact support',
onClick: () => {
if (typeof window !== 'undefined' && (window as any).$crisp) {
;(window as any).$crisp.push(['do', 'chat:open'])
}
},
onClick: () => setIsSupportModalOpen(true),
variant: 'stroke' as const,
},
]}
/>
<SumsubKycModals flow={sumsubFlow} />
</div>
)
}
Expand All @@ -882,10 +884,8 @@
ctas={[
{
text: 'Verify now',
onClick: () => {
saveRedirectUrl()
router.push('/profile/identity-verification')
},
onClick: () =>
sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined),
variant: 'purple',
shadowSize: '4',
icon: 'check-circle',
Expand All @@ -902,10 +902,8 @@
ctas={[
{
text: 'Continue verification',
onClick: () => {
saveRedirectUrl()
router.push('/profile/identity-verification')
},
onClick: () =>
sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined),
variant: 'purple',
shadowSize: '4',
icon: 'check-circle',
Expand All @@ -920,6 +918,7 @@
},
]}
/>
<SumsubKycModals flow={sumsubFlow} />
</div>
)
}
Expand Down Expand Up @@ -973,7 +972,8 @@
}

// check if we're still loading payment data before showing anything
const isLoadingPaymentData = isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency
const isLoadingPaymentData =
!shouldBlockPay && (isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency)
Comment on lines +975 to +976

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Prevent fallthrough render with undefined currency when pay is blocked.

At Line 975, isLoadingPaymentData short-circuits on !shouldBlockPay. If shouldBlockPay is true, the page can skip loading while currency is still unset (query is disabled at Line 366), then hit unguarded reads like currency.price in the main render path.

💡 Minimal fix
-    const isLoadingPaymentData =
-        !shouldBlockPay && (isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency)
+    const isLoadingPaymentData =
+        isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isLoadingPaymentData =
!shouldBlockPay && (isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency)
const isLoadingPaymentData =
isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(mobile-ui)/qr-pay/page.tsx around lines 975 - 976, The current
isLoadingPaymentData expression short-circuits on !shouldBlockPay and can allow
undefined currency to pass through when shouldBlockPay is true; update the
expression in page.tsx (isLoadingPaymentData) to always treat missing currency
as loading (e.g., make the expression start with !currency || ... or otherwise
include a top-level currency check) so subsequent unguarded reads like
currency.price are safe even when pay is blocked; keep other conditions
(isFirstLoad, paymentProcessor, paymentLock) unchanged.


if (waitingForMerchantAmount) {
return <QrPayPageLoading message="Waiting for the merchant to set the amount" />
Expand Down Expand Up @@ -1055,7 +1055,7 @@
You paid {qrPayment?.details.merchant.name ?? paymentLock?.paymentRecipientName}
</h1>
<div className="text-2xl font-extrabold">
{currency.symbol}{' '}

Check failure on line 1058 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'currency' is possibly 'undefined'.
{formatNumberForDisplay(
qrPayment?.details.paymentAssetAmount ?? paymentLock?.paymentAssetAmount,
{ maxDecimals: 2 }
Expand Down Expand Up @@ -1216,10 +1216,10 @@
amount: Number(usdAmount),
currency: {
amount: qrPayment!.details.paymentAssetAmount,
code: currency.code,

Check failure on line 1219 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'currency' is possibly 'undefined'.
},
initials: 'QR',
currencySymbol: currency.symbol,

Check failure on line 1222 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'currency' is possibly 'undefined'.
status: 'completed',
date: now,
createdAt: now,
Expand All @@ -1228,7 +1228,7 @@
originalUserRole: EHistoryUserRole.SENDER,
avatarUrl: methodIcon,
receipt: {
exchange_rate: currency.price.toString(),

Check failure on line 1231 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'currency' is possibly 'undefined'.
},
},
totalAmountCollected: Number(usdAmount),
Expand Down Expand Up @@ -1340,7 +1340,7 @@
<Card className="space-y-0 px-4">
<PaymentInfoRow
label="Exchange Rate"
value={`1 USD = ${currency.price} ${currency.code.toUpperCase()}`}

Check failure on line 1343 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'currency' is possibly 'undefined'.

Check failure on line 1343 in src/app/(mobile-ui)/qr-pay/page.tsx

View workflow job for this annotation

GitHub Actions / typecheck

'currency' is possibly 'undefined'.
moreInfoText="Rate shown is current but may vary slightly (~$1-5 ARS) until payment is confirmed."
/>
<PaymentInfoRow label="Peanut fee" value="Sponsored by Peanut!" hideBottomBorder />
Expand Down
Loading
Loading