Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0f69e5c
feat: preconfirmation notification UX overhaul
techgangboss Mar 25, 2026
5616aa6
fix: fallback gas limit when estimation reverts on ETH path
techgangboss Mar 25, 2026
d3c5758
fix: show clear error instead of raw viem dump on gas estimation revert
techgangboss Mar 25, 2026
b8fa9cd
style: unified toast card that evolves through swap lifecycle
techgangboss Mar 25, 2026
67bd992
feat: synthesized whoosh + chime sound on preconfirmation
techgangboss Mar 25, 2026
79bcbd5
style: switch preconfirm sound to level-up arpeggio (C5-E5-G5)
techgangboss Mar 25, 2026
acd711c
style: prominent speed badge, flush share strip, bare dismiss X
techgangboss Mar 25, 2026
c49f336
perf: call FastRPC directly for ERC-20 swaps, skip Vercel proxy
techgangboss Mar 25, 2026
e36f633
fix: max balance click using raw BigInt balance instead of parsed float
techgangboss Mar 25, 2026
05e4e5f
feat: dynamic OG card for preconfirmation share on X
techgangboss Mar 25, 2026
ec0a477
fix: fastprotocol.io not .xyz, remove pair from OG card, say swap pre…
techgangboss Mar 25, 2026
a8a8b0c
style: clean OG card, natural tweet variations, fire emoji at end only
techgangboss Mar 25, 2026
cc4003f
style: tweet variations mention Ethereum mainnet, mev surplus, tag @e…
techgangboss Mar 25, 2026
176a94d
style: remove mev surplus references from tweets, speed+reliability f…
techgangboss Mar 25, 2026
259a499
fix: hide speed in tweets >10s, pass URL separately for OG card rende…
techgangboss Mar 25, 2026
7fc9e03
debug: add timing instrumentation to ERC-20 swap pipeline
techgangboss Mar 25, 2026
b516c1b
perf: add mevcommit_getTransactionCommitments as fastest polling source
techgangboss Mar 25, 2026
4076ab2
fix: remove bare http() fallback that resolves to eth.merkle.io
techgangboss Mar 25, 2026
3ed7846
debug: log mevcommit_getTransactionCommitments responses during polling
techgangboss Mar 25, 2026
620c240
chore: remove debug instrumentation console.logs before PR
techgangboss Mar 25, 2026
ab777f0
fix: address code audit findings — input sanitization, false positive…
techgangboss Mar 25, 2026
73bee60
style: run prettier on all changed files
techgangboss Mar 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/app/api/fast-tx-status/[hash]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
116 changes: 116 additions & 0 deletions src/app/api/og/preconfirm/route.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { ImageResponse } from "next/og"
import { NextRequest } from "next/server"

export const runtime = "edge"

/**
* Dynamic OG image for preconfirmation share cards.
* Usage: /api/og/preconfirm?time=0.4
*/
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl
const raw = parseFloat(searchParams.get("time") || "0.4")
const time = !isNaN(raw) && raw >= 0 && raw <= 999 ? raw.toFixed(1) : "0.4"

return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
background: "linear-gradient(145deg, #050a10 0%, #0a1628 40%, #0d1f3c 100%)",
fontFamily: "system-ui, sans-serif",
position: "relative",
overflow: "hidden",
}}
>
{/* Subtle radial glow */}
<div
style={{
position: "absolute",
width: "600px",
height: "600px",
borderRadius: "50%",
background: "radial-gradient(circle, rgba(59, 130, 246, 0.1) 0%, transparent 70%)",
top: "50%",
left: "50%",
transform: "translate(-50%, -50%)",
}}
/>

{/* Top label */}
<div
style={{
fontSize: "18px",
fontWeight: 600,
color: "rgba(255,255,255,0.35)",
letterSpacing: "0.12em",
textTransform: "uppercase" as const,
marginBottom: "20px",
}}
>
Swap Preconfirmed
</div>

{/* Speed number */}
<div
style={{
display: "flex",
alignItems: "baseline",
gap: "12px",
}}
>
<div
style={{
fontSize: "148px",
fontWeight: 800,
color: "#fff",
lineHeight: 1,
letterSpacing: "-0.04em",
}}
>
{time}
</div>
<div
style={{
fontSize: "48px",
fontWeight: 600,
color: "rgba(255,255,255,0.35)",
lineHeight: 1,
}}
>
sec
</div>
</div>

{/* Thin accent line */}
<div
style={{
width: "80px",
height: "2px",
background: "linear-gradient(90deg, transparent, rgba(96, 165, 250, 0.5), transparent)",
marginTop: "28px",
marginBottom: "28px",
}}
/>

{/* Branding */}
<div
style={{
fontSize: "20px",
fontWeight: 600,
color: "rgba(255,255,255,0.25)",
}}
>
fastprotocol.io
</div>
</div>,
{
width: 1200,
height: 630,
}
)
}
37 changes: 37 additions & 0 deletions src/app/share/preconfirm/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { Metadata } from "next"
import { redirect } from "next/navigation"

interface Props {
searchParams: Promise<{ time?: string }>
}

export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
const params = await searchParams
const raw = parseFloat(params.time || "0.4")
const time = !isNaN(raw) && raw >= 0 && raw <= 999 ? raw.toFixed(1) : "0.4"
const title = `Preconfirmed in ${time}s — Fast Swaps`
const description = `Swap preconfirmed in ${time} seconds on Fast Protocol`
const ogUrl = `/api/og/preconfirm?time=${time}`

return {
title,
description,
openGraph: {
title,
description,
images: [{ url: ogUrl, width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
title,
description,
images: [ogUrl],
site: "@Fast_Protocol",
},
}
}

/** Redirect visitors to the main swap page — this route only exists for OG meta. */
export default async function SharePreconfirmPage() {
redirect("/")
}
24 changes: 23 additions & 1 deletion src/components/swap/BuyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -76,6 +79,7 @@ const BuyCardComponent: React.FC<BuyCardProps> = ({
* prevents unnecessary re-renders of the entire swap interface.
*/
const [hasImageError, setHasImageError] = useState(false)
const isBalanceFlashing = useBalanceFlash(toBalanceValue, isConnected)

useEffect(() => {
setHasImageError(false)
Expand All @@ -91,12 +95,30 @@ const BuyCardComponent: React.FC<BuyCardProps> = ({
setAmount(value)
}

const handleBalanceClick = () => {
if (!toToken || toBalanceValue <= 0 || !isConnected) return
setEditingSide("buy")
setAmount(toBalanceValue.toString())
}

return (
<div className="rounded-[14px] sm:rounded-[16px] bg-[#161b22] border border-white/5 px-3 py-2.5 sm:px-5 sm:py-4">
{/* Header Section */}
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-gray-500 font-medium uppercase tracking-wide">Buy</span>
{toToken && <span className="text-xs text-gray-500">Balance: {formattedToBalance}</span>}
{toToken && (
<button
type="button"
onClick={handleBalanceClick}
disabled={!isConnected || toBalanceValue <= 0}
className={cn(
"text-xs transition-colors duration-700 hover:text-white disabled:hover:text-gray-500 disabled:cursor-default cursor-pointer",
isBalanceFlashing ? "text-green-400" : "text-gray-500"
)}
>
Balance: {formattedToBalance}
</button>
)}
</div>

<div className="flex items-center justify-between gap-3">
Expand Down
138 changes: 138 additions & 0 deletions src/components/swap/PreconfirmCelebration.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<AnimatePresence>
{show && (
<div className="absolute inset-0 pointer-events-none overflow-visible z-10">
{/* Central flash */}
<motion.div
className="absolute inset-0 rounded-full"
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: [0, 0.8, 0], scale: [0.5, 1.5, 2] }}
transition={{ duration: 0.5, ease: "easeOut" }}
style={{
background: "radial-gradient(circle, rgba(96, 165, 250, 0.6) 0%, transparent 70%)",
}}
/>

{/* 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 (
<motion.div
key={p.id}
className="absolute rounded-full"
style={{
width: p.size,
height: p.size,
backgroundColor: p.color,
left: "50%",
top: "50%",
marginLeft: -p.size / 2,
marginTop: -p.size / 2,
boxShadow: `0 0 ${p.size * 2}px ${p.color}`,
}}
initial={{ x: 0, y: 0, opacity: 1, scale: 1 }}
animate={{
x,
y,
opacity: [1, 1, 0],
scale: [1, 1.2, 0.3],
}}
transition={{
duration: p.duration,
delay: p.delay,
ease: "easeOut",
}}
/>
)
})}

{/* Ring pulse */}
<motion.div
className="absolute inset-[-8px] rounded-full border-2 border-blue-400/50"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: [0, 0.6, 0], scale: [0.8, 1.6, 2] }}
transition={{ duration: 0.7, delay: 0.05, ease: "easeOut" }}
/>
</div>
)}
</AnimatePresence>
)
}

/**
* 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 (
<motion.div
className="absolute inset-[-4px] rounded-full"
initial={{ opacity: 0 }}
animate={{ opacity: [0.3, 0.6, 0.3] }}
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
style={{
background: "radial-gradient(circle, rgba(96, 165, 250, 0.4) 0%, transparent 70%)",
}}
/>
)
}
Loading
Loading