diff --git a/src/app/api/fast-tx-status/[hash]/route.ts b/src/app/api/fast-tx-status/[hash]/route.ts index 0a50ffa0..1b31e86f 100644 --- a/src/app/api/fast-tx-status/[hash]/route.ts +++ b/src/app/api/fast-tx-status/[hash]/route.ts @@ -3,7 +3,8 @@ import { getAnalyticsClient } from "@/lib/analytics/client" /** * Queries mctransactions for a swap's preconfirmation status. - * Returns: "pre-confirmed" | "confirmed" | "failed" | null (not found yet) + * Returns: "preconfirmed" | "confirmed" | "failed" | null (not found yet) + * Note: DB stores "pre-confirmed" — normalized to "preconfirmed" by the client-side fetcher. */ export async function GET(request: NextRequest, { params }: { params: Promise<{ hash: string }> }) { try { diff --git a/src/app/api/og/preconfirm/route.tsx b/src/app/api/og/preconfirm/route.tsx new file mode 100644 index 00000000..61473c62 --- /dev/null +++ b/src/app/api/og/preconfirm/route.tsx @@ -0,0 +1,116 @@ +import { ImageResponse } from "next/og" +import { NextRequest } from "next/server" + +export const runtime = "edge" + +/** + * Dynamic OG image for preconfirmation share cards. + * Usage: /api/og/preconfirm?time=0.4 + */ +export async function GET(request: NextRequest) { + const { searchParams } = request.nextUrl + const raw = parseFloat(searchParams.get("time") || "0.4") + const time = !isNaN(raw) && raw >= 0 && raw <= 999 ? raw.toFixed(1) : "0.4" + + return new ImageResponse( +
+ {/* Subtle radial glow */} +
+ + {/* Top label */} +
+ Swap Preconfirmed +
+ + {/* Speed number */} +
+
+ {time} +
+
+ sec +
+
+ + {/* Thin accent line */} +
+ + {/* Branding */} +
+ fastprotocol.io +
+
, + { + width: 1200, + height: 630, + } + ) +} diff --git a/src/app/share/preconfirm/page.tsx b/src/app/share/preconfirm/page.tsx new file mode 100644 index 00000000..c424332f --- /dev/null +++ b/src/app/share/preconfirm/page.tsx @@ -0,0 +1,37 @@ +import { Metadata } from "next" +import { redirect } from "next/navigation" + +interface Props { + searchParams: Promise<{ time?: string }> +} + +export async function generateMetadata({ searchParams }: Props): Promise { + const params = await searchParams + const raw = parseFloat(params.time || "0.4") + const time = !isNaN(raw) && raw >= 0 && raw <= 999 ? raw.toFixed(1) : "0.4" + const title = `Preconfirmed in ${time}s — Fast Swaps` + const description = `Swap preconfirmed in ${time} seconds on Fast Protocol` + const ogUrl = `/api/og/preconfirm?time=${time}` + + return { + title, + description, + openGraph: { + title, + description, + images: [{ url: ogUrl, width: 1200, height: 630 }], + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [ogUrl], + site: "@Fast_Protocol", + }, + } +} + +/** Redirect visitors to the main swap page — this route only exists for OG meta. */ +export default async function SharePreconfirmPage() { + redirect("/") +} diff --git a/src/components/swap/BuyCard.tsx b/src/components/swap/BuyCard.tsx index e911a1db..95d00d10 100644 --- a/src/components/swap/BuyCard.tsx +++ b/src/components/swap/BuyCard.tsx @@ -10,6 +10,9 @@ import { cn } from "@/lib/utils" import AmountInput from "./AmountInput" import TokenInfoRow from "./TokenInfoRow" +// Hooks +import { useBalanceFlash } from "@/hooks/use-balance-flash" + // Types import { Token } from "@/types/swap" import { UseBalanceReturnType } from "wagmi" @@ -76,6 +79,7 @@ const BuyCardComponent: React.FC = ({ * prevents unnecessary re-renders of the entire swap interface. */ const [hasImageError, setHasImageError] = useState(false) + const isBalanceFlashing = useBalanceFlash(toBalanceValue, isConnected) useEffect(() => { setHasImageError(false) @@ -91,12 +95,30 @@ const BuyCardComponent: React.FC = ({ setAmount(value) } + const handleBalanceClick = () => { + if (!toToken || toBalanceValue <= 0 || !isConnected) return + setEditingSide("buy") + setAmount(toBalanceValue.toString()) + } + return (
{/* Header Section */}
Buy - {toToken && Balance: {formattedToBalance}} + {toToken && ( + + )}
diff --git a/src/components/swap/PreconfirmCelebration.tsx b/src/components/swap/PreconfirmCelebration.tsx new file mode 100644 index 00000000..1bd2a1b0 --- /dev/null +++ b/src/components/swap/PreconfirmCelebration.tsx @@ -0,0 +1,138 @@ +"use client" + +import { useEffect, useState, useRef } from "react" +import { motion, AnimatePresence } from "motion/react" + +/** + * Particle burst animation that fires when a swap is preconfirmed. + * Creates a ring of spark particles that explode outward from center. + */ + +interface Particle { + id: number + angle: number + distance: number + size: number + delay: number + duration: number + color: string +} + +const SPARK_COLORS = [ + "#60a5fa", // blue-400 + "#93c5fd", // blue-300 + "#3b82f6", // blue-500 + "#818cf8", // indigo-400 + "#a78bfa", // violet-400 + "#ffffff", // white +] + +function generateParticles(count: number): Particle[] { + return Array.from({ length: count }, (_, i) => ({ + id: i, + angle: (360 / count) * i + (Math.random() * 30 - 15), + distance: 40 + Math.random() * 35, + size: 2 + Math.random() * 3, + delay: Math.random() * 0.08, + duration: 0.5 + Math.random() * 0.3, + color: SPARK_COLORS[Math.floor(Math.random() * SPARK_COLORS.length)], + })) +} + +export function PreconfirmCelebration({ active }: { active: boolean }) { + const [particles] = useState(() => generateParticles(14)) + const [show, setShow] = useState(false) + const hasPlayed = useRef(false) + + useEffect(() => { + if (active && !hasPlayed.current) { + hasPlayed.current = true + setShow(true) + // Clean up particles after animation completes + const timer = setTimeout(() => setShow(false), 1200) + return () => clearTimeout(timer) + } + }, [active]) + + return ( + + {show && ( +
+ {/* Central flash */} + + + {/* Spark particles */} + {particles.map((p) => { + const rad = (p.angle * Math.PI) / 180 + const x = Math.cos(rad) * p.distance + const y = Math.sin(rad) * p.distance + return ( + + ) + })} + + {/* Ring pulse */} + +
+ )} +
+ ) +} + +/** + * Glow effect behind the Fast logo when preconfirmed. + * Persistent subtle pulse that stays while toast is visible. + */ +export function PreconfirmGlow({ active }: { active: boolean }) { + if (!active) return null + + return ( + + ) +} diff --git a/src/components/swap/SellCard.tsx b/src/components/swap/SellCard.tsx index 0917bfd4..8ac778da 100644 --- a/src/components/swap/SellCard.tsx +++ b/src/components/swap/SellCard.tsx @@ -4,16 +4,21 @@ import React, { useState, useEffect } from "react" import Image from "next/image" // UI Components & Icons import { ChevronDown } from "lucide-react" +import { formatUnits } from "viem" import { cn } from "@/lib/utils" // Local Components import AmountInput from "./AmountInput" import TokenInfoRow from "./TokenInfoRow" +// Hooks +import { useBalanceFlash } from "@/hooks/use-balance-flash" + // Types import { Token } from "@/types/swap" import { QuoteResult } from "@/hooks/use-swap-quote" import { UseBalanceReturnType } from "wagmi" +import { ZERO_ADDRESS } from "@/lib/swap-constants" interface SellCardProps { // Token & Balance Data @@ -68,6 +73,7 @@ const SellCardComponent: React.FC = ({ setSwappedQuote, }) => { const [hasImageError, setHasImageError] = useState(false) + const isBalanceFlashing = useBalanceFlash(fromBalanceValue, isConnected) /** * Reset image error state if the token changes. @@ -76,6 +82,24 @@ const SellCardComponent: React.FC = ({ setHasImageError(false) }, [fromToken?.address]) + const handleMaxBalance = () => { + if (!fromToken || !fromBalance || fromBalance.value === 0n) return + setEditingSide("sell") + setIsManualInversion(false) + setSwappedQuote(null) + + const isNativeEth = fromToken.address === ZERO_ADDRESS + if (isNativeEth) { + // Reserve 0.01 ETH for gas + const reserve = 10n ** 16n // 0.01 ETH in wei + const max = fromBalance.value > reserve ? fromBalance.value - reserve : 0n + if (max === 0n) return + setAmount(formatUnits(max, fromToken.decimals)) + } else { + setAmount(formatUnits(fromBalance.value, fromToken.decimals)) + } + } + const handleAmountChange = (value: string) => { setEditingSide("sell") setAmount(value) @@ -87,7 +111,17 @@ const SellCardComponent: React.FC = ({
Sell {fromToken && ( - Balance: {formattedFromBalance} + )}
diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 6c39f217..1d242afb 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -2,9 +2,11 @@ import { useEffect, useRef } from "react" import Image from "next/image" +import { motion, AnimatePresence } from "motion/react" import { useWaitForTransactionReceipt } from "wagmi" import type { TransactionReceipt } from "viem" -import { X, RefreshCw } from "lucide-react" +import { X, RefreshCw, ExternalLink } from "lucide-react" +import { FaXTwitter } from "react-icons/fa6" import { useSwapToastStore } from "@/stores/swapToastStore" import { useWaitForTxConfirmation } from "@/hooks/use-wait-for-tx-confirmation" import { @@ -14,12 +16,13 @@ import { } from "@/lib/transaction-errors" import { FAST_PROTOCOL_NETWORK } from "@/lib/network-config" import { TokenPairIcon } from "./TokenPairIcon" +import { PreconfirmCelebration, PreconfirmGlow } from "./PreconfirmCelebration" +import { playPreconfirmSound } from "@/lib/preconfirm-sound" import { cn } from "@/lib/utils" -/** - * SwapToast handles the multi-stage lifecycle of a transaction: - * pending → pre-confirmed → confirmed (or failed at any point). - */ +const CONFIRMED_AUTO_DISMISS_MS = 6000 +const SHARE_STRIP_DELAY_S = 1.5 + export function SwapToast({ hash }: { hash: string }) { const toast = useSwapToastStore((s) => s.toasts.find((t) => t.hash === hash)) const setStatus = useSwapToastStore((s) => s.setStatus) @@ -31,8 +34,6 @@ export function SwapToast({ hash }: { hash: string }) { const removeToast = useSwapToastStore((s) => s.removeToast) const toastRef = useRef(null) - - // Placeholder hash (Permit path) until relayer returns real hash; don't poll until then const effectiveHash = hash.startsWith("pending-") ? undefined : hash const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({ @@ -52,7 +53,8 @@ export function SwapToast({ hash }: { hash: string }) { onPreConfirmed: () => { const currentStatus = useSwapToastStore.getState().toasts.find((t) => t.hash === hash)?.status if (effectiveHash && currentStatus !== "confirmed") { - setStatus(hash, "pre-confirmed") + setStatus(hash, "preconfirmed") + playPreconfirmSound() const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash) t?.onPreConfirm?.() } @@ -66,36 +68,42 @@ export function SwapToast({ hash }: { hash: string }) { }, }) - // Click Outside Logic useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { + if (toast?.status !== "confirmed") return + const timer = setTimeout(() => removeToast(hash), CONFIRMED_AUTO_DISMISS_MS) + return () => clearTimeout(timer) + }, [toast?.status, hash, removeToast]) + + useEffect(() => { + const handler = (e: MouseEvent) => { if (!toastRef.current?.contains(e.target as Node) && toast) { - if (toast.status === "confirmed" || toast.status === "failed") { - removeToast(hash) - } else { - collapse(hash) - } + if (toast.status === "confirmed" || toast.status === "failed") removeToast(hash) + else collapse(hash) } } - document.addEventListener("mousedown", handleClickOutside) - return () => document.removeEventListener("mousedown", handleClickOutside) + document.addEventListener("mousedown", handler) + return () => document.removeEventListener("mousedown", handler) }, [hash, toast, collapse, removeToast]) if (!toast) return null const isPending = toast.status === "pending" + const isPreConfirmed = toast.status === "preconfirmed" const isConfirmed = toast.status === "confirmed" - const isPreConfirmed = toast.status === "pre-confirmed" const isFailed = toast.status === "failed" + const settled = isPreConfirmed || isConfirmed const explorerUrl = effectiveHash ? `${FAST_PROTOCOL_NETWORK.blockExplorerUrls[0]}tx/${effectiveHash}` : null + const elapsedSec = + toast.preconfirmedAt && toast.createdAt + ? ((toast.preconfirmedAt - toast.createdAt) / 1000).toFixed(1) + : null - // Failed State + // ── Failed ──────────────────────────────────────────────────────── if (isFailed) { const barterSlippage = toast.errorMessage ? parseBarterSlippageError(toast.errorMessage) : null - // Barter slippage: specialized inline retry toast if (barterSlippage) { return (
-
-
- -
+
+
- -
+
Slippage too low
Minimum required: {barterSlippage.recommendedSlippage}%
- -
- - -
+ +
) } - // Generic failure: "Swap Failed" — click toast for details, X to dismiss return (
-
-
- -
+
+
- -
+
Swap Failed
{toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} {toast.tokenOut?.symbol}
-
) } - // Collapsed State: Minimalist bubble - if ((isPending || toast.status === "pre-confirmed") && toast.collapsed) { + // ── Collapsed ───────────────────────────────────────────────────── + if ((isPending || isPreConfirmed) && toast.collapsed) { return ( ) } + // ── Main card ───────────────────────────────────────────────────── return ( -
explorerUrl && window.open(explorerUrl, "_blank")} + layout className={cn( - "relative w-[360px] overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl transition-all duration-300 border border-white/10 hover:border-white/20", - explorerUrl ? "cursor-pointer" : "cursor-default", - isConfirmed && "border-white/20" + "group relative w-[360px] rounded-2xl bg-neutral-900 shadow-2xl transition-colors duration-500 border", + isConfirmed + ? "border-green-500/25" + : isPreConfirmed + ? "border-blue-500/30" + : "border-white/10 hover:border-white/20" )} > -
- {/* LEFT ICON: Token pair for pending, Fast icon for pre-confirmed/confirmed */} -
+ {/* ── Dismiss: bare X, no bubble ── */} + + + {/* ── Card body ── */} +
explorerUrl && window.open(explorerUrl, "_blank")} + className={cn( + "relative h-[84px] p-4 flex items-center gap-4", + explorerUrl && settled && "cursor-pointer" + )} + > + {/* LEFT: Icon */} +
+ + +
-
+ {settled && ( + + Fast Protocol + )} - > - Fast Protocol -
+ + + {/* Green checkmark badge */} + + {isConfirmed && ( + + + + + + )} +
- {/* MIDDLE TEXT: Status label + amounts */} + {/* MIDDLE: Text */}
- + {isConfirmed ? ( + + Tokens Available +
+ {toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} + {toast.tokenOut?.symbol} +
+
+ ) : isPreConfirmed ? ( + + Preconfirmed +
+ {toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} + {toast.tokenOut?.symbol} +
+
+ ) : ( + + Swapping... +
+ {toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} + {toast.tokenOut?.symbol} +
+
)} - > - {isConfirmed - ? "Tokens Available" - : isPreConfirmed - ? "Tokens Preconfirmed" - : "Swapping..."} -
- - {/* Amount Subtext */} -
- {toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} - {toast.tokenOut?.symbol} -
+
- {/* RIGHT ACTION/STATUS */} -
- {isConfirmed || isPreConfirmed ? ( - - ) : ( -
- {/* The Spinner */} -
+ {/* RIGHT: Speed badge + explorer link */} +
+ {/* Speed badge — prominent */} + + {settled && elapsedSec && ( + + {elapsedSec}s + + )} + - {/* Background ring for visual depth */} + {/* Explorer link */} + {settled && explorerUrl ? ( + + + + ) : isPending ? ( +
+
- )} + ) : null}
-
+ + {/* ── Bottom accent line ── */} + + {isConfirmed && ( + + )} + {isPreConfirmed && !isConfirmed && ( + + )} + + + {/* ── Share on X: flush strip below card body ── */} + + {(isPreConfirmed || isConfirmed) && elapsedSec && ( + +
+ +
+
+ )} +
+ ) } diff --git a/src/components/swap/SwapToastContainer.tsx b/src/components/swap/SwapToastContainer.tsx index 85a7e996..0869eaf6 100644 --- a/src/components/swap/SwapToastContainer.tsx +++ b/src/components/swap/SwapToastContainer.tsx @@ -1,6 +1,7 @@ "use client" import { useEffect } from "react" +import { AnimatePresence } from "motion/react" import { useSwapToastStore } from "@/stores/swapToastStore" import { SwapToast } from "./SwapToast" import { FEATURE_FLAGS, TEST_SWAP_TOAST_PLACEHOLDER } from "@/lib/feature-flags" @@ -27,12 +28,12 @@ export function SwapToastContainer() { }, [addToast]) return ( -
- {toasts.map((t, i) => ( -
- -
- ))} +
+ + {toasts.map((t) => ( + + ))} +
) } diff --git a/src/hooks/use-balance-flash.ts b/src/hooks/use-balance-flash.ts new file mode 100644 index 00000000..c0428322 --- /dev/null +++ b/src/hooks/use-balance-flash.ts @@ -0,0 +1,31 @@ +"use client" + +import { useState, useEffect, useRef } from "react" + +/** + * Returns true briefly when a numeric balance changes upward. + * Used to flash/pulse the balance display after tokens arrive. + */ +export function useBalanceFlash(value: number, enabled: boolean = true): boolean { + const [isFlashing, setIsFlashing] = useState(false) + const prevValue = useRef(value) + + useEffect(() => { + if (!enabled || prevValue.current === value) { + prevValue.current = value + return + } + + // Only flash on increases (tokens arriving, not spending) + if (value > prevValue.current && prevValue.current > 0) { + setIsFlashing(true) + const timer = setTimeout(() => setIsFlashing(false), 1500) + prevValue.current = value + return () => clearTimeout(timer) + } + + prevValue.current = value + }, [value, enabled]) + + return isFlashing +} diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index 9aeb7b80..66f350fe 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -1,22 +1,14 @@ "use client" -import { useState, useCallback, useEffect } from "react" -import { - useAccount, - usePublicClient, - useSendTransaction, - useWaitForTransactionReceipt, -} from "wagmi" -import { - useBroadcastGasPrice, - ETH_PATH_GAS_LIMIT_MULTIPLIER, -} from "@/hooks/use-broadcast-gas-price" +import { useState, useCallback } from "react" +import { useAccount, usePublicClient, useSendTransaction } from "wagmi" +import { ETH_PATH_GAS_LIMIT_MULTIPLIER } from "@/hooks/use-broadcast-gas-price" import { mainnet } from "wagmi/chains" -import { parseUnits, formatUnits, type TransactionReceipt } from "viem" +import { parseUnits, formatUnits } from "viem" import { useSwapIntent } from "@/hooks/use-swap-intent" import { usePermit2Nonce } from "@/hooks/use-permit2-nonce" -import { useWaitForTxConfirmation } from "@/hooks/use-wait-for-tx-confirmation" import { ZERO_ADDRESS, WETH_ADDRESS } from "@/lib/swap-constants" +import { FASTSWAP_API_BASE } from "@/lib/network-config" import { fetchEthPathTxAndEstimate } from "@/lib/eth-path-tx" import type { Token } from "@/types/swap" @@ -51,69 +43,22 @@ export function useSwapConfirmation({ onSuccess, }: UseSwapConfirmationParams) { const { isConnected, address } = useAccount() - const { getFreshGasFees } = useBroadcastGasPrice() const publicClient = usePublicClient({ chainId: mainnet.id }) const { createIntentSignature } = useSwapIntent() - const { - getFreshNonce, - releaseNonce, - syncFromChain, - isLoading: isNonceLoading, - } = usePermit2Nonce() + const { getFreshNonce, releaseNonce, isLoading: isNonceLoading } = usePermit2Nonce() const { sendTransactionAsync } = useSendTransaction() // --- Transaction State --- + // Note: Confirmation polling is handled by SwapToast (single source of truth). const [isSigning, setIsSigning] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) - const [isConfirming, setIsConfirming] = useState(false) - const [isSuccess, setIsSuccess] = useState(false) const [hash, setHash] = useState(null) const [error, setError] = useState(null) - // Wagmi receipt hook used as a data source for the race-condition confirmation hook - const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({ - hash: hash ? (hash as `0x${string}`) : undefined, - }) - - const onConfirmed = useCallback(() => { - setIsSubmitting(false) - setIsConfirming(false) - setIsSuccess(true) - syncFromChain() // Refresh nonce state - onSuccess?.() - }, [onSuccess, syncFromChain]) - - const onConfirmationError = useCallback((err: Error) => { - setIsSubmitting(false) - setIsConfirming(false) - setError(err instanceof Error ? err : new Error(String(err))) - }, []) - - // Races DB polling against on-chain receipt - useWaitForTxConfirmation({ - hash: hash ?? undefined, - receipt: (receipt as TransactionReceipt | undefined) ?? undefined, - receiptError, - mode: "status", - onConfirmed, - onError: onConfirmationError, - }) - - // Sync confirmation status based on hash availability - useEffect(() => { - if (hash && !isSuccess && !error) { - setIsConfirming(true) - } else if (error) { - setIsConfirming(false) - } - }, [hash, isSuccess, error]) - const reset = useCallback(() => { setIsSigning(false) setIsSubmitting(false) - setIsConfirming(false) - setIsSuccess(false) setHash(null) setError(null) }, []) @@ -121,7 +66,6 @@ export function useSwapConfirmation({ const handleSwapError = useCallback((err: unknown) => { setIsSigning(false) setIsSubmitting(false) - setIsConfirming(false) setError(err instanceof Error ? err : new Error(String(err))) }, []) @@ -202,13 +146,6 @@ export function useSwapConfirmation({ const deadlineUnix = Math.floor(Date.now() / 1000) + deadline * 60 let result - console.log("body", { - outputToken: toToken.address, - inputAmt: inputAmtWei, - userAmtOut: userAmtOutWei, - sender: address, - deadline: String(deadlineUnix), - }) try { result = await fetchEthPathTxAndEstimate( { @@ -224,15 +161,12 @@ export function useSwapConfirmation({ } catch (err) { const apiError = err instanceof Error ? err.message : "FastSwap API error" let errorMessage = apiError - console.log("apiError", apiError) if (apiError.toLowerCase().includes("api error")) { errorMessage += `\n\nContext:\nInput token: ${fromToken.symbol} (${fromToken.address})\nOutput token: ${toToken.symbol} (${toToken.address})\nSlippage: ${slippage}\nMinimum Output: ${userAmtOutWei}\nDeadline (minutes): ${deadline}` } throw new Error(errorMessage) } - await getFreshGasFees() - const bufferedGas = (result.gasEstimate * ETH_PATH_GAS_LIMIT_MULTIPLIER) / 100n const txHash = await sendTransactionAsync({ @@ -295,14 +229,14 @@ export function useSwapConfirmation({ slippage: (parseFloat(slippage || "0.5") || 0.5).toFixed(1), } - const resp = await fetch("/api/fastswap", { + // Call FastRPC directly — CORS allows it, skip Vercel serverless proxy + const resp = await fetch(`${FASTSWAP_API_BASE}/fastswap`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }) const result = await resp.json() - console.log("[Permit Path] fastswap response:", { status: resp.status, result }) if (!resp.ok || !result?.txHash) { releaseNonce(nonce) const rawError = result?.error || "FastSwap API error" @@ -319,8 +253,6 @@ export function useSwapConfirmation({ confirmSwap, isSigning, isSubmitting, - isConfirming, - isSuccess, hash, error, reset, diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 815aefe7..747c3b6d 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -4,10 +4,21 @@ import { useState, useEffect, useRef, useCallback } from "react" import type { TransactionReceipt } from "viem" import { fetchFastTxStatus } from "@/lib/fast-tx-status" import { fetchTransactionReceiptFromDb } from "@/lib/transaction-receipt-utils" +import { fetchCommitmentStatus } from "@/lib/fast-rpc-status" import { getTxConfirmationTimeoutMs } from "@/lib/tx-config" import { RPCError } from "@/lib/transaction-errors" -const STATUS_CHECK_INTERVAL_MS = 500 +/** + * Adaptive polling: starts fast to catch sub-second preconfirmations, + * then backs off. First 5 polls at 100ms (~500ms window), then 500ms. + */ +const FAST_POLL_INTERVAL_MS = 100 +const NORMAL_POLL_INTERVAL_MS = 500 +const FAST_POLL_COUNT = 5 + +function getPollInterval(pollCount: number): number { + return pollCount < FAST_POLL_COUNT ? FAST_POLL_INTERVAL_MS : NORMAL_POLL_INTERVAL_MS +} export type WaitForTxConfirmationMode = "receipt" | "status" @@ -24,7 +35,7 @@ export interface UseWaitForTxConfirmationParams { receiptError?: Error | null mode: WaitForTxConfirmationMode onConfirmed: (result: TxConfirmationResult) => void - /** Called when RPC receipt or mctransactions reports pre-confirmed. */ + /** Called when RPC receipt or mctransactions reports preconfirmed. */ onPreConfirmed?: (result: TxConfirmationResult) => void onError?: (error: Error) => void } @@ -38,12 +49,12 @@ export interface UseWaitForTxConfirmationReturn { /** * Two-phase polling with Wagmi as parallel fallback: * - * Phase 1 (pending → pre-confirmed): + * Phase 1 (pending → preconfirmed): * Poll BOTH eth_getTransactionReceipt (FastRPC) and mctransactions in parallel. - * First source to show success/pre-confirmed fires onPreConfirmed. + * First source to show success/preconfirmed fires onPreConfirmed. * mctransactions "failed" in this phase fires onError immediately. * - * Phase 2 (pre-confirmed → final): + * Phase 2 (preconfirmed → final): * Stop RPC receipt polling. Poll only mctransactions for confirmed/failed. * mctransactions "confirmed" → fire onConfirmed (final success). * mctransactions "failed" → fire onError. @@ -163,6 +174,7 @@ export function useWaitForTxConfirmation({ try { const timeoutMs = await getTxConfirmationTimeoutMs() const startTime = Date.now() + let pollCount = 0 // ── Phase 1: Poll both RPC receipt and mctransactions ── while (!abortController.signal.aborted && !hasConfirmedRef.current) { @@ -175,8 +187,12 @@ export function useWaitForTxConfirmation({ return } - // Poll both sources in parallel - const [rpcResult, mcStatus] = await Promise.all([ + // Poll three sources in parallel: + // 1. FastRPC commitment status (fastest — node knows instantly) + // 2. RPC eth_getTransactionReceipt + // 3. mctransactions DB status (slowest — lags ~15s) + const [commitStatus, rpcResult, mcStatus] = await Promise.all([ + fetchCommitmentStatus(hash, abortController.signal), fetchTransactionReceiptFromDb(hash, abortController.signal), fetchFastTxStatus(hash, abortController.signal), ]) @@ -205,9 +221,10 @@ export function useWaitForTxConfirmation({ return } - // Either source signals pre-confirmed → fire and move to phase 2 + // Any source signals preconfirmed → fire and move to phase 2 if ( - mcStatus === "pre-confirmed" || + commitStatus === "preconfirmed" || + mcStatus === "preconfirmed" || mcStatus === "confirmed" || (rpcResult && rpcResult.receipt.status === "success") ) { @@ -227,7 +244,7 @@ export function useWaitForTxConfirmation({ break // → Phase 2 } - await new Promise((r) => setTimeout(r, STATUS_CHECK_INTERVAL_MS)) + await new Promise((r) => setTimeout(r, getPollInterval(pollCount++))) } // ── Phase 2: Poll only mctransactions for confirmed/failed ── @@ -281,7 +298,7 @@ export function useWaitForTxConfirmation({ return } - await new Promise((r) => setTimeout(r, STATUS_CHECK_INTERVAL_MS)) + await new Promise((r) => setTimeout(r, NORMAL_POLL_INTERVAL_MS)) } } catch (err) { if ((err as Error).name !== "AbortError" && !hasConfirmedRef.current) { diff --git a/src/lib/eth-path-tx.ts b/src/lib/eth-path-tx.ts index a7477f7a..1e2a7787 100644 --- a/src/lib/eth-path-tx.ts +++ b/src/lib/eth-path-tx.ts @@ -48,12 +48,20 @@ export async function fetchEthPathTxAndEstimate( throw new Error(apiError) } - const estimated = await publicClient.estimateGas({ - account, - to: data.to as `0x${string}`, - data: data.data as `0x${string}`, - value: BigInt(data.value || 0), - }) + let estimated: bigint + try { + estimated = await publicClient.estimateGas({ + account, + to: data.to as `0x${string}`, + data: data.data as `0x${string}`, + value: BigInt(data.value || 0), + }) + } catch (err) { + // Surface a clear message instead of the raw viem dump + throw new Error( + "This swap would fail on-chain — the price may have moved. Try increasing slippage or refreshing the quote." + ) + } return { to: data.to, diff --git a/src/lib/fast-rpc-status.ts b/src/lib/fast-rpc-status.ts new file mode 100644 index 00000000..028754f6 --- /dev/null +++ b/src/lib/fast-rpc-status.ts @@ -0,0 +1,56 @@ +/** + * Polls FastRPC's mevcommit_getTransactionCommitments JSON-RPC method. + * This queries the FastRPC node directly — it knows about preconfirmation + * commitments instantly, unlike the mctransactions DB which lags ~15s. + * + * Returns "preconfirmed" if commitments exist for this tx, null otherwise. + */ + +const RPC_URL = "https://fastrpc.mev-commit.xyz" +const REQUEST_TIMEOUT_MS = 3000 + +export async function fetchCommitmentStatus( + txHash: string, + abortSignal?: AbortSignal +): Promise<"preconfirmed" | null> { + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + + if (abortSignal) { + if (abortSignal.aborted) { + clearTimeout(timeoutId) + return null + } + abortSignal.addEventListener("abort", () => controller.abort(), { once: true }) + } + + try { + const response = await fetch(RPC_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + method: "mevcommit_getTransactionCommitments", + params: [txHash], + id: 1, + }), + signal: controller.signal, + }) + + clearTimeout(timeoutId) + + if (!response.ok) return null + + const data = await response.json() + + // Only trust array results with actual commitment objects + if (data?.result && Array.isArray(data.result) && data.result.length > 0) { + return "preconfirmed" + } + + return null + } catch { + clearTimeout(timeoutId) + return null + } +} diff --git a/src/lib/fast-tx-status.ts b/src/lib/fast-tx-status.ts index 5e8004a2..e84b251c 100644 --- a/src/lib/fast-tx-status.ts +++ b/src/lib/fast-tx-status.ts @@ -1,10 +1,18 @@ -export type FastTxStatus = "pre-confirmed" | "confirmed" | "failed" | null +export type FastTxStatus = "preconfirmed" | "confirmed" | "failed" | null const REQUEST_TIMEOUT_MS = 5000 +/** Normalize DB status values (e.g. "pre-confirmed") to frontend values ("preconfirmed"). */ +function normalizeStatus(raw: string): FastTxStatus { + if (raw === "pre-confirmed" || raw === "preconfirmed") return "preconfirmed" + if (raw === "confirmed") return "confirmed" + if (raw === "failed") return "failed" + return null +} + /** * Fetches the mctransactions status for a swap tx hash. - * Returns "pre-confirmed" | "confirmed" | "failed" | null (not found yet). + * Returns "preconfirmed" | "confirmed" | "failed" | null (not found yet). */ export async function fetchFastTxStatus( txHash: string, @@ -13,6 +21,15 @@ export async function fetchFastTxStatus( const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + // Link parent abort signal so in-flight requests cancel immediately + if (abortSignal) { + if (abortSignal.aborted) { + clearTimeout(timeoutId) + return null + } + abortSignal.addEventListener("abort", () => controller.abort(), { once: true }) + } + try { const response = await fetch(`/api/fast-tx-status/${txHash}`, { signal: controller.signal, @@ -25,7 +42,7 @@ export async function fetchFastTxStatus( if (!response.ok) return null const data = await response.json() - return data.status as FastTxStatus + return data.status ? normalizeStatus(data.status) : null } catch { clearTimeout(timeoutId) return null diff --git a/src/lib/preconfirm-sound.ts b/src/lib/preconfirm-sound.ts new file mode 100644 index 00000000..490ac439 --- /dev/null +++ b/src/lib/preconfirm-sound.ts @@ -0,0 +1,50 @@ +/** + * "Level Up" sound for preconfirmation. + * Three-note rising arpeggio (C5-E5-G5) via Web Audio API. + * No external files, instant playback, ~350ms. + */ + +let audioCtx: AudioContext | null = null + +function getAudioContext(): AudioContext | null { + if (typeof window === "undefined") return null + if (!audioCtx) { + try { + audioCtx = new AudioContext() + } catch { + return null + } + } + return audioCtx +} + +export function playPreconfirmSound() { + const ctx = getAudioContext() + if (!ctx) return + + if (ctx.state === "suspended") { + ctx.resume().catch(() => {}) + } + + const now = ctx.currentTime + + // C5 → E5 → G5 rising arpeggio + const notes: [number, number, number][] = [ + [523, 0, 0.12], // C5 + [659, 0.1, 0.12], // E5 + [784, 0.2, 0.15], // G5 + ] + + for (const [freq, start, dur] of notes) { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = "sine" + osc.frequency.value = freq + gain.gain.setValueAtTime(0.08, now + start) + gain.gain.exponentialRampToValueAtTime(0.001, now + start + dur) + osc.connect(gain) + gain.connect(ctx.destination) + osc.start(now + start) + osc.stop(now + start + dur) + } +} diff --git a/src/lib/transaction-receipt-utils.ts b/src/lib/transaction-receipt-utils.ts index 9a11286f..35df92ee 100644 --- a/src/lib/transaction-receipt-utils.ts +++ b/src/lib/transaction-receipt-utils.ts @@ -56,6 +56,15 @@ async function fetchTransactionReceipt( const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS) + // Link parent abort signal so in-flight requests cancel immediately + if (abortSignal) { + if (abortSignal.aborted) { + clearTimeout(timeoutId) + return null + } + abortSignal.addEventListener("abort", () => controller.abort(), { once: true }) + } + try { const response = await fetch(RPC_URL, { method: "POST", diff --git a/src/lib/tx-config.ts b/src/lib/tx-config.ts index f29de912..a49905b7 100644 --- a/src/lib/tx-config.ts +++ b/src/lib/tx-config.ts @@ -2,9 +2,27 @@ const DEFAULT_TIMEOUT_MS = 60000 let cachedTimeoutMs: number | null = null +/** Pre-warm the timeout cache on app load so first poll has zero delay. */ +const warmupPromise = + typeof window !== "undefined" + ? fetch("/api/config/tx-timeout") + .then((res) => (res.ok ? res.json() : null)) + .then((data) => { + if (data && typeof data.timeoutMs === "number" && data.timeoutMs > 0) { + cachedTimeoutMs = data.timeoutMs + } + }) + .catch(() => {}) + : Promise.resolve() + export async function getTxConfirmationTimeoutMs(): Promise { if (cachedTimeoutMs !== null) return cachedTimeoutMs + // Wait for warmup if it's still in-flight (usually already resolved by now) + await warmupPromise + + if (cachedTimeoutMs !== null) return cachedTimeoutMs + try { const res = await fetch("/api/config/tx-timeout") if (!res.ok) throw new Error(`HTTP ${res.status}`) diff --git a/src/lib/wagmi.ts b/src/lib/wagmi.ts index 614ac9c0..88c6353b 100644 --- a/src/lib/wagmi.ts +++ b/src/lib/wagmi.ts @@ -91,12 +91,8 @@ const rpcFallbacks = [ }), // SECONDARY: Public Nodes - // http("https://eth.llamarpc.com", { timeout: 10000 }), http("https://rpc.ankr.com/eth", { timeout: 10000 }), http("https://1rpc.io/eth", { timeout: 10000 }), - - // LAST RESORT: Standard Public Node - http(), ] const bscRpcFallbacks = [ diff --git a/src/stores/swapToastStore.ts b/src/stores/swapToastStore.ts index 68e60a66..4edb36b3 100644 --- a/src/stores/swapToastStore.ts +++ b/src/stores/swapToastStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand" import type { Token } from "@/types/swap" import type { TransactionReceipt } from "viem" -export type SwapToastStatus = "pending" | "pre-confirmed" | "confirmed" | "failed" +export type SwapToastStatus = "pending" | "preconfirmed" | "confirmed" | "failed" export type SwapToast = { /** Stable id so key doesn't change when we update hash (Permit path); avoids remount jank. */ @@ -10,12 +10,16 @@ export type SwapToast = { hash: string status: SwapToastStatus collapsed: boolean + /** Timestamp when toast was created (pending). Used for elapsed time display. */ + createdAt: number + /** Timestamp when preconfirmed status was set. Freezes the elapsed timer. */ + preconfirmedAt?: number tokenIn?: Token tokenOut?: Token amountIn?: string amountOut?: string onConfirm?: () => void - /** Called when DB has success receipt (pre-confirmation). Use to reset form state. */ + /** Called when DB has success receipt (preconfirmation). Use to reset form state. */ onPreConfirm?: () => void /** Error info stored on toast when tx fails (status "failed"). */ errorMessage?: string @@ -30,7 +34,7 @@ export type SwapTxError = { receipt?: TransactionReceipt /** Raw RPC result from DB as returned (unmodified). Shown in Error Log when user clicks. */ rawDbRecord?: unknown - /** True when the tx had already reached pre-confirmed before failing (e.g. reverted after DB 0x1). Hide Try Again. */ + /** True when the tx had already reached preconfirmed before failing (e.g. reverted after DB 0x1). Hide Try Again. */ occurredAfterPreConfirm?: boolean } @@ -84,6 +88,7 @@ export const useSwapToastStore = create((set, get) => ({ hash, status: "pending", collapsed: false, + createdAt: Date.now(), tokenIn, tokenOut, amountIn, @@ -96,13 +101,23 @@ export const useSwapToastStore = create((set, get) => ({ setStatus: (hash, status) => set((s) => ({ - toasts: s.toasts.map((t) => (t.hash === hash ? { ...t, status } : t)), + toasts: s.toasts.map((t) => + t.hash === hash + ? { + ...t, + status, + ...(status === "preconfirmed" && !t.preconfirmedAt + ? { preconfirmedAt: Date.now() } + : {}), + } + : t + ), })), setFailed: (hash, receipt, message, rawDbRecord) => set((s) => { const toast = s.toasts.find((t) => t.hash === hash) - const occurredAfterPreConfirm = toast?.status === "pre-confirmed" + const occurredAfterPreConfirm = toast?.status === "preconfirmed" return { toasts: s.toasts.map((t) => t.hash === hash @@ -146,7 +161,7 @@ export const useSwapToastStore = create((set, get) => ({ collapse: (hash) => set((s) => ({ toasts: s.toasts.map((t) => - t.hash === hash && (t.status === "pending" || t.status === "pre-confirmed") + t.hash === hash && (t.status === "pending" || t.status === "preconfirmed") ? { ...t, collapsed: true } : t ), @@ -162,8 +177,9 @@ export const useSwapToastStore = create((set, get) => ({ toasts: s.toasts.filter((t) => t.hash !== hash), })), - updateToastHash: (placeholderHash, realHash) => + updateToastHash: (placeholderHash, realHash) => { set((s) => ({ toasts: s.toasts.map((t) => (t.hash === placeholderHash ? { ...t, hash: realHash } : t)), - })), + })) + }, }))