diff --git a/app/api/aggregator.ts b/app/api/aggregator.ts index bb31d99b..2ae1cdeb 100644 --- a/app/api/aggregator.ts +++ b/app/api/aggregator.ts @@ -914,6 +914,50 @@ export async function updateTransactionStatus({ return response.data; } +/** + * Confirms the user received funds for a stuck order (proxies to aggregator validate). + */ +export async function validateOrder({ + orderId, + accessToken, + walletAddress, +}: { + orderId: string; + accessToken: string; + walletAddress: string; +}): Promise<{ + success: boolean; + data?: { message?: string; validatedAt?: string }; + error?: string; +}> { + try { + const response = await axios.post( + `/api/v1/orders/${orderId}/validate`, + {}, + { + headers: { + Authorization: `Bearer ${accessToken}`, + "x-wallet-address": walletAddress.toLowerCase(), + }, + }, + ); + return response.data; + } catch (error) { + if (axios.isAxiosError(error)) { + const message = + (error.response?.data as { error?: string })?.error || + error.message || + "Failed to validate order"; + return { success: false, error: message }; + } + console.error("Validate order error:", error); + return { + success: false, + error: "Failed to validate order. Please try again.", + }; + } +} + /** * Updates the details of a transaction including status, hash, and time spent * @param {Object} params - The parameters object diff --git a/app/api/v1/orders/[id]/validate/route.ts b/app/api/v1/orders/[id]/validate/route.ts new file mode 100644 index 00000000..4896dace --- /dev/null +++ b/app/api/v1/orders/[id]/validate/route.ts @@ -0,0 +1,240 @@ +import { NextRequest, NextResponse } from "next/server"; +import { withRateLimit } from "@/app/lib/rate-limit"; +import { + trackApiRequest, + trackApiResponse, + trackApiError, +} from "@/app/lib/server-analytics"; +import config, { DEFAULT_PRIVY_CONFIG } from "@/app/lib/config"; +import { verifyJWT } from "@/app/lib/jwt"; +import { supabaseAdmin } from "@/app/lib/supabase"; + +// Route handler for POST - validate order (user confirmed payment received) +export const POST = withRateLimit( + async ( + request: NextRequest, + context: { params: Promise<{ id: string }> }, + ) => { + const startTime = Date.now(); + let orderId: string | null = null; + + try { + const authHeader = request.headers.get("Authorization"); + const token = authHeader?.replace(/^Bearer\s+/i, ""); + + if (!token) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Missing auth token"), + 401, + ); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + + let authenticatedUserId: string; + try { + const jwtResult = await verifyJWT(token, DEFAULT_PRIVY_CONFIG); + authenticatedUserId = jwtResult.payload.sub ?? ""; + + if (!authenticatedUserId) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Invalid token"), + 401, + ); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + } catch (jwtError) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + jwtError as Error, + 401, + ); + return NextResponse.json( + { success: false, error: "Invalid or expired token" }, + { status: 401 }, + ); + } + + const walletAddress = request.headers + .get("x-wallet-address") + ?.toLowerCase(); + + if (!walletAddress) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Missing wallet address"), + 401, + ); + return NextResponse.json( + { success: false, error: "Unauthorized" }, + { status: 401 }, + ); + } + + const params = await context.params; + orderId = params.id; + if (!orderId) { + trackApiError( + request, + "/api/v1/orders/validate", + "POST", + new Error("Order ID is required"), + 400, + ); + return NextResponse.json( + { success: false, error: "Order ID is required" }, + { status: 400 }, + ); + } + + const fullPath = `/api/v1/orders/${orderId}/validate`; + + // Verify the order belongs to the authenticated wallet before using sender API key + const { data: ownedTransaction, error: ownershipError } = + await supabaseAdmin + .from("transactions") + .select("id") + .eq("order_id", orderId) + .eq("wallet_address", walletAddress) + .maybeSingle(); + + if (ownershipError || !ownedTransaction) { + trackApiError( + request, + fullPath, + "POST", + new Error("Order not found or unauthorized"), + 403, + ); + return NextResponse.json( + { success: false, error: "Order not found or unauthorized" }, + { status: 403 }, + ); + } + + trackApiRequest(request, fullPath, "POST", { + wallet_address: walletAddress, + order_id: orderId, + }); + + const aggregatorUrl = config.aggregatorUrl?.trim(); + const apiKeyId = + config.aggregatorSenderApiKeyId?.trim() || + config.aggregatorSenderApiKey?.trim(); + if (!aggregatorUrl || !apiKeyId) { + trackApiError( + request, + fullPath, + "POST", + new Error("Order validate service is not configured"), + 503, + ); + return NextResponse.json( + { + success: false, + error: + "Order validate service is not configured. Please try again later.", + }, + { status: 503 }, + ); + } + + const url = `${aggregatorUrl}/sender/orders/${orderId}/validate`; + const FETCH_TIMEOUT_MS = 30_000; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + let res: Response; + try { + res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "API-Key": apiKeyId, + }, + body: JSON.stringify({}), + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + + const data = await res.json().catch(() => ({})); + const rawMessage = + typeof data?.message === "string" + ? data.message + : (data?.Message as string) ?? ""; + + if (!res.ok) { + const status = + res.status >= 400 && res.status < 600 ? res.status : 502; + const errorMessage = + rawMessage.trim() || "Failed to validate order"; + trackApiError( + request, + fullPath, + "POST", + new Error(errorMessage), + status, + { response_time_ms: Date.now() - startTime }, + ); + return NextResponse.json( + { success: false, error: errorMessage }, + { status }, + ); + } + + const responseTime = Date.now() - startTime; + trackApiResponse(fullPath, "POST", 200, responseTime, { + wallet_address: walletAddress, + order_id: orderId, + }); + + const successMessage = + rawMessage.trim() || "Order validated successfully"; + + return NextResponse.json({ + success: true, + data: { + message: successMessage, + validatedAt: new Date().toISOString(), + }, + }); + } catch (error) { + console.error("Validate order error:", error); + const responseTime = Date.now() - startTime; + const fullPath = orderId + ? `/api/v1/orders/${orderId}/validate` + : "/api/v1/orders/validate"; + const isTimeout = + error instanceof Error && error.name === "AbortError"; + const status = isTimeout ? 504 : 500; + trackApiError(request, fullPath, "POST", error as Error, status, { + response_time_ms: responseTime, + }); + return NextResponse.json( + { + success: false, + error: isTimeout + ? "Validation request timed out. Please try again." + : "Failed to validate order. Please try again.", + }, + { status }, + ); + } + }, +); diff --git a/app/components/MainPageContent.tsx b/app/components/MainPageContent.tsx index 87951f3c..35db3ed4 100644 --- a/app/components/MainPageContent.tsx +++ b/app/components/MainPageContent.tsx @@ -72,6 +72,11 @@ import { } from "../lib/pendingReferralCode"; import { isReferralEnabled } from "../utils"; import { useWalletAddress } from "../hooks/useWalletAddress"; +import { networks } from "../mocks"; +import { + readStuckPaymentSession, + clearStuckPaymentSession, +} from "../lib/stuckPaymentSession"; /** * PageLayout component renders the main page structure including modals, @@ -261,6 +266,7 @@ export function MainPageContent() { const failedProviders = useRef>(new Set()); const autoSelectedNetworkSessionRef = useRef(null); const noProviderEventGuard = useRef>(new Set()); + const restoredStuckSessionRef = useRef(false); const [isUserVerified, setIsUserVerified] = useState(false); const [rateError, setRateError] = useState(null); @@ -476,12 +482,74 @@ export function MainPageContent() { }, [walletAddress]); useEffect(function setPageLoadingState() { - setOrderId(""); - setOnrampPaymentAccount(null); - setActiveOrderIsOnramp(false); + // Keep stuck-order fields intact when a session will be restored on auth ready. + if (!readStuckPaymentSession()) { + setOrderId(""); + setOnrampPaymentAccount(null); + setActiveOrderIsOnramp(false); + } setIsPageLoading(false); }, []); + useEffect( + function restoreStuckPaymentSessionOnLoad() { + if (restoredStuckSessionRef.current) return; + if (!ready) return; + if (!authenticated && !isInjectedWallet) return; + + const session = readStuckPaymentSession(); + if (!session) return; + + restoredStuckSessionRef.current = true; + + const restoredForm: FormData = { + network: session.network || selectedNetwork.chain.name, + token: session.form.token, + currency: session.form.currency, + institution: session.form.institution, + accountIdentifier: session.form.accountIdentifier, + recipientName: session.form.recipientName, + accountType: session.form.accountType || "bank", + walletAddress: session.form.walletAddress || "", + memo: session.form.memo || "", + amountSent: session.form.amountSent, + amountReceived: session.form.amountReceived, + swapMode: session.form.swapMode || (session.isOnramp ? "onramp" : "offramp"), + isSwapped: session.isOnramp, + receiveDestinationExplicitlySelected: true, + }; + + formMethods.reset({ + ...formMethods.getValues(), + ...restoredForm, + }); + setFormValues(restoredForm); + setOrderId(session.orderId); + setCreatedAt(session.createdAt); + setTransactionStatus(session.transactionStatus); + setActiveOrderIsOnramp(session.isOnramp); + + if (session.transactionId) { + try { + localStorage.setItem("currentTransactionId", session.transactionId); + } catch { + // ignore + } + } + + if (session.network) { + const match = networks.find((n) => n.chain.name === session.network); + if (match) { + setSelectedNetwork(match); + } + } + + setCurrentStep(STEPS.STATUS); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ready, authenticated, isInjectedWallet], + ); + useEffect( function resetOnLogout() { // Reset form when user logs out (but not for injected wallets) @@ -490,6 +558,8 @@ export function MainPageContent() { setFormValues({} as FormData); setOnrampPaymentAccount(null); setActiveOrderIsOnramp(false); + clearStuckPaymentSession(); + restoredStuckSessionRef.current = false; } }, // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/app/components/PaymentConfirmationModal.tsx b/app/components/PaymentConfirmationModal.tsx new file mode 100644 index 00000000..5d190008 --- /dev/null +++ b/app/components/PaymentConfirmationModal.tsx @@ -0,0 +1,200 @@ +"use client"; +import { useState, useCallback, useEffect } from "react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Dialog, DialogPanel } from "@headlessui/react"; +import { Loading03Icon } from "hugeicons-react"; +import Image from "next/image"; +import { classNames } from "../utils"; +import { primaryBtnClasses } from "./Styles"; + +interface PaymentConfirmationModalProps { + isOpen: boolean; + /** User confirmed funds received. */ + onConfirm: () => void | Promise; + /** User has not received funds — triggers reindex / status retry. */ + onDecline: () => void | Promise; + tokenAmount: string | number; + token: string; + /** Destination wallet where funds are going. */ + recipientAddress?: string; + explorerLink?: string; +} + +function truncateAddress(address: string) { + if (address.length <= 14) return address; + return `${address.slice(0, 6)}...${address.slice(-5)}`; +} + +export const PaymentConfirmationModal = ({ + isOpen, + onConfirm, + onDecline, + tokenAmount, + token, + recipientAddress, + explorerLink, +}: PaymentConfirmationModalProps) => { + const [confirming, setConfirming] = useState(false); + const [declining, setDeclining] = useState(false); + const busy = confirming || declining; + + useEffect(() => { + if (!isOpen) { + setConfirming(false); + setDeclining(false); + } + }, [isOpen]); + + const handleConfirm = useCallback(async () => { + if (busy) return; + setConfirming(true); + try { + await Promise.resolve(onConfirm()); + } catch { + // Keep modal open on failure; parent surfaces errors via toast. + } finally { + setConfirming(false); + } + }, [busy, onConfirm]); + + const handleDecline = useCallback(async () => { + if (busy) return; + setDeclining(true); + try { + await Promise.resolve(onDecline()); + } finally { + setDeclining(false); + } + }, [busy, onDecline]); + + const tokenLogo = token?.toLowerCase() || "usdc"; + + return ( + + {isOpen && ( + {}} + className="relative z-[70]" + > + + +
+ + +
+
+ + + Pending + +
+ +

+ Have you received +
+ this payment? +

+ +
+
+ {`${token} + + {tokenAmount} {token} + +
+ + + -······· + + + {recipientAddress ? ( +
+ + {truncateAddress(recipientAddress)} + + {explorerLink && ( + + View + + )} +
+ ) : ( + explorerLink && ( + + View + + ) + )} +
+ +

+ We noticed this transaction is taking longer than usual to + update. Please let us know if you have received your funds + so we can finalize your status. +

+ +
+ + +
+
+
+
+
+
+ )} +
+ ); +}; diff --git a/app/components/index.ts b/app/components/index.ts index f3f5306e..95942f15 100644 --- a/app/components/index.ts +++ b/app/components/index.ts @@ -44,6 +44,7 @@ export { SearchInput } from "./recipient/SearchInput"; export { RecipientListItem } from "./recipient/RecipientListItem"; export { SavedBeneficiariesModal } from "./recipient/SavedBeneficiariesModal"; export { TransactionHelperText } from "./TransactionHelperText"; +export { PaymentConfirmationModal } from "./PaymentConfirmationModal"; export { inputClasses, primaryBtnClasses, diff --git a/app/lib/config.ts b/app/lib/config.ts index 9c99d9a7..7f63a80b 100644 --- a/app/lib/config.ts +++ b/app/lib/config.ts @@ -24,6 +24,7 @@ export const STARKNET_PAYMASTER_MODE = "sponsored"; const config: Config = { aggregatorUrl: process.env.NEXT_PUBLIC_AGGREGATOR_URL || "", + aggregatorSenderApiKeyId: process.env.AGGREGATOR_SENDER_API_KEY_ID || "", privyAppId: process.env.NEXT_PUBLIC_PRIVY_APP_ID || "", rpcUrlKey: process.env.NEXT_PUBLIC_RPC_URL_KEY || "", mixpanelToken: process.env.NEXT_PUBLIC_MIXPANEL_TOKEN || "", diff --git a/app/lib/reindex.ts b/app/lib/reindex.ts index e587b261..665945fa 100644 --- a/app/lib/reindex.ts +++ b/app/lib/reindex.ts @@ -1,6 +1,43 @@ import { reindexTransaction } from "../api/aggregator"; import { normalizeNetworkForRateFetch } from "../utils"; import { networks } from "../mocks"; +import type { OrderDetailsData } from "../types"; + +export type ReindexTarget = { + txHash: string; + network: string; +}; + +/** Resolve tx hash + network for GET /reindex/:network/:tx_hash_or_address. */ +export function resolveReindexTarget( + orderDetails: OrderDetailsData | undefined, + fallbackNetwork: string, + createdHash?: string, + sessionTxHash?: string, +): ReindexTarget | null { + const network = orderDetails?.network || fallbackNetwork; + if (!network) return null; + + let txHash = createdHash || orderDetails?.txHash || ""; + + if (!txHash && orderDetails?.txReceipts?.length) { + const pendingReceipt = orderDetails.txReceipts.find( + (receipt) => receipt.status === "pending" && receipt.txHash, + ); + txHash = + pendingReceipt?.txHash || + orderDetails.txReceipts.find((receipt) => receipt.txHash)?.txHash || + orderDetails.txReceipts[0]?.txHash || + ""; + } + + if (!txHash && sessionTxHash) { + txHash = sessionTxHash; + } + + if (!txHash) return null; + return { txHash, network }; +} /** * Helper function for exponential backoff delay diff --git a/app/lib/session-cleanup.ts b/app/lib/session-cleanup.ts index 4f51ad07..586379ee 100644 --- a/app/lib/session-cleanup.ts +++ b/app/lib/session-cleanup.ts @@ -8,6 +8,7 @@ export function clearUserSessionData(userId?: string, walletAddress?: string) { "currentTransactionId", "lastFundingAttempt", "fundingCallbackId", + "noblocks_stuck_payment_session", ]; if (walletAddress) { @@ -30,4 +31,20 @@ export function clearUserSessionData(userId?: string, walletAddress?: string) { // Ignore storage errors (e.g. private browsing) } } + + // Clear per-order stuck timer keys (prefix match). + try { + const toDelete: string[] = []; + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + if (k?.startsWith("stuck_fulfilling_since_")) { + toDelete.push(k); + } + } + for (const k of toDelete) { + localStorage.removeItem(k); + } + } catch { + // ignore + } } diff --git a/app/lib/stuckPaymentSession.ts b/app/lib/stuckPaymentSession.ts new file mode 100644 index 00000000..26351586 --- /dev/null +++ b/app/lib/stuckPaymentSession.ts @@ -0,0 +1,107 @@ +import type { TransactionStatusType } from "../types"; + +const SESSION_KEY = "noblocks_stuck_payment_session"; + +/** Abandoned stuck sessions stop restoring into the status step after this age. */ +const MAX_SESSION_AGE_MS = 6 * 60 * 60 * 1000; + +export type StuckPaymentFormSnapshot = { + amountSent: number; + amountReceived: number; + token: string; + currency: string; + institution: string; + recipientName: string; + accountIdentifier: string; + accountType?: "bank" | "mobile_money"; + walletAddress?: string; + memo?: string; + swapMode?: "onramp" | "offramp"; +}; + +export type StuckPaymentSession = { + orderId: string; + transactionId?: string; + createdAt: string; + transactionStatus: Extract< + TransactionStatusType, + "fulfilling" | "fulfilled" + >; + isOnramp: boolean; + network?: string; + txHash?: string; + form: StuckPaymentFormSnapshot; + savedAt: number; +}; + +function isStuckStatus( + status: string, +): status is StuckPaymentSession["transactionStatus"] { + return status === "fulfilling" || status === "fulfilled"; +} + +export function readStuckPaymentSession(): StuckPaymentSession | null { + if (typeof window === "undefined") return null; + try { + const raw = localStorage.getItem(SESSION_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as StuckPaymentSession; + if ( + !parsed?.orderId || + !parsed?.createdAt || + !isStuckStatus(parsed.transactionStatus) || + !parsed?.form || + !Number.isFinite(parsed.savedAt) || + Date.now() - parsed.savedAt > MAX_SESSION_AGE_MS + ) { + return null; + } + return parsed; + } catch { + return null; + } +} + +export function writeStuckPaymentSession( + session: Omit, +): void { + if (typeof window === "undefined") return; + if (!isStuckStatus(session.transactionStatus)) return; + try { + const payload: StuckPaymentSession = { + ...session, + savedAt: Date.now(), + }; + localStorage.setItem(SESSION_KEY, JSON.stringify(payload)); + } catch { + // ignore quota / private browsing + } +} + +export function clearStuckPaymentSession(): void { + if (typeof window === "undefined") return; + try { + localStorage.removeItem(SESSION_KEY); + } catch { + // ignore + } +} + +/** Clears per-order stuck timer keys used by the confirmation delay. */ +export function clearStuckFulfillingSince(orderId?: string | null): void { + if (typeof window === "undefined" || !orderId) return; + try { + localStorage.removeItem(`stuck_fulfilling_since_${orderId}`); + } catch { + // ignore + } +} + +export function resetStuckFulfillingSince(orderId: string): void { + if (typeof window === "undefined" || !orderId) return; + try { + localStorage.setItem(`stuck_fulfilling_since_${orderId}`, String(Date.now())); + } catch { + // ignore + } +} diff --git a/app/pages/TransactionStatus.tsx b/app/pages/TransactionStatus.tsx index a6fdc9ae..f28fd320 100644 --- a/app/pages/TransactionStatus.tsx +++ b/app/pages/TransactionStatus.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; import { useTheme } from "next-themes"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } from "react"; import { AnimatePresence } from "framer-motion"; import { ImSpinner } from "react-icons/im"; import { Checkbox } from "@headlessui/react"; @@ -40,11 +40,22 @@ import { resolveOnrampOrderStatusFromV2Response, updateTransactionDetails, unwrapV2SenderOrderEnvelope, + validateOrder, fetchSavedRecipients, saveRecipient, deleteSavedRecipient, } from "../api/aggregator"; -import { reindexSingleTransaction } from "../lib/reindex"; +import { + reindexSingleTransaction, + resolveReindexTarget, +} from "../lib/reindex"; +import { + writeStuckPaymentSession, + clearStuckPaymentSession, + clearStuckFulfillingSince, + resetStuckFulfillingSince, + readStuckPaymentSession, +} from "../lib/stuckPaymentSession"; import { STEPS, type OrderDetailsData, @@ -75,6 +86,7 @@ import { import { readForwardBalanceWei } from "../lib/onrampForwarding/readForwardBalanceWei"; import type { Token } from "../types"; import { usePrivy } from "@privy-io/react-auth"; +import { PaymentConfirmationModal } from "../components/PaymentConfirmationModal"; import { TransactionHelperText } from "../components/TransactionHelperText"; import { useConfetti } from "../hooks/useConfetti"; import { BlockFestCashbackComponent } from "../components/blockfest"; @@ -169,6 +181,12 @@ export function TransactionStatus({ const [hasShownConfetti, setHasShownConfetti] = useState(false); const [isSavingRecipient, setIsSavingRecipient] = useState(false); const [showSaveSuccess, setShowSaveSuccess] = useState(false); + const [showPaymentConfirmation, setShowPaymentConfirmation] = useState(false); + const [paymentConfirmationEpoch, setPaymentConfirmationEpoch] = useState(0); + const paymentConfirmationTimerRef = useRef(null); + const stuckInFulfillingSinceRef = useRef(null); + const lastStuckOrderIdRef = useRef(null); + const lastAutoStuckReindexKeyRef = useRef(null); const [hasReindexed, setHasReindexed] = useState(false); // Noblocks Play banner — dismissed state persists via localStorage. const [isFantasyBannerDismissed, setIsFantasyBannerDismissed] = @@ -198,6 +216,7 @@ export function TransactionStatus({ lastPersistedOrderStatusRef.current = null; lastFulfillPersistKeyRef.current = null; setProcessingStartedAt(""); + lastAutoStuckReindexKeyRef.current = null; }, [orderId]); useEffect( @@ -224,6 +243,16 @@ export function TransactionStatus({ const recipientWalletAddress = String(watch("walletAddress") || ""); const amountReceivedCrypto = Number(watch("amountReceived")) || 0; + const getReindexTarget = useCallback(() => { + const sessionTxHash = readStuckPaymentSession()?.txHash; + return resolveReindexTarget( + orderDetails, + selectedNetwork.chain.name, + createdHash, + sessionTxHash, + ); + }, [orderDetails, selectedNetwork.chain.name, createdHash]); + /** * Leg 2: once an onramp settles into the user's Noblocks wallet, forward the funds to the user's * chosen destination (if different) using the SAME sponsored EIP-7702 transfer flow as a normal @@ -774,6 +803,13 @@ export function TransactionStatus({ } if (status === "fulfilled") { + const hashFromOrder = + responseData.txHash || + responseData.txReceipts?.find((r) => r.txHash)?.txHash || + responseData.txReceipts?.[0]?.txHash; + if (hashFromOrder) { + setCreatedHash(hashFromOrder); + } setRocketStatus("fulfilled"); return; } @@ -840,6 +876,226 @@ export function TransactionStatus({ [orderId, transactionStatus], ); + useEffect( + function showPaymentConfirmationAfterDelay() { + const STUCK_STORAGE_KEY_PREFIX = "stuck_fulfilling_since_"; + const getStuckStorageKey = () => + orderId ? `${STUCK_STORAGE_KEY_PREFIX}${orderId}` : null; + + const isStuckState = ["fulfilling", "fulfilled"].includes( + transactionStatus, + ); + + if (!isStuckState) { + setShowPaymentConfirmation(false); + stuckInFulfillingSinceRef.current = null; + lastStuckOrderIdRef.current = null; + clearStuckPaymentSession(); + const key = getStuckStorageKey(); + if (key && typeof window !== "undefined") { + try { + localStorage.removeItem(key); + } catch { + // ignore + } + } + if (paymentConfirmationTimerRef.current) { + clearTimeout(paymentConfirmationTimerRef.current); + paymentConfirmationTimerRef.current = null; + } + return; + } + + if (orderId !== lastStuckOrderIdRef.current) { + stuckInFulfillingSinceRef.current = null; + lastStuckOrderIdRef.current = orderId ?? null; + } + + // Persist enough context to restore this stuck order after a full page refresh. + if (orderId) { + const reindexTarget = getReindexTarget(); + writeStuckPaymentSession({ + orderId, + transactionId: + typeof window !== "undefined" + ? localStorage.getItem("currentTransactionId") || undefined + : undefined, + createdAt, + transactionStatus: transactionStatus as "fulfilling" | "fulfilled", + isOnramp, + network: reindexTarget?.network || orderDetails?.network || selectedNetwork.chain.name, + txHash: reindexTarget?.txHash, + form: { + amountSent: Number(amount) || 0, + amountReceived: Number(amountReceivedCrypto) || 0, + token: String(token || ""), + currency: String(currency || ""), + institution: String(institution || ""), + recipientName: String(recipientName || ""), + accountIdentifier: String(accountIdentifier || ""), + accountType: + (formMethods.watch("accountType") as + | "bank" + | "mobile_money" + | undefined) || "bank", + walletAddress: String(recipientWalletAddress || ""), + memo: String(formMethods.watch("memo") || ""), + swapMode: isOnramp ? "onramp" : "offramp", + }, + }); + } + + const now = Date.now(); + if (stuckInFulfillingSinceRef.current === null) { + const key = getStuckStorageKey(); + if (key && typeof window !== "undefined") { + try { + const stored = localStorage.getItem(key); + const parsed = stored ? parseInt(stored, 10) : NaN; + if (Number.isFinite(parsed) && parsed <= now) { + stuckInFulfillingSinceRef.current = parsed; + } + } catch { + // ignore + } + } + if (stuckInFulfillingSinceRef.current === null) { + stuckInFulfillingSinceRef.current = now; + const keyToWrite = getStuckStorageKey(); + if (keyToWrite && typeof window !== "undefined") { + try { + localStorage.setItem(keyToWrite, String(now)); + } catch { + // ignore + } + } + } + } + const stuckSince = stuckInFulfillingSinceRef.current; + const elapsed = now - stuckSince; + const delayMs = 120_000; + + if (elapsed >= delayMs) { + setShowPaymentConfirmation(true); + } else { + paymentConfirmationTimerRef.current = setTimeout(() => { + setShowPaymentConfirmation(true); + }, delayMs - elapsed); + } + + return () => { + if (paymentConfirmationTimerRef.current) { + clearTimeout(paymentConfirmationTimerRef.current); + paymentConfirmationTimerRef.current = null; + } + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [transactionStatus, orderId, createdAt, isOnramp, createdHash, orderDetails, paymentConfirmationEpoch, getReindexTarget], + ); + + /** + * Off-ramp: when the stuck-payment prompt opens (120s+ in fulfilling/fulfilled), + * automatically reindex once per prompt cycle so the aggregator re-checks chain/PSP state. + */ + useEffect( + function autoReindexStuckOfframpOrder() { + if (!showPaymentConfirmation || isOnramp) return; + + const target = getReindexTarget(); + if (!target) return; + + const attemptKey = `${paymentConfirmationEpoch}:${target.txHash}:${target.network}`; + if (lastAutoStuckReindexKeyRef.current === attemptKey) return; + lastAutoStuckReindexKeyRef.current = attemptKey; + + void reindexSingleTransaction(target.txHash, target.network).catch( + (error) => { + console.error("Auto reindex for stuck off-ramp order failed:", error); + }, + ); + }, + [ + showPaymentConfirmation, + isOnramp, + paymentConfirmationEpoch, + getReindexTarget, + ], + ); + + const handlePaymentConfirmed = async () => { + const accessToken = await getAccessToken(); + if (!accessToken || !orderId) { + throw new Error("Missing access token or order ID"); + } + + const walletAddress = embeddedWallet?.address || ""; + if (!walletAddress) { + throw new Error("Missing wallet address"); + } + + try { + const validateResult = await validateOrder({ + orderId, + accessToken, + walletAddress, + }); + + if (!validateResult.success) { + throw new Error(validateResult.error || "Validation failed"); + } + + const transactionId = localStorage.getItem("currentTransactionId"); + if (!transactionId) throw new Error("Transaction not found"); + + const updateResult = await updateTransactionDetails({ + transactionId, + status: "settled", + txHash: createdHash || orderDetails?.txHash, + timeSpent: calculateDuration(createdAt, new Date().toISOString()), + accessToken, + walletAddress, + }); + + if (!updateResult?.success) { + throw new Error("Transaction update failed"); + } + + setTransactionStatus("settled"); + setShowPaymentConfirmation(false); + clearStuckPaymentSession(); + clearStuckFulfillingSince(orderId); + } catch (error) { + console.error("Error confirming payment:", error); + const message = + error instanceof Error && error.message + ? error.message + : "Could not confirm payment. Please try again."; + toast.error(message); + throw error; + } + }; + + /** "No, I haven't" — dismiss, restart 120s timer, reindex in background. */ + const handlePaymentNotReceived = useCallback(async () => { + setShowPaymentConfirmation(false); + stuckInFulfillingSinceRef.current = Date.now(); + if (orderId) { + resetStuckFulfillingSince(orderId); + } + setPaymentConfirmationEpoch((n) => n + 1); + + const target = getReindexTarget(); + if (!target) return; + + try { + await reindexSingleTransaction(target.txHash, target.network); + toast.success("We're re-checking your payment status."); + } catch (error) { + console.error("Error reindexing after payment not received:", error); + } + }, [getReindexTarget, orderId]); + /** * Tracks transaction events for analytics * Only tracks once per transaction when status is final @@ -925,35 +1181,26 @@ export function TransactionStatus({ if ( transactionStatus !== "pending" || hasReindexed || - !orderDetails || - !orderDetails.network + !orderDetails ) { return; } - // Get txHash from orderDetails.txHash or from txReceipts - let txHash = orderDetails.txHash; - if ( - !txHash && - orderDetails.txReceipts && - orderDetails.txReceipts.length > 0 - ) { - // Try to find a pending receipt first, otherwise use the first one - const pendingReceipt = orderDetails.txReceipts.find( - (receipt) => receipt.status === "pending", - ); - txHash = pendingReceipt?.txHash || orderDetails.txReceipts[0]?.txHash; - } - - // If we still don't have a txHash, we can't reindex - if (!txHash) { + const target = resolveReindexTarget( + orderDetails, + selectedNetwork.chain.name, + createdHash, + ); + if (!target) { return; } + const { txHash, network } = target; + // Reindex transaction to sync with blockchain state const callReindex = async (): Promise => { try { - await reindexSingleTransaction(txHash, orderDetails.network); + await reindexSingleTransaction(txHash, network); setHasReindexed(true); } catch (error) { console.error("Error reindexing transaction:", error); @@ -987,7 +1234,7 @@ export function TransactionStatus({ } }; }, - [transactionStatus, hasReindexed, orderDetails, createdAt], + [transactionStatus, hasReindexed, orderDetails, createdAt, createdHash, selectedNetwork.chain.name], ); /** @@ -1487,6 +1734,25 @@ export function TransactionStatus({ className="w-full space-y-4" /> + + {[ "validated", diff --git a/app/types.ts b/app/types.ts index cd1f9d12..83112682 100644 --- a/app/types.ts +++ b/app/types.ts @@ -414,6 +414,8 @@ export type KYCStatusResponse = { export type Config = { aggregatorUrl: string; + /** Server-only sender API key UUID for order validate proxy. */ + aggregatorSenderApiKeyId: string; privyAppId: string; rpcUrlKey: string; mixpanelToken: string;