From 0f69e5ca2c89fd48bb2ff6e45060e35e8c876c64 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 18:35:19 -0700 Subject: [PATCH 01/22] feat: preconfirmation notification UX overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Redesign swap notification experience to make preconfirmations the hero moment: - Celebration animation with particle burst, glow ring, and spring-bounce Fast logo - "Preconfirmed in X.Xs" speed timer — the shareable, tweet-able differentiator - Share on X button with @Fast_Protocol mention and elapsed time - Tokens Available auto-dismisses after 5s with progress bar + balance flash - Clickable balance text fills max amount (reserves 0.01 ETH for gas) - Adaptive polling: 100ms initial → 500ms (catches sub-second preconfs) - Removed redundant getFreshGasFees() call (-300ms before wallet popup) - Removed duplicate polling from useSwapConfirmation (halves API calls) - Pre-warmed tx-timeout config, abort signal propagation - Standardized "preconfirmed" (no hyphen) across all UI code Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/fast-tx-status/[hash]/route.ts | 3 +- src/components/swap/BuyCard.tsx | 24 +- src/components/swap/PreconfirmCelebration.tsx | 138 ++++++++ src/components/swap/SellCard.tsx | 30 +- src/components/swap/SwapToast.tsx | 298 ++++++++++++++---- src/components/swap/SwapToastContainer.tsx | 13 +- src/hooks/use-balance-flash.ts | 31 ++ src/hooks/use-swap-confirmation.ts | 52 +-- src/hooks/use-wait-for-tx-confirmation.ts | 29 +- src/lib/fast-tx-status.ts | 20 +- src/lib/transaction-receipt-utils.ts | 6 + src/lib/tx-config.ts | 18 ++ src/stores/swapToastStore.ts | 25 +- 13 files changed, 554 insertions(+), 133 deletions(-) create mode 100644 src/components/swap/PreconfirmCelebration.tsx create mode 100644 src/hooks/use-balance-flash.ts 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/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..1542b24a 100644 --- a/src/components/swap/SellCard.tsx +++ b/src/components/swap/SellCard.tsx @@ -10,10 +10,14 @@ 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 { QuoteResult } from "@/hooks/use-swap-quote" import { UseBalanceReturnType } from "wagmi" +import { ZERO_ADDRESS } from "@/lib/swap-constants" interface SellCardProps { // Token & Balance Data @@ -68,6 +72,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 +81,19 @@ const SellCardComponent: React.FC = ({ setHasImageError(false) }, [fromToken?.address]) + const handleMaxBalance = () => { + if (!fromToken || fromBalanceValue <= 0) return + setEditingSide("sell") + setIsManualInversion(false) + setSwappedQuote(null) + // Reserve gas for native ETH swaps + const isNativeEth = fromToken.address === ZERO_ADDRESS + const reserveForGas = isNativeEth ? 0.01 : 0 + const maxAmount = Math.max(0, fromBalanceValue - reserveForGas) + if (maxAmount <= 0) return + setAmount(maxAmount.toString()) + } + const handleAmountChange = (value: string) => { setEditingSide("sell") setAmount(value) @@ -87,7 +105,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..5a8999c1 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, Check } 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,11 +16,18 @@ import { } from "@/lib/transaction-errors" import { FAST_PROTOCOL_NETWORK } from "@/lib/network-config" import { TokenPairIcon } from "./TokenPairIcon" +import { PreconfirmCelebration, PreconfirmGlow } from "./PreconfirmCelebration" import { cn } from "@/lib/utils" +/** Auto-dismiss delay for "Tokens Available" toast (ms). */ +const CONFIRMED_AUTO_DISMISS_MS = 5000 + /** * SwapToast handles the multi-stage lifecycle of a transaction: - * pending → pre-confirmed → confirmed (or failed at any point). + * pending → preconfirmed → confirmed (or failed at any point). + * + * Preconfirmed = hero celebration moment (particles, glow, scale bounce) + * Confirmed = nonchalant "tokens available" that auto-dismisses */ export function SwapToast({ hash }: { hash: string }) { const toast = useSwapToastStore((s) => s.toasts.find((t) => t.hash === hash)) @@ -52,7 +61,7 @@ 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") const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash) t?.onPreConfirm?.() } @@ -66,6 +75,13 @@ export function SwapToast({ hash }: { hash: string }) { }, }) + // Auto-dismiss confirmed toasts after delay + useEffect(() => { + if (toast?.status !== "confirmed") return + const timer = setTimeout(() => removeToast(hash), CONFIRMED_AUTO_DISMISS_MS) + return () => clearTimeout(timer) + }, [toast?.status, hash, removeToast]) + // Click Outside Logic useEffect(() => { const handleClickOutside = (e: MouseEvent) => { @@ -85,7 +101,7 @@ export function SwapToast({ hash }: { hash: string }) { const isPending = toast.status === "pending" const isConfirmed = toast.status === "confirmed" - const isPreConfirmed = toast.status === "pre-confirmed" + const isPreConfirmed = toast.status === "preconfirmed" const isFailed = toast.status === "failed" const explorerUrl = effectiveHash ? `${FAST_PROTOCOL_NETWORK.blockExplorerUrls[0]}tx/${effectiveHash}` @@ -185,110 +201,276 @@ export function SwapToast({ hash }: { hash: string }) { } // Collapsed State: Minimalist bubble - if ((isPending || toast.status === "pre-confirmed") && toast.collapsed) { + if ((isPending || isPreConfirmed) && toast.collapsed) { return ( ) } + // Confirmed = nonchalant auto-dismissing notification + if (isConfirmed) { + return ( + explorerUrl && window.open(explorerUrl, "_blank")} + initial={{ opacity: 0, y: -8, scale: 0.96 }} + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={{ opacity: 0, y: -12, scale: 0.95 }} + transition={{ duration: 0.3, ease: [0.2, 0.8, 0.2, 1] }} + className={cn( + "relative w-[360px] overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl border border-green-500/20 hover:border-green-500/40 transition-colors", + explorerUrl ? "cursor-pointer" : "cursor-default" + )} + > + {/* Auto-dismiss progress bar */} + + +
+ {/* Green checkmark icon */} + +
+ +
+
+ +
+ Tokens Available +
+ {toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} + {toast.tokenOut?.symbol} +
+
+ +
+ {explorerUrl && ( + + )} + +
+
+
+ ) + } + + // Pending & Preconfirmed states 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" + "relative w-[360px] overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl transition-colors duration-300 border", + isPreConfirmed + ? "border-blue-500/30 shadow-blue-500/10" + : "border-white/10 hover:border-white/20", + explorerUrl ? "cursor-pointer" : "cursor-default" )} >
- {/* LEFT ICON: Token pair for pending, Fast icon for pre-confirmed/confirmed */} -
+ {/* LEFT ICON: Token pair → Fast logo with celebration */} +
+ {/* Celebration particles (fires once on preconfirmed) */} + + + {/* Glow behind logo */} + + + {/* Token pair (pending state) */}
-
+ {isPreConfirmed && ( + + Fast Protocol + )} - > - Fast Protocol -
+
{/* MIDDLE TEXT: Status label + amounts */}
- + {isPreConfirmed ? ( + + + Preconfirmed + {toast.preconfirmedAt && toast.createdAt && ( + + in {((toast.preconfirmedAt - toast.createdAt) / 1000).toFixed(1)}s + + )} + +
+ {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 */}
- - {/* Background ring for visual depth */}
)}
-
+ + {/* Preconfirmed: animated blue border glow */} + {isPreConfirmed && ( + + )} + ) } 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..f24d460f 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -5,17 +5,12 @@ import { useAccount, usePublicClient, useSendTransaction, - useWaitForTransactionReceipt, } from "wagmi" -import { - useBroadcastGasPrice, - ETH_PATH_GAS_LIMIT_MULTIPLIER, -} from "@/hooks/use-broadcast-gas-price" +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 { fetchEthPathTxAndEstimate } from "@/lib/eth-path-tx" import type { Token } from "@/types/swap" @@ -51,14 +46,12 @@ 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 { sendTransactionAsync } = useSendTransaction() @@ -71,34 +64,8 @@ export function useSwapConfirmation({ 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, - }) + // Note: Confirmation polling is handled by SwapToast (single source of truth). + // Removed duplicate useWaitForTxConfirmation here to halve API calls per swap. // Sync confirmation status based on hash availability useEffect(() => { @@ -202,13 +169,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 +184,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({ @@ -302,7 +259,6 @@ export function useSwapConfirmation({ }) 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" diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 815aefe7..7c147b4d 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -7,7 +7,17 @@ import { fetchTransactionReceiptFromDb } from "@/lib/transaction-receipt-utils" 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 +34,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 +48,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 +173,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) { @@ -205,9 +216,9 @@ export function useWaitForTxConfirmation({ return } - // Either source signals pre-confirmed → fire and move to phase 2 + // Either source signals preconfirmed → fire and move to phase 2 if ( - mcStatus === "pre-confirmed" || + mcStatus === "preconfirmed" || mcStatus === "confirmed" || (rpcResult && rpcResult.receipt.status === "success") ) { @@ -227,7 +238,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 +292,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/fast-tx-status.ts b/src/lib/fast-tx-status.ts index 5e8004a2..3e257f2d 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,12 @@ 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 +39,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/transaction-receipt-utils.ts b/src/lib/transaction-receipt-utils.ts index 9a11286f..1d56af4a 100644 --- a/src/lib/transaction-receipt-utils.ts +++ b/src/lib/transaction-receipt-utils.ts @@ -56,6 +56,12 @@ 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/stores/swapToastStore.ts b/src/stores/swapToastStore.ts index 68e60a66..532682bc 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,21 @@ 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 +159,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 ), From 5616aa680fe6d92ada7aee118f698a1bb707c1c0 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 18:41:34 -0700 Subject: [PATCH 02/22] fix: fallback gas limit when estimation reverts on ETH path Gas estimation can revert if the quote is stale or liquidity shifted between quote and execution. Instead of blocking the user with an opaque "Execution reverted" error before the wallet popup, fall back to a generous 500k gas limit so the tx can still be attempted. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/eth-path-tx.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/lib/eth-path-tx.ts b/src/lib/eth-path-tx.ts index a7477f7a..fced6a05 100644 --- a/src/lib/eth-path-tx.ts +++ b/src/lib/eth-path-tx.ts @@ -48,12 +48,22 @@ 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), - }) + // Gas estimation can revert if the quote is stale or liquidity shifted. + // Fall back to a generous static limit so the user still gets the wallet popup — + // the actual on-chain execution determines success/failure. + const FALLBACK_GAS_LIMIT = 500_000n + 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 { + console.warn("[eth-path-tx] Gas estimation reverted, using fallback limit") + estimated = FALLBACK_GAS_LIMIT + } return { to: data.to, From d3c57582740a7acbdc54687ea741037053fde474 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 18:45:54 -0700 Subject: [PATCH 03/22] fix: show clear error instead of raw viem dump on gas estimation revert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the silent gas fallback with a user-friendly error message when gas estimation reverts. The revert means the tx would fail on-chain, so we should block it — not burn the user's gas on a doomed tx. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/eth-path-tx.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/lib/eth-path-tx.ts b/src/lib/eth-path-tx.ts index fced6a05..1e2a7787 100644 --- a/src/lib/eth-path-tx.ts +++ b/src/lib/eth-path-tx.ts @@ -48,10 +48,6 @@ export async function fetchEthPathTxAndEstimate( throw new Error(apiError) } - // Gas estimation can revert if the quote is stale or liquidity shifted. - // Fall back to a generous static limit so the user still gets the wallet popup — - // the actual on-chain execution determines success/failure. - const FALLBACK_GAS_LIMIT = 500_000n let estimated: bigint try { estimated = await publicClient.estimateGas({ @@ -60,9 +56,11 @@ export async function fetchEthPathTxAndEstimate( data: data.data as `0x${string}`, value: BigInt(data.value || 0), }) - } catch { - console.warn("[eth-path-tx] Gas estimation reverted, using fallback limit") - estimated = FALLBACK_GAS_LIMIT + } 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 { From b8fa9cd2aada73f5fa913f02be5b66c38eb73e6e Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 18:52:29 -0700 Subject: [PATCH 04/22] style: unified toast card that evolves through swap lifecycle Single card transitions in place instead of swapping between components: - Confirmed: green checkmark badge on Fast logo corner, title changes to "Tokens Available", green accent line, auto-dismiss countdown - Share on X: floating pill popup that slides in 2s after preconfirmed, positioned below the card - Dismiss: subtle 5px circle in top-right corner, only visible on hover - Explorer link: minimal icon in right column, no button chrome Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 393 +++++++++++++----------------- 1 file changed, 169 insertions(+), 224 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 5a8999c1..55b7c693 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -5,7 +5,7 @@ import Image from "next/image" import { motion, AnimatePresence } from "motion/react" import { useWaitForTransactionReceipt } from "wagmi" import type { TransactionReceipt } from "viem" -import { X, RefreshCw, ExternalLink, Check } 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" @@ -19,15 +19,20 @@ import { TokenPairIcon } from "./TokenPairIcon" import { PreconfirmCelebration, PreconfirmGlow } from "./PreconfirmCelebration" import { cn } from "@/lib/utils" -/** Auto-dismiss delay for "Tokens Available" toast (ms). */ -const CONFIRMED_AUTO_DISMISS_MS = 5000 +/** Auto-dismiss delay for confirmed state (ms). */ +const CONFIRMED_AUTO_DISMISS_MS = 6000 +/** Delay before share button floats in (ms after preconfirmed). */ +const SHARE_POPUP_DELAY_S = 2 /** - * SwapToast handles the multi-stage lifecycle of a transaction: - * pending → preconfirmed → confirmed (or failed at any point). + * SwapToast — a single evolving card for the full swap lifecycle: * - * Preconfirmed = hero celebration moment (particles, glow, scale bounce) - * Confirmed = nonchalant "tokens available" that auto-dismisses + * pending → preconfirmed → confirmed (or failed at any point) + * + * The card never swaps out; it transitions in place: + * - Pending: spinner, token pair icon, white border + * - Preconfirmed: celebration particles, Fast logo, blue accent, speed timer + * - Confirmed: green checkmark badge on Fast logo, title change, green accent, auto-dismiss */ export function SwapToast({ hash }: { hash: string }) { const toast = useSwapToastStore((s) => s.toasts.find((t) => t.hash === hash)) @@ -41,7 +46,7 @@ export function SwapToast({ hash }: { hash: string }) { const toastRef = useRef(null) - // Placeholder hash (Permit path) until relayer returns real hash; don't poll until then + // Placeholder hash (Permit path) until relayer returns real hash const effectiveHash = hash.startsWith("pending-") ? undefined : hash const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({ @@ -75,43 +80,44 @@ export function SwapToast({ hash }: { hash: string }) { }, }) - // Auto-dismiss confirmed toasts after delay + // Auto-dismiss after confirmed useEffect(() => { if (toast?.status !== "confirmed") return const timer = setTimeout(() => removeToast(hash), CONFIRMED_AUTO_DISMISS_MS) return () => clearTimeout(timer) }, [toast?.status, hash, removeToast]) - // Click Outside Logic + // Click outside useEffect(() => { - const handleClickOutside = (e: MouseEvent) => { + 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 isConfirmed = toast.status === "confirmed" const isPreConfirmed = toast.status === "preconfirmed" + const isConfirmed = toast.status === "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 states (slippage retry + generic) ────────────────────── 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}%
-
@@ -160,7 +159,6 @@ export function SwapToast({ hash }: { hash: string }) { ) } - // 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 + // ── Collapsed bubble ────────────────────────────────────────────── if ((isPending || isPreConfirmed) && toast.collapsed) { return ( - )} - -
-
- - ) - } - - // Pending & Preconfirmed states + // ── Main card: single element that evolves ──────────────────────── return ( explorerUrl && window.open(explorerUrl, "_blank")} layout className={cn( - "relative w-[360px] overflow-hidden rounded-2xl bg-neutral-900 shadow-2xl transition-colors duration-300 border", - isPreConfirmed - ? "border-blue-500/30 shadow-blue-500/10" - : "border-white/10 hover:border-white/20", - explorerUrl ? "cursor-pointer" : "cursor-default" + "group relative w-[360px] overflow-visible 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 → Fast logo with celebration */} + {/* ── Subtle corner dismiss ── */} + + + {/* ── Card body ── */} +
explorerUrl && window.open(explorerUrl, "_blank")} + className={cn("relative h-[84px] p-4 flex items-center gap-4", explorerUrl && "cursor-pointer")} + > + {/* LEFT: Icon area */}
{/* Celebration particles (fires once on preconfirmed) */} + - {/* Glow behind logo */} - - - {/* Token pair (pending state) */} + {/* Token pair (pending) */}
- {/* Fast logo (preconfirmed state) — bounces in with scale */} + {/* Fast logo (preconfirmed+confirmed) */} - {isPreConfirmed && ( + {settled && ( )} + + {/* Green checkmark badge (confirmed — overlays bottom-right of Fast logo) */} + + {isConfirmed && ( + + + + + + )} +
- {/* MIDDLE TEXT: Status label + amounts */} + {/* MIDDLE: Text */}
- {isPreConfirmed ? ( + {isConfirmed ? ( + + + Tokens Available + +
+ {toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} + {toast.tokenOut?.symbol} +
+
+ ) : isPreConfirmed ? ( Preconfirmed - {toast.preconfirmedAt && toast.createdAt && ( - - in {((toast.preconfirmedAt - toast.createdAt) / 1000).toFixed(1)}s + {elapsedSec && ( + + in {elapsedSec}s )} @@ -397,80 +353,69 @@ export function SwapToast({ hash }: { hash: string }) {
- {/* RIGHT ACTION/STATUS */} -
- {isPreConfirmed && explorerUrl && ( - { - e.stopPropagation() - window.open(explorerUrl, "_blank") - }} - className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors" - aria-label="View on explorer" - > - - - )} - - {isPreConfirmed && toast.preconfirmedAt && toast.createdAt && ( - { - e.stopPropagation() - const elapsed = ((toast.preconfirmedAt! - toast.createdAt) / 1000).toFixed(1) - const tweet = `Just swapped on @Fast_Protocol — preconfirmed in ${elapsed}s\n\nhttps://fastprotocol.xyz` - window.open( - `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, - "_blank" - ) - }} - className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-500/10 hover:bg-blue-500/20 transition-colors" - aria-label="Share on X" - > - - - )} - - {isPreConfirmed ? ( - { - e.stopPropagation() - removeToast(hash) - }} - className="flex h-8 w-8 items-center justify-center rounded-full bg-white/5 hover:bg-white/10 transition-colors" - aria-label="Dismiss" + {/* RIGHT: Status indicator */} +
+ {settled && explorerUrl ? ( + - - - ) : ( -
-
+ + + ) : isPending ? ( +
+
- )} + ) : null}
- {/* Preconfirmed: animated blue border glow */} - {isPreConfirmed && ( - - )} + {/* ── Bottom accent line ── */} + + {isConfirmed && ( + + )} + {isPreConfirmed && !isConfirmed && ( + + )} + + + {/* ── Floating share-on-X popup ── */} + + {(isPreConfirmed || isConfirmed) && elapsedSec && ( + { + e.stopPropagation() + const tweet = `Just swapped on @Fast_Protocol — preconfirmed in ${elapsedSec}s\n\nhttps://fastprotocol.xyz` + window.open( + `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, + "_blank" + ) + }} + className="absolute -bottom-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-neutral-800 border border-white/10 shadow-lg hover:bg-neutral-700 hover:border-white/20 transition-colors" + > + + Share + + )} + ) } From 67bd992a61683aebf22727c714162e66647206eb Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 18:59:43 -0700 Subject: [PATCH 05/22] feat: synthesized whoosh + chime sound on preconfirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Web Audio API synthesized sound — no external files: - Quick bandpass-filtered noise sweep (whoosh, 150ms) - Rising two-note sine chime (C6→E6, ~200ms) - Total ~300ms, subtle volume, respects browser autoplay policy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 2 + src/lib/preconfirm-sound.ts | 79 +++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 src/lib/preconfirm-sound.ts diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 55b7c693..837c11ce 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -17,6 +17,7 @@ import { 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" /** Auto-dismiss delay for confirmed state (ms). */ @@ -67,6 +68,7 @@ export function SwapToast({ hash }: { hash: string }) { const currentStatus = useSwapToastStore.getState().toasts.find((t) => t.hash === hash)?.status if (effectiveHash && currentStatus !== "confirmed") { setStatus(hash, "preconfirmed") + playPreconfirmSound() const t = useSwapToastStore.getState().toasts.find((x) => x.hash === hash) t?.onPreConfirm?.() } diff --git a/src/lib/preconfirm-sound.ts b/src/lib/preconfirm-sound.ts new file mode 100644 index 00000000..2c046113 --- /dev/null +++ b/src/lib/preconfirm-sound.ts @@ -0,0 +1,79 @@ +/** + * Short synthesized "whoosh + chime" sound for preconfirmation. + * Uses Web Audio API — no external files, instant playback. + * Respects user's system; only plays once per call. + */ + +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 +} + +/** + * Fast "whoosh + rising chime" — ~300ms total. + * Feels like a quick confirmation ding with velocity. + */ +export function playPreconfirmSound() { + const ctx = getAudioContext() + if (!ctx) return + + // Resume if suspended (browser autoplay policy) + if (ctx.state === "suspended") { + ctx.resume().catch(() => {}) + } + + const now = ctx.currentTime + + // ── Whoosh: filtered noise sweep ── + const noiseLength = 0.15 + const noiseBuffer = ctx.createBuffer(1, ctx.sampleRate * noiseLength, ctx.sampleRate) + const noiseData = noiseBuffer.getChannelData(0) + for (let i = 0; i < noiseData.length; i++) { + noiseData[i] = (Math.random() * 2 - 1) * 0.3 + } + const noise = ctx.createBufferSource() + noise.buffer = noiseBuffer + + const noiseFilter = ctx.createBiquadFilter() + noiseFilter.type = "bandpass" + noiseFilter.frequency.setValueAtTime(2000, now) + noiseFilter.frequency.exponentialRampToValueAtTime(6000, now + 0.1) + noiseFilter.Q.value = 0.5 + + const noiseGain = ctx.createGain() + noiseGain.gain.setValueAtTime(0.08, now) + noiseGain.gain.exponentialRampToValueAtTime(0.01, now + noiseLength) + + noise.connect(noiseFilter) + noiseFilter.connect(noiseGain) + noiseGain.connect(ctx.destination) + noise.start(now) + noise.stop(now + noiseLength) + + // ── Chime: two quick rising tones ── + const playTone = (freq: number, start: number, dur: number, vol: number) => { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + osc.type = "sine" + osc.frequency.setValueAtTime(freq, now + start) + gain.gain.setValueAtTime(vol, 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) + } + + // Rising two-note chime (C6 → E6) + playTone(1047, 0.04, 0.18, 0.06) // C6 + playTone(1319, 0.10, 0.22, 0.08) // E6 +} From 79bcbd556c923bc6a0d82a3a1d14523ed9697472 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:04:22 -0700 Subject: [PATCH 06/22] style: switch preconfirm sound to level-up arpeggio (C5-E5-G5) Three-note rising major arpeggio, ~350ms. Game-like dopamine hit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/preconfirm-sound.ts | 53 +++++++++---------------------------- 1 file changed, 12 insertions(+), 41 deletions(-) diff --git a/src/lib/preconfirm-sound.ts b/src/lib/preconfirm-sound.ts index 2c046113..123920a4 100644 --- a/src/lib/preconfirm-sound.ts +++ b/src/lib/preconfirm-sound.ts @@ -1,7 +1,7 @@ /** - * Short synthesized "whoosh + chime" sound for preconfirmation. - * Uses Web Audio API — no external files, instant playback. - * Respects user's system; only plays once per call. + * "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 @@ -18,62 +18,33 @@ function getAudioContext(): AudioContext | null { return audioCtx } -/** - * Fast "whoosh + rising chime" — ~300ms total. - * Feels like a quick confirmation ding with velocity. - */ export function playPreconfirmSound() { const ctx = getAudioContext() if (!ctx) return - // Resume if suspended (browser autoplay policy) if (ctx.state === "suspended") { ctx.resume().catch(() => {}) } const now = ctx.currentTime - // ── Whoosh: filtered noise sweep ── - const noiseLength = 0.15 - const noiseBuffer = ctx.createBuffer(1, ctx.sampleRate * noiseLength, ctx.sampleRate) - const noiseData = noiseBuffer.getChannelData(0) - for (let i = 0; i < noiseData.length; i++) { - noiseData[i] = (Math.random() * 2 - 1) * 0.3 - } - const noise = ctx.createBufferSource() - noise.buffer = noiseBuffer - - const noiseFilter = ctx.createBiquadFilter() - noiseFilter.type = "bandpass" - noiseFilter.frequency.setValueAtTime(2000, now) - noiseFilter.frequency.exponentialRampToValueAtTime(6000, now + 0.1) - noiseFilter.Q.value = 0.5 + // 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 + ] - const noiseGain = ctx.createGain() - noiseGain.gain.setValueAtTime(0.08, now) - noiseGain.gain.exponentialRampToValueAtTime(0.01, now + noiseLength) - - noise.connect(noiseFilter) - noiseFilter.connect(noiseGain) - noiseGain.connect(ctx.destination) - noise.start(now) - noise.stop(now + noiseLength) - - // ── Chime: two quick rising tones ── - const playTone = (freq: number, start: number, dur: number, vol: number) => { + for (const [freq, start, dur] of notes) { const osc = ctx.createOscillator() const gain = ctx.createGain() osc.type = "sine" - osc.frequency.setValueAtTime(freq, now + start) - gain.gain.setValueAtTime(vol, now + start) + 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) } - - // Rising two-note chime (C6 → E6) - playTone(1047, 0.04, 0.18, 0.06) // C6 - playTone(1319, 0.10, 0.22, 0.08) // E6 } From acd711ca0e2314f7b34b2397d9cadcabc6f4c501 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:12:45 -0700 Subject: [PATCH 07/22] style: prominent speed badge, flush share strip, bare dismiss X - Speed: bold badge to the right (blue/green bg pill with Xs value) - Explorer link: larger icon, color-matched to state (blue/green) - Share on X: flush strip below card body with border-t separator, slides in after 1.5s, feels like a natural extension of the card - Dismiss X: bare glyph, no circle/bubble, hover-only opacity Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 179 +++++++++++++++--------------- 1 file changed, 89 insertions(+), 90 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 837c11ce..24ec356b 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -20,21 +20,9 @@ import { PreconfirmCelebration, PreconfirmGlow } from "./PreconfirmCelebration" import { playPreconfirmSound } from "@/lib/preconfirm-sound" import { cn } from "@/lib/utils" -/** Auto-dismiss delay for confirmed state (ms). */ const CONFIRMED_AUTO_DISMISS_MS = 6000 -/** Delay before share button floats in (ms after preconfirmed). */ -const SHARE_POPUP_DELAY_S = 2 +const SHARE_STRIP_DELAY_S = 1.5 -/** - * SwapToast — a single evolving card for the full swap lifecycle: - * - * pending → preconfirmed → confirmed (or failed at any point) - * - * The card never swaps out; it transitions in place: - * - Pending: spinner, token pair icon, white border - * - Preconfirmed: celebration particles, Fast logo, blue accent, speed timer - * - Confirmed: green checkmark badge on Fast logo, title change, green accent, auto-dismiss - */ export function SwapToast({ hash }: { hash: string }) { const toast = useSwapToastStore((s) => s.toasts.find((t) => t.hash === hash)) const setStatus = useSwapToastStore((s) => s.setStatus) @@ -46,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 const effectiveHash = hash.startsWith("pending-") ? undefined : hash const { data: receipt, error: receiptError } = useWaitForTransactionReceipt({ @@ -82,14 +68,12 @@ export function SwapToast({ hash }: { hash: string }) { }, }) - // Auto-dismiss after confirmed useEffect(() => { if (toast?.status !== "confirmed") return const timer = setTimeout(() => removeToast(hash), CONFIRMED_AUTO_DISMISS_MS) return () => clearTimeout(timer) }, [toast?.status, hash, removeToast]) - // Click outside useEffect(() => { const handler = (e: MouseEvent) => { if (!toastRef.current?.contains(e.target as Node) && toast) { @@ -116,7 +100,7 @@ export function SwapToast({ hash }: { hash: string }) { ? ((toast.preconfirmedAt - toast.createdAt) / 1000).toFixed(1) : null - // ── Failed states (slippage retry + generic) ────────────────────── + // ── Failed ──────────────────────────────────────────────────────── if (isFailed) { const barterSlippage = toast.errorMessage ? parseBarterSlippageError(toast.errorMessage) : null @@ -136,26 +120,24 @@ export function SwapToast({ hash }: { hash: string }) { Minimum required: {barterSlippage.recommendedSlippage}%
-
- - -
+ +
) @@ -183,17 +165,17 @@ export function SwapToast({ hash }: { hash: string }) {
) } - // ── Collapsed bubble ────────────────────────────────────────────── + // ── Collapsed ───────────────────────────────────────────────────── if ((isPending || isPreConfirmed) && toast.collapsed) { return ( {/* ── Card body ── */} @@ -245,15 +224,13 @@ export function SwapToast({ hash }: { hash: string }) { role="button" tabIndex={0} onClick={() => explorerUrl && window.open(explorerUrl, "_blank")} - className={cn("relative h-[84px] p-4 flex items-center gap-4", explorerUrl && "cursor-pointer")} + className={cn("relative h-[84px] p-4 flex items-center gap-4", explorerUrl && settled && "cursor-pointer")} > - {/* LEFT: Icon area */} + {/* LEFT: Icon */}
- {/* Celebration particles (fires once on preconfirmed) */} - {/* Token pair (pending) */}
- {/* Fast logo (preconfirmed+confirmed) */} {settled && ( - {/* Green checkmark badge (confirmed — overlays bottom-right of Fast logo) */} + {/* Green checkmark badge */} {isConfirmed && ( - - Tokens Available - + Tokens Available
{toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} {toast.tokenOut?.symbol} @@ -330,14 +304,7 @@ export function SwapToast({ hash }: { hash: string }) { animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.3 }} > - - Preconfirmed - {elapsedSec && ( - - in {elapsedSec}s - - )} - + Preconfirmed
{toast.amountIn ?? "—"} {toast.tokenIn?.symbol} → {toast.amountOut ?? "—"}{" "} {toast.tokenOut?.symbol} @@ -355,15 +322,41 @@ export function SwapToast({ hash }: { hash: string }) {
- {/* RIGHT: Status indicator */} -
+ {/* RIGHT: Speed badge + explorer link */} +
+ {/* Speed badge — prominent */} + + {settled && elapsedSec && ( + + {elapsedSec}s + + )} + + + {/* Explorer link */} {settled && explorerUrl ? ( - + ) : isPending ? (
@@ -378,7 +371,7 @@ export function SwapToast({ hash }: { hash: string }) { {isConfirmed && ( - {/* ── Floating share-on-X popup ── */} + {/* ── Share on X: flush strip below card body ── */} {(isPreConfirmed || isConfirmed) && elapsedSec && ( - { - e.stopPropagation() - const tweet = `Just swapped on @Fast_Protocol — preconfirmed in ${elapsedSec}s\n\nhttps://fastprotocol.xyz` - window.open( - `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, - "_blank" - ) - }} - className="absolute -bottom-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-neutral-800 border border-white/10 shadow-lg hover:bg-neutral-700 hover:border-white/20 transition-colors" + - - Share - +
+ +
+
)}
From c49f3368ae227da6e98cb5c6f64e1357d4af8d8d Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:16:29 -0700 Subject: [PATCH 08/22] perf: call FastRPC directly for ERC-20 swaps, skip Vercel proxy The /api/fastswap proxy was a pure pass-through adding 100-300ms (serverless cold start + extra hop). FastRPC CORS allows direct client calls. This cuts the "Swapping..." spinner duration for ERC-20 permit path swaps. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/use-swap-confirmation.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index f24d460f..defd58d4 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -12,6 +12,7 @@ import { parseUnits, formatUnits } from "viem" import { useSwapIntent } from "@/hooks/use-swap-intent" import { usePermit2Nonce } from "@/hooks/use-permit2-nonce" 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" @@ -252,7 +253,8 @@ 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 (~100-300ms saved) + const resp = await fetch(`${FASTSWAP_API_BASE}/fastswap`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), From e36f633aeea08546bf9e71934fe61bce239eadd0 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:26:24 -0700 Subject: [PATCH 09/22] fix: max balance click using raw BigInt balance instead of parsed float The previous implementation used fromBalanceValue (parseFloat) which could lose precision and produce floating point artifacts like 0.08990300000000001. Now uses fromBalance.value (BigInt wei) with formatUnits for exact representation. Also fixes disabled check to use raw balance instead of parsed float. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SellCard.tsx | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/src/components/swap/SellCard.tsx b/src/components/swap/SellCard.tsx index 1542b24a..8ac778da 100644 --- a/src/components/swap/SellCard.tsx +++ b/src/components/swap/SellCard.tsx @@ -4,6 +4,7 @@ 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 @@ -82,16 +83,21 @@ const SellCardComponent: React.FC = ({ }, [fromToken?.address]) const handleMaxBalance = () => { - if (!fromToken || fromBalanceValue <= 0) return + if (!fromToken || !fromBalance || fromBalance.value === 0n) return setEditingSide("sell") setIsManualInversion(false) setSwappedQuote(null) - // Reserve gas for native ETH swaps + const isNativeEth = fromToken.address === ZERO_ADDRESS - const reserveForGas = isNativeEth ? 0.01 : 0 - const maxAmount = Math.max(0, fromBalanceValue - reserveForGas) - if (maxAmount <= 0) return - setAmount(maxAmount.toString()) + 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) => { @@ -108,7 +114,7 @@ const SellCardComponent: React.FC = ({
, diff --git a/src/app/share/preconfirm/page.tsx b/src/app/share/preconfirm/page.tsx index dbdd4025..988ed0e7 100644 --- a/src/app/share/preconfirm/page.tsx +++ b/src/app/share/preconfirm/page.tsx @@ -2,20 +2,18 @@ import { Metadata } from "next" import { redirect } from "next/navigation" interface Props { - searchParams: Promise<{ time?: string; in?: string; out?: string }> + searchParams: Promise<{ time?: string }> } export async function generateMetadata({ searchParams }: Props): Promise { const params = await searchParams const time = params.time || "0.4" - const tokenIn = params.in || "ETH" - const tokenOut = params.out || "USDC" const secs = parseFloat(time) const fire = secs < 1 ? "\u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? "\u{1F525}\u{1F525}" : "\u{1F525}" const title = `${fire} Preconfirmed in ${time}s — Fast Swaps` - const description = `${tokenIn} → ${tokenOut} swap preconfirmed in ${time} seconds on Fast Protocol` - const ogUrl = `/api/og/preconfirm?time=${time}&in=${tokenIn}&out=${tokenOut}` + const description = `Swap preconfirmed in ${time} seconds on Fast Protocol` + const ogUrl = `/api/og/preconfirm?time=${time}` return { title, diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 7332620b..4621ee24 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -404,10 +404,8 @@ export function SwapToast({ hash }: { hash: string }) { e.stopPropagation() const secs = parseFloat(elapsedSec!) const fire = secs < 1 ? "\u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? "\u{1F525}\u{1F525}" : "\u{1F525}" - const tokenIn = toast.tokenIn?.symbol || "ETH" - const tokenOut = toast.tokenOut?.symbol || "USDC" - const shareUrl = `${window.location.origin}/share/preconfirm?time=${elapsedSec}&in=${tokenIn}&out=${tokenOut}` - const tweet = `${fire} Preconfirmed in ${elapsedSec}s — Fast Swaps\n\n${tokenIn} → ${tokenOut} swap preconfirmed on @Fast_Protocol\n\n${shareUrl}` + const shareUrl = `${window.location.origin}/share/preconfirm?time=${elapsedSec}` + const tweet = `${fire} Swap preconfirmed in ${elapsedSec}s on @Fast_Protocol\n\n${shareUrl}` window.open( `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, "_blank" From a8a8b0c2fa2f369730818d34948f8d7029623040 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:42:30 -0700 Subject: [PATCH 12/22] style: clean OG card, natural tweet variations, fire emoji at end only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OG card: removed emoji, clean layout — speed number, "Swap Preconfirmed" label, accent line, fastprotocol.io branding. No pair names. Tweet: natural message variations based on speed tier: <1s: "Didn't even see a spinner" / "That's a Fast Swap" 1-4s: "Before the block even landed" / "Hit different" >4s: "Still faster than waiting for a block" Fire emoji at end of text only (1-3 based on speed). No URL in tweet — just the tag and the OG image card. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/og/preconfirm/route.tsx | 80 +++++++++-------------------- src/app/share/preconfirm/page.tsx | 5 +- src/components/swap/SwapToast.tsx | 24 +++++++-- 3 files changed, 46 insertions(+), 63 deletions(-) diff --git a/src/app/api/og/preconfirm/route.tsx b/src/app/api/og/preconfirm/route.tsx index 7ba1b859..18b72eea 100644 --- a/src/app/api/og/preconfirm/route.tsx +++ b/src/app/api/og/preconfirm/route.tsx @@ -11,9 +11,6 @@ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl const time = searchParams.get("time") || "0.4" - const secs = parseFloat(time) - const fire = secs < 1 ? "\u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? "\u{1F525}\u{1F525}" : "\u{1F525}" - return new ImageResponse(
- {/* Top: Fast Swaps label */} + {/* Top label */}
-
- Fast Swaps -
+ Swap Preconfirmed
- {/* Center: speed number */} + {/* Speed number */}
@@ -96,47 +85,26 @@ export async function GET(request: NextRequest) {
- {/* Fire emojis */} + {/* Thin accent line */}
- {fire} -
- - {/* Label */} -
- swap preconfirmed -
+ /> - {/* Bottom: branding */} + {/* Branding */}
-
- fastprotocol.io -
+ fastprotocol.io
, { diff --git a/src/app/share/preconfirm/page.tsx b/src/app/share/preconfirm/page.tsx index 988ed0e7..2bf5075d 100644 --- a/src/app/share/preconfirm/page.tsx +++ b/src/app/share/preconfirm/page.tsx @@ -8,10 +8,7 @@ interface Props { export async function generateMetadata({ searchParams }: Props): Promise { const params = await searchParams const time = params.time || "0.4" - const secs = parseFloat(time) - const fire = secs < 1 ? "\u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? "\u{1F525}\u{1F525}" : "\u{1F525}" - - const title = `${fire} Preconfirmed in ${time}s — Fast Swaps` + 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}` diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 4621ee24..dab27606 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -403,9 +403,27 @@ export function SwapToast({ hash }: { hash: string }) { onClick={(e) => { e.stopPropagation() const secs = parseFloat(elapsedSec!) - const fire = secs < 1 ? "\u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? "\u{1F525}\u{1F525}" : "\u{1F525}" - const shareUrl = `${window.location.origin}/share/preconfirm?time=${elapsedSec}` - const tweet = `${fire} Swap preconfirmed in ${elapsedSec}s on @Fast_Protocol\n\n${shareUrl}` + const fire = secs < 1 ? " \u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? " \u{1F525}\u{1F525}" : " \u{1F525}" + + // Variations grounded in the actual speed experience + const fast = [ + `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Didn't even see a spinner.${fire}`, + `${elapsedSec}s from sign to preconfirmed. That's a Fast Swap.${fire}`, + `Just swapped and it was preconfirmed in ${elapsedSec}s. This is what preconfirmations feel like.${fire}`, + ] + const mid = [ + `I just made a Fast Swap — preconfirmed in ${elapsedSec}s before the block even landed.${fire}`, + `Swapped and preconfirmed in ${elapsedSec}s. No more watching spinners.${fire}`, + `${elapsedSec}s to preconfirmed. Fast Swaps hit different.${fire}`, + ] + const slow = [ + `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Still faster than waiting for a block.${fire}`, + `Preconfirmed in ${elapsedSec}s on a Fast Swap. Knew my tokens were secured before L1 confirmed.${fire}`, + ] + + const variants = secs < 1 ? fast : secs < 4 ? mid : slow + const text = variants[Math.floor(Math.random() * variants.length)] + const tweet = `${text}\n\n@Fast_Protocol` window.open( `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, "_blank" From cc4003fd2b0fb561d748ee98c3ab57e9aa36d8a3 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:45:43 -0700 Subject: [PATCH 13/22] style: tweet variations mention Ethereum mainnet, mev surplus, tag @ethereum/@ethereumfdn Speed + mev rewards are the two highlights. Tweets now naturally weave in Ethereum mainnet context and mev surplus being returned to the swapper. Tags rotate between @ethereum and @ethereumfdn. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index dab27606..ffb88a58 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -405,25 +405,25 @@ export function SwapToast({ hash }: { hash: string }) { const secs = parseFloat(elapsedSec!) const fire = secs < 1 ? " \u{1F525}\u{1F525}\u{1F525}" : secs < 4 ? " \u{1F525}\u{1F525}" : " \u{1F525}" - // Variations grounded in the actual speed experience const fast = [ - `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Didn't even see a spinner.${fire}`, - `${elapsedSec}s from sign to preconfirmed. That's a Fast Swap.${fire}`, - `Just swapped and it was preconfirmed in ${elapsedSec}s. This is what preconfirmations feel like.${fire}`, + `I just made a Fast Swap on Ethereum mainnet — preconfirmed in ${elapsedSec}s. Didn't even see a spinner.${fire}\n\n@Fast_Protocol @ethereum`, + `${elapsedSec}s from sign to preconfirmed on Ethereum. That's a Fast Swap — mev surplus returned to me instead of extracted.${fire}\n\n@Fast_Protocol`, + `Just swapped on Ethereum mainnet and it was preconfirmed in ${elapsedSec}s. This is what preconfirmations feel like.${fire}\n\n@Fast_Protocol @ethereumfdn`, + `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Sub-second on L1, mev rewards back to the swapper.${fire}\n\n@Fast_Protocol @ethereum`, ] const mid = [ - `I just made a Fast Swap — preconfirmed in ${elapsedSec}s before the block even landed.${fire}`, - `Swapped and preconfirmed in ${elapsedSec}s. No more watching spinners.${fire}`, - `${elapsedSec}s to preconfirmed. Fast Swaps hit different.${fire}`, + `I just made a Fast Swap on Ethereum — preconfirmed in ${elapsedSec}s before the block even landed.${fire}\n\n@Fast_Protocol @ethereum`, + `Swapped on Ethereum mainnet and preconfirmed in ${elapsedSec}s. No more watching spinners, mev surplus returned.${fire}\n\n@Fast_Protocol`, + `${elapsedSec}s to preconfirmed on Ethereum L1. Fast Swaps give you the speed and return the mev.${fire}\n\n@Fast_Protocol @ethereumfdn`, ] const slow = [ - `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Still faster than waiting for a block.${fire}`, - `Preconfirmed in ${elapsedSec}s on a Fast Swap. Knew my tokens were secured before L1 confirmed.${fire}`, + `I just made a Fast Swap on Ethereum — preconfirmed in ${elapsedSec}s. Knew my tokens were secured before L1 confirmed, mev surplus returned.${fire}\n\n@Fast_Protocol @ethereum`, + `Preconfirmed in ${elapsedSec}s on Ethereum mainnet. Still faster than waiting for a block, and I keep the mev.${fire}\n\n@Fast_Protocol @ethereumfdn`, ] const variants = secs < 1 ? fast : secs < 4 ? mid : slow const text = variants[Math.floor(Math.random() * variants.length)] - const tweet = `${text}\n\n@Fast_Protocol` + const tweet = text window.open( `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, "_blank" From 176a94deeee4e7dcde56d5fd2d44468cdebe0c1c Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:52:38 -0700 Subject: [PATCH 14/22] style: remove mev surplus references from tweets, speed+reliability for slow tier Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index ffb88a58..d76d0ce2 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -407,18 +407,17 @@ export function SwapToast({ hash }: { hash: string }) { const fast = [ `I just made a Fast Swap on Ethereum mainnet — preconfirmed in ${elapsedSec}s. Didn't even see a spinner.${fire}\n\n@Fast_Protocol @ethereum`, - `${elapsedSec}s from sign to preconfirmed on Ethereum. That's a Fast Swap — mev surplus returned to me instead of extracted.${fire}\n\n@Fast_Protocol`, + `${elapsedSec}s from sign to preconfirmed on Ethereum. That's a Fast Swap.${fire}\n\n@Fast_Protocol @ethereumfdn`, `Just swapped on Ethereum mainnet and it was preconfirmed in ${elapsedSec}s. This is what preconfirmations feel like.${fire}\n\n@Fast_Protocol @ethereumfdn`, - `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Sub-second on L1, mev rewards back to the swapper.${fire}\n\n@Fast_Protocol @ethereum`, + `I just made a Fast Swap — preconfirmed in ${elapsedSec}s. Sub-second on Ethereum L1.${fire}\n\n@Fast_Protocol @ethereum`, ] const mid = [ `I just made a Fast Swap on Ethereum — preconfirmed in ${elapsedSec}s before the block even landed.${fire}\n\n@Fast_Protocol @ethereum`, - `Swapped on Ethereum mainnet and preconfirmed in ${elapsedSec}s. No more watching spinners, mev surplus returned.${fire}\n\n@Fast_Protocol`, - `${elapsedSec}s to preconfirmed on Ethereum L1. Fast Swaps give you the speed and return the mev.${fire}\n\n@Fast_Protocol @ethereumfdn`, + `Swapped on Ethereum mainnet and preconfirmed in ${elapsedSec}s. No more watching spinners.${fire}\n\n@Fast_Protocol @ethereumfdn`, + `${elapsedSec}s to preconfirmed on Ethereum L1. Fast Swaps hit different.${fire}\n\n@Fast_Protocol @ethereum`, ] const slow = [ - `I just made a Fast Swap on Ethereum — preconfirmed in ${elapsedSec}s. Knew my tokens were secured before L1 confirmed, mev surplus returned.${fire}\n\n@Fast_Protocol @ethereum`, - `Preconfirmed in ${elapsedSec}s on Ethereum mainnet. Still faster than waiting for a block, and I keep the mev.${fire}\n\n@Fast_Protocol @ethereumfdn`, + `I just made a Fast Swap on Ethereum — preconfirmed in ${elapsedSec}s. Fast and reliable, secured before L1 confirmed.${fire}\n\n@Fast_Protocol @ethereum`, ] const variants = secs < 1 ? fast : secs < 4 ? mid : slow From 259a49915c27dd8c88f8543eff60a5f1d0a66b52 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 19:54:22 -0700 Subject: [PATCH 15/22] fix: hide speed in tweets >10s, pass URL separately for OG card rendering Twitter requires a URL in the tweet to render OG cards. Using the separate &url= param in the intent so the link is present but the tweet text stays clean. Over 10s tweets omit the speed number. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index d76d0ce2..485920ae 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -416,15 +416,18 @@ export function SwapToast({ hash }: { hash: string }) { `Swapped on Ethereum mainnet and preconfirmed in ${elapsedSec}s. No more watching spinners.${fire}\n\n@Fast_Protocol @ethereumfdn`, `${elapsedSec}s to preconfirmed on Ethereum L1. Fast Swaps hit different.${fire}\n\n@Fast_Protocol @ethereum`, ] - const slow = [ + const slow = secs <= 10 ? [ `I just made a Fast Swap on Ethereum — preconfirmed in ${elapsedSec}s. Fast and reliable, secured before L1 confirmed.${fire}\n\n@Fast_Protocol @ethereum`, + ] : [ + `I just made a Fast Swap on Ethereum — preconfirmed and secured before L1 confirmed.${fire}\n\n@Fast_Protocol @ethereum`, ] const variants = secs < 1 ? fast : secs < 4 ? mid : slow const text = variants[Math.floor(Math.random() * variants.length)] - const tweet = text + // Twitter needs a URL in the tweet to render the OG card + const shareUrl = `${window.location.origin}/share/preconfirm?time=${elapsedSec}` window.open( - `https://twitter.com/intent/tweet?text=${encodeURIComponent(tweet)}`, + `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(shareUrl)}`, "_blank" ) }} From 7fc9e0335c5990c1fff87ef9f2c9c71bd4bffe38 Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 20:08:29 -0700 Subject: [PATCH 16/22] debug: add timing instrumentation to ERC-20 swap pipeline Console logs to measure exactly where the 10s goes: - [Permit Path] FastRPC /fastswap response time (ms) - [Toast] Dead time between placeholder creation and real hash arrival - [TxConfirmation] Time from polling start to preconfirmed detection Open browser console during a swap to see the breakdown. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/use-swap-confirmation.ts | 6 +++++- src/hooks/use-wait-for-tx-confirmation.ts | 5 +++++ src/stores/swapToastStore.ts | 10 ++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index defd58d4..b2e7efc2 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -253,7 +253,8 @@ export function useSwapConfirmation({ slippage: (parseFloat(slippage || "0.5") || 0.5).toFixed(1), } - // Call FastRPC directly — CORS allows it, skip Vercel serverless proxy (~100-300ms saved) + // Call FastRPC directly — CORS allows it, skip Vercel serverless proxy + const t0 = performance.now() const resp = await fetch(`${FASTSWAP_API_BASE}/fastswap`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -261,6 +262,9 @@ export function useSwapConfirmation({ }) const result = await resp.json() + const t1 = performance.now() + console.log(`[Permit Path] FastRPC /fastswap responded in ${(t1 - t0).toFixed(0)}ms`, { status: resp.status, txHash: result?.txHash }) + if (!resp.ok || !result?.txHash) { releaseNonce(nonce) const rawError = result?.error || "FastSwap API error" diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 7c147b4d..93f242fb 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -154,10 +154,13 @@ export function useWaitForTxConfirmation({ setIsConfirmed(false) setError(null) + const pollingStartedAt = performance.now() + /** Fire onPreConfirmed once, from whichever source wins the race. */ const firePreConfirmed = () => { if (preConfirmedFiredRef.current) return preConfirmedFiredRef.current = true + console.log(`[TxConfirmation] Preconfirmed detected in ${(performance.now() - pollingStartedAt).toFixed(0)}ms after polling started`) const result: TxConfirmationResult = mode === "receipt" ? { source: "db" } : { source: "db", status: { success: true, hash } } try { @@ -170,6 +173,8 @@ export function useWaitForTxConfirmation({ } const poll = async () => { + const pollStartTime = performance.now() + console.log(`[TxConfirmation] Polling started for ${hash.slice(0, 10)}...`) try { const timeoutMs = await getTxConfirmationTimeoutMs() const startTime = Date.now() diff --git a/src/stores/swapToastStore.ts b/src/stores/swapToastStore.ts index 532682bc..3b085311 100644 --- a/src/stores/swapToastStore.ts +++ b/src/stores/swapToastStore.ts @@ -175,8 +175,14 @@ export const useSwapToastStore = create((set, get) => ({ toasts: s.toasts.filter((t) => t.hash !== hash), })), - updateToastHash: (placeholderHash, realHash) => + updateToastHash: (placeholderHash, realHash) => { + const toast = get().toasts.find((t) => t.hash === placeholderHash) + if (toast) { + const elapsed = Date.now() - toast.createdAt + console.log(`[Toast] Hash updated after ${elapsed}ms dead time (placeholder → ${realHash.slice(0, 10)}...)`) + } set((s) => ({ toasts: s.toasts.map((t) => (t.hash === placeholderHash ? { ...t, hash: realHash } : t)), - })), + })) + }, })) From b516c1befc9575e63fae2fe5a10833670027cc4c Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 20:36:45 -0700 Subject: [PATCH 17/22] perf: add mevcommit_getTransactionCommitments as fastest polling source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mctransactions DB lags ~15s behind the actual preconfirmation. Now polls FastRPC's mevcommit_getTransactionCommitments JSON-RPC in parallel — the node knows about commitments instantly since it received them directly from the provider. Three sources now race in parallel: 1. mevcommit_getTransactionCommitments (fastest — node has it) 2. eth_getTransactionReceipt via FastRPC 3. mctransactions DB (slowest, kept as fallback) Note: ideal solution is eth_sendRawTransactionSync which returns the commitment in the same call, but requires backend changes to the /fastswap relayer endpoint for the Permit path. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/use-wait-for-tx-confirmation.ts | 12 +++-- src/lib/fast-rpc-status.ts | 58 +++++++++++++++++++++++ 2 files changed, 67 insertions(+), 3 deletions(-) create mode 100644 src/lib/fast-rpc-status.ts diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 93f242fb..7698c24c 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -4,6 +4,7 @@ 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" @@ -191,8 +192,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), ]) @@ -221,8 +226,9 @@ export function useWaitForTxConfirmation({ return } - // Either source signals preconfirmed → fire and move to phase 2 + // Any source signals preconfirmed → fire and move to phase 2 if ( + commitStatus === "preconfirmed" || mcStatus === "preconfirmed" || mcStatus === "confirmed" || (rpcResult && rpcResult.receipt.status === "success") diff --git a/src/lib/fast-rpc-status.ts b/src/lib/fast-rpc-status.ts new file mode 100644 index 00000000..98992f00 --- /dev/null +++ b/src/lib/fast-rpc-status.ts @@ -0,0 +1,58 @@ +/** + * 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() + + // If result is a non-empty array, the tx has commitments → preconfirmed + if (data?.result && Array.isArray(data.result) && data.result.length > 0) { + return "preconfirmed" + } + + // Non-array truthy result also indicates commitments exist + if (data?.result && !data?.error) { + return "preconfirmed" + } + + return null + } catch { + clearTimeout(timeoutId) + return null + } +} From 4076ab2974f1e47b82318b58412ae5225743d3ca Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 21:24:46 -0700 Subject: [PATCH 18/22] fix: remove bare http() fallback that resolves to eth.merkle.io wagmi's http() with no URL resolves to the chain's default public RPC (eth.merkle.io for mainnet) which has no CORS headers, causing ~40+ console errors per session. Alchemy + Ankr + 1rpc are sufficient fallbacks. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/wagmi.ts | 4 ---- 1 file changed, 4 deletions(-) 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 = [ From 3ed78465b645c004a9d877383d03b105feb83b0b Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 21:27:12 -0700 Subject: [PATCH 19/22] debug: log mevcommit_getTransactionCommitments responses during polling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Need to see what the endpoint returns for Permit path txs — the 0.6s ETH path worked but the ERC-20 Permit path still took 9.2s. The node may not track commitments for relayer-submitted txs the same way. Also includes the eth.merkle.io fix from previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/lib/fast-rpc-status.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/fast-rpc-status.ts b/src/lib/fast-rpc-status.ts index 98992f00..eb86497f 100644 --- a/src/lib/fast-rpc-status.ts +++ b/src/lib/fast-rpc-status.ts @@ -40,13 +40,18 @@ export async function fetchCommitmentStatus( const data = await response.json() + // Log first few polls to debug what the endpoint returns + console.log(`[CommitmentPoll] ${txHash.slice(0, 10)}...`, JSON.stringify(data).slice(0, 200)) + // If result is a non-empty array, the tx has commitments → preconfirmed if (data?.result && Array.isArray(data.result) && data.result.length > 0) { + console.log(`[CommitmentPoll] FOUND commitments for ${txHash.slice(0, 10)}...`) return "preconfirmed" } // Non-array truthy result also indicates commitments exist if (data?.result && !data?.error) { + console.log(`[CommitmentPoll] FOUND result for ${txHash.slice(0, 10)}...`) return "preconfirmed" } From 620c240185ccdd0e085d2fa4f9598ba958c7e3fa Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 21:50:15 -0700 Subject: [PATCH 20/22] chore: remove debug instrumentation console.logs before PR Strip all timing/polling debug logs added during development: [Permit Path], [Toast], [TxConfirmation], [CommitmentPoll] Co-Authored-By: Claude Opus 4.6 (1M context) --- src/hooks/use-swap-confirmation.ts | 4 ---- src/hooks/use-wait-for-tx-confirmation.ts | 5 ----- src/lib/fast-rpc-status.ts | 5 ----- src/stores/swapToastStore.ts | 5 ----- 4 files changed, 19 deletions(-) diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index b2e7efc2..3ad1380a 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -254,7 +254,6 @@ export function useSwapConfirmation({ } // Call FastRPC directly — CORS allows it, skip Vercel serverless proxy - const t0 = performance.now() const resp = await fetch(`${FASTSWAP_API_BASE}/fastswap`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -262,9 +261,6 @@ export function useSwapConfirmation({ }) const result = await resp.json() - const t1 = performance.now() - console.log(`[Permit Path] FastRPC /fastswap responded in ${(t1 - t0).toFixed(0)}ms`, { status: resp.status, txHash: result?.txHash }) - if (!resp.ok || !result?.txHash) { releaseNonce(nonce) const rawError = result?.error || "FastSwap API error" diff --git a/src/hooks/use-wait-for-tx-confirmation.ts b/src/hooks/use-wait-for-tx-confirmation.ts index 7698c24c..747c3b6d 100644 --- a/src/hooks/use-wait-for-tx-confirmation.ts +++ b/src/hooks/use-wait-for-tx-confirmation.ts @@ -155,13 +155,10 @@ export function useWaitForTxConfirmation({ setIsConfirmed(false) setError(null) - const pollingStartedAt = performance.now() - /** Fire onPreConfirmed once, from whichever source wins the race. */ const firePreConfirmed = () => { if (preConfirmedFiredRef.current) return preConfirmedFiredRef.current = true - console.log(`[TxConfirmation] Preconfirmed detected in ${(performance.now() - pollingStartedAt).toFixed(0)}ms after polling started`) const result: TxConfirmationResult = mode === "receipt" ? { source: "db" } : { source: "db", status: { success: true, hash } } try { @@ -174,8 +171,6 @@ export function useWaitForTxConfirmation({ } const poll = async () => { - const pollStartTime = performance.now() - console.log(`[TxConfirmation] Polling started for ${hash.slice(0, 10)}...`) try { const timeoutMs = await getTxConfirmationTimeoutMs() const startTime = Date.now() diff --git a/src/lib/fast-rpc-status.ts b/src/lib/fast-rpc-status.ts index eb86497f..98992f00 100644 --- a/src/lib/fast-rpc-status.ts +++ b/src/lib/fast-rpc-status.ts @@ -40,18 +40,13 @@ export async function fetchCommitmentStatus( const data = await response.json() - // Log first few polls to debug what the endpoint returns - console.log(`[CommitmentPoll] ${txHash.slice(0, 10)}...`, JSON.stringify(data).slice(0, 200)) - // If result is a non-empty array, the tx has commitments → preconfirmed if (data?.result && Array.isArray(data.result) && data.result.length > 0) { - console.log(`[CommitmentPoll] FOUND commitments for ${txHash.slice(0, 10)}...`) return "preconfirmed" } // Non-array truthy result also indicates commitments exist if (data?.result && !data?.error) { - console.log(`[CommitmentPoll] FOUND result for ${txHash.slice(0, 10)}...`) return "preconfirmed" } diff --git a/src/stores/swapToastStore.ts b/src/stores/swapToastStore.ts index 3b085311..4b9f2e1c 100644 --- a/src/stores/swapToastStore.ts +++ b/src/stores/swapToastStore.ts @@ -176,11 +176,6 @@ export const useSwapToastStore = create((set, get) => ({ })), updateToastHash: (placeholderHash, realHash) => { - const toast = get().toasts.find((t) => t.hash === placeholderHash) - if (toast) { - const elapsed = Date.now() - toast.createdAt - console.log(`[Toast] Hash updated after ${elapsed}ms dead time (placeholder → ${realHash.slice(0, 10)}...)`) - } set((s) => ({ toasts: s.toasts.map((t) => (t.hash === placeholderHash ? { ...t, hash: realHash } : t)), })) From ab777f0a558070a25a4dceb6bd88b4a7131c1d7d Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 22:00:08 -0700 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20address=20code=20audit=20findings?= =?UTF-8?q?=20=E2=80=94=20input=20sanitization,=20false=20positive,=20dead?= =?UTF-8?q?=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critical fixes from code audit: - Sanitize `time` query param in OG image + share page (parseFloat + range check) to prevent metadata injection via crafted URLs - Remove false positive fallback in fetchCommitmentStatus — only trust array results with actual commitment objects, not any truthy value - Remove dead isSuccess/isConfirming state from useSwapConfirmation — confirmation is now owned by SwapToast, these were never set after removing the duplicate polling hook Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/og/preconfirm/route.tsx | 3 ++- src/app/share/preconfirm/page.tsx | 3 ++- src/hooks/use-swap-confirmation.ts | 22 ++-------------------- src/lib/fast-rpc-status.ts | 7 +------ 4 files changed, 7 insertions(+), 28 deletions(-) diff --git a/src/app/api/og/preconfirm/route.tsx b/src/app/api/og/preconfirm/route.tsx index 18b72eea..61473c62 100644 --- a/src/app/api/og/preconfirm/route.tsx +++ b/src/app/api/og/preconfirm/route.tsx @@ -9,7 +9,8 @@ export const runtime = "edge" */ export async function GET(request: NextRequest) { const { searchParams } = request.nextUrl - const time = searchParams.get("time") || "0.4" + const raw = parseFloat(searchParams.get("time") || "0.4") + const time = !isNaN(raw) && raw >= 0 && raw <= 999 ? raw.toFixed(1) : "0.4" return new ImageResponse(
{ const params = await searchParams - const time = params.time || "0.4" + 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}` diff --git a/src/hooks/use-swap-confirmation.ts b/src/hooks/use-swap-confirmation.ts index 3ad1380a..dcdf2c39 100644 --- a/src/hooks/use-swap-confirmation.ts +++ b/src/hooks/use-swap-confirmation.ts @@ -1,6 +1,6 @@ "use client" -import { useState, useCallback, useEffect } from "react" +import { useState, useCallback } from "react" import { useAccount, usePublicClient, @@ -58,30 +58,15 @@ export function useSwapConfirmation({ 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) - // Note: Confirmation polling is handled by SwapToast (single source of truth). - // Removed duplicate useWaitForTxConfirmation here to halve API calls per swap. - - // 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) }, []) @@ -89,7 +74,6 @@ export function useSwapConfirmation({ const handleSwapError = useCallback((err: unknown) => { setIsSigning(false) setIsSubmitting(false) - setIsConfirming(false) setError(err instanceof Error ? err : new Error(String(err))) }, []) @@ -277,8 +261,6 @@ export function useSwapConfirmation({ confirmSwap, isSigning, isSubmitting, - isConfirming, - isSuccess, hash, error, reset, diff --git a/src/lib/fast-rpc-status.ts b/src/lib/fast-rpc-status.ts index 98992f00..8867d2da 100644 --- a/src/lib/fast-rpc-status.ts +++ b/src/lib/fast-rpc-status.ts @@ -40,16 +40,11 @@ export async function fetchCommitmentStatus( const data = await response.json() - // If result is a non-empty array, the tx has commitments → preconfirmed + // Only trust array results with actual commitment objects if (data?.result && Array.isArray(data.result) && data.result.length > 0) { return "preconfirmed" } - // Non-array truthy result also indicates commitments exist - if (data?.result && !data?.error) { - return "preconfirmed" - } - return null } catch { clearTimeout(timeoutId) From 73bee60ba2833f2bda5023b00ca0d10f1dece46d Mon Sep 17 00:00:00 2001 From: Murat Akdeniz Date: Tue, 24 Mar 2026 22:08:59 -0700 Subject: [PATCH 22/22] style: run prettier on all changed files Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/swap/SwapToast.tsx | 93 ++++++++++++++++++++-------- src/hooks/use-swap-confirmation.ts | 12 +--- src/lib/fast-rpc-status.ts | 5 +- src/lib/fast-tx-status.ts | 5 +- src/lib/preconfirm-sound.ts | 6 +- src/lib/transaction-receipt-utils.ts | 5 +- src/stores/swapToastStore.ts | 4 +- 7 files changed, 87 insertions(+), 43 deletions(-) diff --git a/src/components/swap/SwapToast.tsx b/src/components/swap/SwapToast.tsx index 485920ae..1d242afb 100644 --- a/src/components/swap/SwapToast.tsx +++ b/src/components/swap/SwapToast.tsx @@ -132,7 +132,10 @@ export function SwapToast({ hash }: { hash: string }) {
) } @@ -212,7 +222,10 @@ export function SwapToast({ hash }: { hash: string }) { {/* ── Dismiss: bare X, no bubble ── */}