diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx
index c8f6c2df4..aad1f6bf2 100644
--- a/src/app/(mobile-ui)/qr-pay/page.tsx
+++ b/src/app/(mobile-ui)/qr-pay/page.tsx
@@ -13,7 +13,7 @@ import Image from 'next/image'
import PeanutLoading from '@/components/Global/PeanutLoading'
import TokenAmountInput from '@/components/Global/TokenAmountInput'
import { useWallet } from '@/hooks/wallet/useWallet'
-import { isTxReverted } from '@/utils/general.utils'
+import { clearRedirectUrl, getRedirectUrl, isTxReverted } from '@/utils/general.utils'
import ErrorAlert from '@/components/Global/ErrorAlert'
import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants'
import { formatUnits, parseUnits } from 'viem'
@@ -25,6 +25,10 @@ import { getCurrencyPrice } from '@/app/actions/currency'
import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow'
import { captureException } from '@sentry/nextjs'
import { isPaymentProcessorQR } from '@/components/Global/DirectSendQR/utils'
+import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate'
+import ActionModal from '@/components/Global/ActionModal'
+import { saveRedirectUrl } from '@/utils/general.utils'
+import { MantecaGeoSpecificKycModal } from '@/components/Kyc/InitiateMantecaKYCModal'
const MANTECA_DEPOSIT_ADDRESS = '0x959e088a09f61aB01cb83b0eBCc74b2CF6d62053'
const MAX_QR_PAYMENT_AMOUNT = '200'
@@ -48,6 +52,7 @@ export default function QRPayPage() {
const { openTransactionDetails, selectedTransaction, isDrawerOpen, closeTransactionDetails } =
useTransactionDetailsDrawer()
const { isLoading, loadingState, setLoadingState } = useContext(loadingStateContext)
+ const { shouldBlockPay, kycGateState } = useQrKycGate()
const resetState = () => {
setIsSuccess(false)
@@ -63,7 +68,7 @@ export default function QRPayPage() {
setLoadingState('Idle')
}
- // First fetch for qrcode info
+ // First fetch for qrcode info — only after KYC gating allows proceeding
useEffect(() => {
resetState()
@@ -72,17 +77,8 @@ export default function QRPayPage() {
return
}
- mantecaApi
- .initiateQrPayment({ qrCode })
- .then((paymentLock) => {
- setPaymentLock(paymentLock)
- })
- .catch((error) => {
- setErrorInitiatingPayment(error.message)
- })
- .finally(() => {
- setIsFirstLoad(false)
- })
+ // defer until gating computed later in component
+ setIsFirstLoad(false)
// Trigger on rescan
}, [timestamp])
@@ -137,6 +133,20 @@ export default function QRPayPage() {
}
}, [paymentLock])
+ // fetch payment lock only when gating allows proceeding and we don't yet have a lock
+ useEffect(() => {
+ if (!qrCode || !isPaymentProcessorQR(qrCode)) return
+ if (!!paymentLock) return
+ if (kycGateState !== QrKycState.PROCEED_TO_PAY) return
+
+ setLoadingState('Fetching details')
+ mantecaApi
+ .initiateQrPayment({ qrCode })
+ .then((pl) => setPaymentLock(pl))
+ .catch((error) => setErrorInitiatingPayment(error.message))
+ .finally(() => setLoadingState('Idle'))
+ }, [kycGateState, paymentLock, qrCode, setLoadingState])
+
const merchantName = useMemo(() => {
if (!paymentLock) return null
return paymentLock.paymentRecipientName
@@ -223,6 +233,56 @@ export default function QRPayPage() {
)
}
+ if (shouldBlockPay) {
+ return (
+
+
+
{
+ router.back()
+ }}
+ isMantecaModalOpen={kycGateState === QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER}
+ onKycSuccess={() => {
+ saveRedirectUrl()
+ const redirectUrl = getRedirectUrl()
+ if (redirectUrl) {
+ clearRedirectUrl()
+ router.push(redirectUrl)
+ } else {
+ router.replace('/home')
+ }
+ }}
+ />
+ router.back()}
+ title="Verify your identity to continue"
+ description="You'll need to verify your identity before paying with a QR code. Don't worry it usually just takes a few minutes."
+ icon="shield"
+ ctas={[
+ {
+ text: 'Verify now',
+ onClick: () => {
+ saveRedirectUrl()
+ router.push('/profile/identity-verification')
+ },
+ variant: 'purple',
+ shadowSize: '4',
+ icon: 'check-circle',
+ },
+ ]}
+ footer={
+
+ Peanut doesn't store any personal information
+
+ }
+ />
+
+ )
+ }
+
if (isFirstLoad || !paymentLock || !currency) {
return
}
@@ -361,7 +421,12 @@ export default function QRPayPage() {
shadowSize="4"
loading={isLoading}
disabled={
- !!errorInitiatingPayment || !!errorMessage || !amount || isLoading || !!balanceErrorMessage
+ !!errorInitiatingPayment ||
+ !!errorMessage ||
+ !amount ||
+ isLoading ||
+ !!balanceErrorMessage ||
+ shouldBlockPay
}
>
{isLoading ? loadingState : 'Pay'}
diff --git a/src/app/actions/bridge/get-customer.ts b/src/app/actions/bridge/get-customer.ts
new file mode 100644
index 000000000..66a47b757
--- /dev/null
+++ b/src/app/actions/bridge/get-customer.ts
@@ -0,0 +1,66 @@
+'use server'
+
+import { unstable_cache } from 'next/cache'
+import { PEANUT_API_KEY, PEANUT_API_URL } from '@/constants'
+import { countryData } from '@/components/AddMoney/consts'
+
+type BridgeCustomer = {
+ id: string
+ email: string
+ type: string
+ status?: string
+ residential_address?: {
+ subdivision?: string | null
+ country?: string | null
+ } | null
+}
+
+// build a comprehensive ISO3 -> ISO2 map from our country list to normalize country codes and avoid issues with bridge kyc
+const ISO3_TO_ISO2: Record = (() => {
+ const map: Record = {}
+ for (const c of countryData) {
+ const iso2 = (c as any).iso2 || c.id
+ const iso3 = (c as any).iso3
+ if (iso2 && iso3) {
+ map[String(iso3).toUpperCase()] = String(iso2).toUpperCase()
+ }
+ }
+ return map
+})()
+
+const normalizeCountry = (input?: string | null): string | null => {
+ if (!input) return null
+ const upper = input.toUpperCase()
+ if (upper.length === 2) return upper
+ return ISO3_TO_ISO2[upper] ?? null
+}
+
+export const getBridgeCustomerCountry = async (
+ bridgeCustomerId: string
+): Promise<{ countryCode: string | null; rawCountry: string | null }> => {
+ const runner = unstable_cache(
+ async () => {
+ const response = await fetch(`${PEANUT_API_URL}/bridge/customers/${bridgeCustomerId}` as string, {
+ headers: {
+ 'Content-Type': 'application/json',
+ 'api-key': PEANUT_API_KEY,
+ },
+ cache: 'no-store',
+ })
+
+ if (!response.ok) {
+ // do not throw to avoid breaking callers; return nulls for graceful fallback
+ return { countryCode: null, rawCountry: null } as const
+ }
+ const data = (await response.json()) as BridgeCustomer
+ const raw = data?.residential_address?.country ?? null
+ const normalized = normalizeCountry(raw)
+ console.log('normalized kushagra', normalized)
+ return { countryCode: normalized, rawCountry: raw }
+ },
+ ['getBridgeCustomerCountry', bridgeCustomerId],
+ { revalidate: 5 * 60 }
+ )
+
+ return runner()
+}
diff --git a/src/components/AddMoney/UserDetailsForm.tsx b/src/components/AddMoney/UserDetailsForm.tsx
index 041d89e64..4f3cedda1 100644
--- a/src/components/AddMoney/UserDetailsForm.tsx
+++ b/src/components/AddMoney/UserDetailsForm.tsx
@@ -17,13 +17,13 @@ interface UserDetailsFormProps {
}
export const UserDetailsForm = forwardRef<{ handleSubmit: () => void }, UserDetailsFormProps>(
- ({ onSubmit, isSubmitting, onValidChange, initialData }, ref) => {
+ ({ onSubmit, onValidChange, initialData }, ref) => {
const [submissionError, setSubmissionError] = useState(null)
const {
control,
handleSubmit,
- formState: { errors, isValid, isValidating },
+ formState: { errors, isValid },
} = useForm({
defaultValues: {
fullName: initialData?.fullName ?? '',
diff --git a/src/components/Kyc/InitiateMantecaKYCModal.tsx b/src/components/Kyc/InitiateMantecaKYCModal.tsx
index 19e22a426..2454bec02 100644
--- a/src/components/Kyc/InitiateMantecaKYCModal.tsx
+++ b/src/components/Kyc/InitiateMantecaKYCModal.tsx
@@ -74,11 +74,13 @@ export const MantecaGeoSpecificKycModal = ({
selectedCountry,
setIsMantecaModalOpen,
isMantecaModalOpen,
+ onKycSuccess,
}: {
isUserBridgeKycApproved: boolean
selectedCountry: { id: string; title: string }
setIsMantecaModalOpen: (isOpen: boolean) => void
isMantecaModalOpen: boolean
+ onKycSuccess: () => void
}) => {
return (
setIsMantecaModalOpen(false)}
onKycSuccess={() => {
setIsMantecaModalOpen(false)
+ onKycSuccess?.()
}}
onManualClose={() => setIsMantecaModalOpen(false)}
country={{ id: selectedCountry.id, title: selectedCountry.title, type: 'country', path: '' }}
diff --git a/src/components/Profile/views/IdentityVerification.view.tsx b/src/components/Profile/views/IdentityVerification.view.tsx
index 5f74d4bb7..f8f757fe5 100644
--- a/src/components/Profile/views/IdentityVerification.view.tsx
+++ b/src/components/Profile/views/IdentityVerification.view.tsx
@@ -20,6 +20,7 @@ import { MantecaKycStatus } from '@/interfaces'
import { useRouter } from 'next/navigation'
import { useCallback, useMemo, useRef, useState } from 'react'
import useKycStatus from '@/hooks/useKycStatus'
+import { getRedirectUrl, clearRedirectUrl } from '@/utils/general.utils'
const IdentityVerificationView = () => {
const { user, fetchUser } = useAuth()
@@ -34,6 +35,25 @@ const IdentityVerificationView = () => {
const [selectedCountry, setSelectedCountry] = useState<{ id: string; title: string } | null>(null)
const [userClickedCountry, setUserClickedCountry] = useState<{ id: string; title: string } | null>(null)
+ const handleRedirect = useCallback(() => {
+ const redirectUrl = getRedirectUrl()
+ if (redirectUrl) {
+ clearRedirectUrl()
+ router.push(redirectUrl)
+ } else {
+ router.replace('/profile')
+ }
+ }, [router])
+
+ const handleKycSuccess = useCallback(async () => {
+ await fetchUser()
+ handleRedirect()
+ }, [router, fetchUser])
+
+ const handleMantecaKycSuccess = useCallback(() => {
+ handleRedirect()
+ }, [router])
+
const {
iframeOptions,
handleInitiateKyc,
@@ -42,7 +62,7 @@ const IdentityVerificationView = () => {
closeVerificationProgressModal,
error: kycError,
isLoading: isKycLoading,
- } = useBridgeKycFlow()
+ } = useBridgeKycFlow({ onKycSuccess: handleKycSuccess })
const { isUserBridgeKycApproved } = useKycStatus()
@@ -232,6 +252,7 @@ const IdentityVerificationView = () => {
selectedCountry={selectedCountry}
setIsMantecaModalOpen={setIsMantecaModalOpen}
isMantecaModalOpen={isMantecaModalOpen}
+ onKycSuccess={handleMantecaKycSuccess}
/>
)}
diff --git a/src/hooks/useMantecaKycFlow.ts b/src/hooks/useMantecaKycFlow.ts
index 25d6f697d..f6339b8df 100644
--- a/src/hooks/useMantecaKycFlow.ts
+++ b/src/hooks/useMantecaKycFlow.ts
@@ -5,6 +5,7 @@ import { useAuth } from '@/context/authContext'
import { CountryData, MantecaSupportedExchanges } from '@/components/AddMoney/consts'
import { BASE_URL } from '@/constants'
import { MantecaKycStatus } from '@/interfaces'
+import { useWebSocket } from './useWebSocket'
type UseMantecaKycFlowOptions = {
onClose?: () => void
@@ -21,11 +22,38 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
visible: false,
closeConfirmMessage: undefined,
})
- const { user } = useAuth()
+ const { user, fetchUser } = useAuth()
const [isMantecaKycRequired, setNeedsMantecaKyc] = useState(false)
const userKycVerifications = user?.user?.kycVerifications
+ const handleIframeClose = useCallback(
+ (source?: 'manual' | 'completed' | 'tos_accepted') => {
+ setIframeOptions((prev) => ({ ...prev, visible: false }))
+ if (source === 'completed') {
+ onSuccess?.()
+ return
+ }
+ if (source === 'manual') {
+ onManualClose?.()
+ return
+ }
+ onClose?.()
+ },
+ [onClose, onSuccess, onManualClose]
+ )
+
+ useWebSocket({
+ username: user?.user.username ?? undefined,
+ autoConnect: true,
+ onMantecaKycStatusUpdate: async (status) => {
+ if (status === MantecaKycStatus.ACTIVE || status === 'WIDGET_FINISHED') {
+ await fetchUser()
+ handleIframeClose('completed')
+ }
+ },
+ })
+
useEffect(() => {
// determine if manteca kyc is required based on geo data available in kycVerifications
const selectedGeo = country?.id
@@ -71,22 +99,6 @@ export const useMantecaKycFlow = ({ onClose, onSuccess, onManualClose, country }
}
}, [])
- const handleIframeClose = useCallback(
- (source?: 'manual' | 'completed' | 'tos_accepted') => {
- setIframeOptions((prev) => ({ ...prev, visible: false }))
- if (source === 'completed') {
- onSuccess?.()
- return
- }
- if (source === 'manual') {
- onManualClose?.()
- return
- }
- onClose?.()
- },
- [onClose, onSuccess, onManualClose]
- )
-
return {
isLoading,
error,
diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts
new file mode 100644
index 000000000..603d466b6
--- /dev/null
+++ b/src/hooks/useQrKycGate.ts
@@ -0,0 +1,77 @@
+'use client'
+
+import { useCallback, useState, useEffect } from 'react'
+import { useAuth } from '@/context/authContext'
+import { MantecaKycStatus } from '@/interfaces'
+import { getBridgeCustomerCountry } from '@/app/actions/bridge/get-customer'
+
+export enum QrKycState {
+ LOADING = 'loading',
+ PROCEED_TO_PAY = 'proceed_to_pay',
+ REQUIRES_IDENTITY_VERIFICATION = 'requires_identity_verification',
+ REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER = 'requires_manteca_kyc_for_arg_bridge_user',
+}
+
+export interface QrKycGateResult {
+ kycGateState: QrKycState
+ shouldBlockPay: boolean
+}
+
+/**
+ * This hook determines the KYC gate state for the QR pay page.
+ * It checks the user's KYC status and the country of the QR code to determine the appropriate action.
+ * @returns {QrKycGateResult} An object with the KYC gate state and a boolean indicating if the user should be blocked from paying.
+ */
+export function useQrKycGate(): QrKycGateResult {
+ const { user } = useAuth()
+ const [kycGateState, setKycGateState] = useState(QrKycState.LOADING)
+
+ const determineKycGateState = useCallback(async () => {
+ const currentUser = user?.user
+ if (!currentUser) {
+ setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION)
+ return
+ }
+
+ const hasAnyMantecaKyc =
+ currentUser.kycVerifications?.some(
+ (v) => v.provider === 'MANTECA' && v.status === MantecaKycStatus.ACTIVE
+ ) ?? false
+
+ if (hasAnyMantecaKyc) {
+ setKycGateState(QrKycState.PROCEED_TO_PAY)
+ return
+ }
+
+ if (currentUser.bridgeKycStatus === 'approved' && currentUser.bridgeCustomerId) {
+ try {
+ const { countryCode } = await getBridgeCustomerCountry(currentUser.bridgeCustomerId)
+ if (countryCode && countryCode.toUpperCase() === 'AR') {
+ setKycGateState(QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER)
+ } else {
+ setKycGateState(QrKycState.PROCEED_TO_PAY)
+ }
+ } catch {
+ // fail to require identity verification to avoid blocking pay due to rare outages
+ setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION)
+ }
+ return
+ }
+
+ setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION)
+ }, [user?.user])
+
+ useEffect(() => {
+ determineKycGateState()
+ }, [determineKycGateState])
+
+ const result: QrKycGateResult = {
+ kycGateState,
+ shouldBlockPay:
+ kycGateState === QrKycState.REQUIRES_MANTECA_KYC_FOR_ARG_BRIDGE_USER ||
+ kycGateState === QrKycState.REQUIRES_IDENTITY_VERIFICATION ||
+ kycGateState === QrKycState.LOADING,
+ }
+
+ return result
+}
diff --git a/src/utils/general.utils.ts b/src/utils/general.utils.ts
index c30e97248..192a401f3 100644
--- a/src/utils/general.utils.ts
+++ b/src/utils/general.utils.ts
@@ -1217,6 +1217,16 @@ export const saveRedirectUrl = () => {
saveToLocalStorage('redirect', relativeUrl)
}
+export const getRedirectUrl = () => {
+ return getFromLocalStorage('redirect')
+}
+
+export const clearRedirectUrl = () => {
+ if (typeof localStorage !== 'undefined') {
+ localStorage.removeItem('redirect')
+ }
+}
+
export const sanitizeRedirectURL = (redirectUrl: string): string => {
try {
const u = new URL(redirectUrl, window.location.origin)