From a27fd10571c93afcb9a7a8a21eb11ca1448d5476 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Mon, 22 Jun 2026 22:42:01 -0600 Subject: [PATCH 1/9] fix(backend): atomically record merge counters in Lua script Consolidate deduplication, global counters, recent list, and daily counter into a single Lua eval so a KV failure cannot leave partial state. --- lib/kv.ts | 56 ++++++++++++++++++++++++++----------------------------- 1 file changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/kv.ts b/lib/kv.ts index b1b3e84..3df5836 100644 --- a/lib/kv.ts +++ b/lib/kv.ts @@ -123,10 +123,15 @@ export async function checkNamespacedRateLimit( } } -// Lua script: atomically check deduplication, increment account counter, and add XLM stroops. -// KEYS[1] = processed set, KEYS[2] = count key, KEYS[3] = xlm key -// ARGV[1] = txHash, ARGV[2] = xlmStroops -// Returns 1 when the txHash is new (counters updated), 0 when duplicate. +// Lua script: atomically deduplicate, update all counters, and push the recent-list entry. +// Running everything in one script guarantees that a KV failure cannot leave the +// global count incremented but the recent list or daily counter missing. +// +// KEYS[1] = processed set, KEYS[2] = count key, KEYS[3] = xlm key, +// KEYS[4] = recent list key, KEYS[5] = daily counter key +// ARGV[1] = txHash, ARGV[2] = xlmStroops, ARGV[3] = MergeRecord JSON, +// ARGV[4] = recentMax, ARGV[5] = daily TTL in seconds +// Returns 1 when the txHash is new (all state updated), 0 when duplicate. const RECORD_SCRIPT = ` if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 1 then return 0 @@ -134,6 +139,10 @@ const RECORD_SCRIPT = ` redis.call('SADD', KEYS[1], ARGV[1]) redis.call('INCR', KEYS[2]) redis.call('INCRBY', KEYS[3], ARGV[2]) + redis.call('LPUSH', KEYS[4], ARGV[3]) + redis.call('LTRIM', KEYS[4], 0, tonumber(ARGV[4]) - 1) + redis.call('INCR', KEYS[5]) + redis.call('EXPIRE', KEYS[5], tonumber(ARGV[5])) return 1 `; @@ -147,35 +156,22 @@ export async function recordMerge( txHash: string, xlmStroops: string ): Promise { + const timestamp = new Date().toISOString(); + const date = timestamp.slice(0, 10); + const record: MergeRecord = { txHash, xlmStroops, timestamp, network }; + const result = await kv.eval( RECORD_SCRIPT, - [PROCESSED_KEY(network), COUNT_KEY[network], XLM_KEY[network]], - [txHash, xlmStroops] + [ + PROCESSED_KEY(network), + COUNT_KEY[network], + XLM_KEY[network], + RECENT_KEY(network), + DAILY_KEY(network, date), + ], + [txHash, xlmStroops, JSON.stringify(record), String(RECENT_MAX), String(400 * 86_400)] ); - const isNew = result === 1; - if (isNew) { - const timestamp = new Date().toISOString(); - pushRecentMerge(network, txHash, xlmStroops, timestamp).catch((err) => - console.error(`Failed to push recent merge for tx ${txHash}:`, err) - ); - } - return isNew; -} - -async function pushRecentMerge( - network: Network, - txHash: string, - xlmStroops: string, - timestamp: string -): Promise { - const record: MergeRecord = { txHash, xlmStroops, timestamp, network }; - const date = timestamp.slice(0, 10); - const pipeline = kv.pipeline(); - pipeline.lpush(RECENT_KEY(network), JSON.stringify(record)); - pipeline.ltrim(RECENT_KEY(network), 0, RECENT_MAX - 1); - pipeline.incr(DAILY_KEY(network, date)); - pipeline.expire(DAILY_KEY(network, date), 400 * 86_400); - await pipeline.exec(); + return result === 1; } export async function getRecentMerges(network: Network, limit = 20): Promise { From 74186b966a35254427498c770190ca7093603747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Mon, 22 Jun 2026 22:42:03 -0600 Subject: [PATCH 2/9] fix(ui): stabilize calendar heatmap colors and hover Use inline styles for stellar color levels since Tailwind JIT does not generate bg-stellar opacity modifiers. Fix hover panel layout jump and preserve selection when moving the cursor to the detail panel. --- .../marketing/stats/CalendarHeatmap.tsx | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) diff --git a/components/marketing/stats/CalendarHeatmap.tsx b/components/marketing/stats/CalendarHeatmap.tsx index e527eff..2f1b470 100644 --- a/components/marketing/stats/CalendarHeatmap.tsx +++ b/components/marketing/stats/CalendarHeatmap.tsx @@ -14,12 +14,14 @@ function countToLevel(count: number): 0 | 1 | 2 | 3 | 4 { return 4; } -const LEVEL_CLASS: Record<0 | 1 | 2 | 3 | 4, string> = { - 0: "bg-white/[0.04]", - 1: "bg-stellar/[0.22]", - 2: "bg-stellar/40", - 3: "bg-stellar/65", - 4: "bg-stellar", +// "stellar" is a hand-written CSS class in globals.css, not a Tailwind color token, +// so bg-stellar/x modifier classes are never generated by JIT. Use inline styles instead. +const LEVEL_BG: Record<0 | 1 | 2 | 3 | 4, string> = { + 0: "hsl(0 0% 100% / 0.06)", + 1: "hsl(var(--stellar) / 0.50)", + 2: "hsl(var(--stellar) / 0.68)", + 3: "hsl(var(--stellar) / 0.84)", + 4: "hsl(var(--stellar))", }; interface Cell { @@ -33,14 +35,12 @@ function buildGrid(daily: DailyActivity[]): Cell[][] { const byDate = new Map(daily.map((d) => [d.date, d.count])); - // Start from Monday of the week 364 days ago const today = new Date(); today.setUTCHours(0, 0, 0, 0); const todayMs = today.getTime(); const start = new Date(todayMs - 363 * 86_400_000); - // Roll back to Monday (dow 0=Sun,1=Mon...6=Sat → we want Mon=0) - const startDow = (start.getUTCDay() + 6) % 7; // 0=Mon + const startDow = (start.getUTCDay() + 6) % 7; start.setUTCDate(start.getUTCDate() - startDow); const weeks: Cell[][] = []; @@ -115,7 +115,12 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) { })(); return ( -
+ // onMouseLeave lives here, not on individual cells, so the cursor can travel + // from an active cell to the detail panel without clearing the hover state. +
setHovered(null)} + >
Activity - last 12 months @@ -134,7 +139,11 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
Less {([0, 1, 2, 3, 4] as const).map((l) => ( -
+
))} More
@@ -178,7 +187,8 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) { {Array.from({ length: 7 }).map((_, di) => (
))}
@@ -188,21 +198,31 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) { {week.map((cell, di) => (
- setHovered({ - date: cell.date, - count: cell.count, - closes: closesByDate.get(cell.date) ?? [], - col: wi, - row: di, - }) - } - onMouseLeave={() => setHovered(null)} + className="h-[12px] w-[12px] cursor-default rounded-[2px] transition-[outline] duration-100" + style={{ + backgroundColor: LEVEL_BG[cell.level], + // outline instead of ring — doesn't affect layout and works + // without Tailwind color tokens + outline: + hovered?.date === cell.date + ? "1px solid hsl(var(--stellar) / 0.80)" + : undefined, + outlineOffset: "1px", + }} + onMouseEnter={() => { + // Only update for days with actual closes so that the cursor + // can travel from an active cell through empty cells to the + // detail panel without the TX chips disappearing. + if (cell.count > 0) { + setHovered({ + date: cell.date, + count: cell.count, + closes: closesByDate.get(cell.date) ?? [], + col: wi, + row: di, + }); + } + }} /> ))}
@@ -212,11 +232,11 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
- {/* tooltip / detail panel */} - {hovered && ( -
+ {/* Detail panel at fixed height — never changes size so the component never jumps. */} +
+ {hovered ? (
-
+
{new Date(hovered.date + "T00:00:00Z").toLocaleDateString("en-US", { month: "long", @@ -252,8 +272,10 @@ export default function CalendarHeatmap({ feed }: { feed: FeedData | null }) {
)}
-
- )} + ) : ( +

Hover a day to inspect

+ )} +
{DAYS.length === 0 && null}
From 06ad05186b41699fb368e037c56e447cfa2c09cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Mon, 22 Jun 2026 22:42:03 -0600 Subject: [PATCH 3/9] feat(ui): add playground mode reset after completion Add a secondary action to return to mode selection and label the rerun button with the active scenario name. --- components/playground/PlaygroundControls.tsx | 23 ++++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/components/playground/PlaygroundControls.tsx b/components/playground/PlaygroundControls.tsx index d7fac86..cc13092 100644 --- a/components/playground/PlaygroundControls.tsx +++ b/components/playground/PlaygroundControls.tsx @@ -280,6 +280,7 @@ export default function PlaygroundControls({ const customConfig = usePlaygroundStore((s) => s.customConfig); const setSelectedMode = usePlaygroundStore((s) => s.setSelectedMode); const setCustomConfig = usePlaygroundStore((s) => s.setCustomConfig); + const reset = usePlaygroundStore((s) => s.reset); const [credentials, setCredentials] = useState(null); const [loadingCredentials, setLoadingCredentials] = useState(false); @@ -469,13 +470,21 @@ export default function PlaygroundControls({ )}{" "} - check the activity log for the receipts.
- +
+ + +
)} From 801680bc1ca9e4fbc1e5c214ac146f8172c3f3a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Mon, 22 Jun 2026 22:42:04 -0600 Subject: [PATCH 4/9] feat(ui): add hero account analyzer on landing page Add an inline address input that routes directly to mainnet analyze and refresh the hero label styling. --- app/(marketing)/page.tsx | 18 ++++-- components/marketing/HeroAccountInput.tsx | 75 +++++++++++++++++++++++ 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 components/marketing/HeroAccountInput.tsx diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx index 03561ac..d8daa17 100644 --- a/app/(marketing)/page.tsx +++ b/app/(marketing)/page.tsx @@ -4,6 +4,7 @@ import { ArrowRight, Check, RefreshCw, Building2, Layers, Eye, ScanLine } from " import Faq from "@/components/marketing/Faq"; import Reveal from "@/components/marketing/Reveal"; import HeroConsole from "@/components/marketing/HeroConsole"; +import HeroAccountInput from "@/components/marketing/HeroAccountInput"; export const metadata: Metadata = { title: "LumenWipe: Recover the XLM locked in your Stellar account", @@ -102,9 +103,13 @@ export default function LandingPage() { <> {/* ============================ HERO ============================ */}
-
- - Stellar Account Demolisher + {/* Label — typographic, not a status pill */} +
+ + + Stellar Account Demolisher + +

@@ -116,7 +121,12 @@ export default function LandingPage() { leftovers, and merge out. Signed entirely in your browser.

-
+ {/* Inline account analyzer */} +
+ +
+ +
Open the app diff --git a/components/marketing/HeroAccountInput.tsx b/components/marketing/HeroAccountInput.tsx new file mode 100644 index 0000000..d2879cb --- /dev/null +++ b/components/marketing/HeroAccountInput.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; +import { Search, AlertCircle } from "lucide-react"; + +function isValidStellarAddress(addr: string): boolean { + return /^G[A-Z2-7]{55}$/.test(addr); +} + +export default function HeroAccountInput() { + const router = useRouter(); + const [address, setAddress] = useState(""); + const [error, setError] = useState(null); + + function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmed = address.trim(); + if (!trimmed) { + setError("Enter a Stellar account address."); + return; + } + if (!isValidStellarAddress(trimmed)) { + setError("Not a valid Stellar address — must start with G and be 56 characters."); + return; + } + setError(null); + router.push(`/mainnet/analyze?source=${encodeURIComponent(trimmed)}`); + } + + return ( +
+
+
+ + { + setAddress(e.target.value); + if (error) setError(null); + }} + placeholder="Paste a Stellar account address — G…" + className="flex-1 bg-transparent py-2 pr-2 mkt-mono text-[0.8rem] text-white placeholder:text-white/25 outline-none min-w-0" + spellCheck={false} + autoComplete="off" + autoCorrect="off" + autoCapitalize="none" + /> + +
+
+ + {error ? ( +

+ + {error} +

+ ) : ( +

+ Analyzes on mainnet · read-only until you sign · no account needed +

+ )} +
+ ); +} From 0eecd32643975c48317178ba3d61968760e2cf4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Salazar?= Date: Mon, 22 Jun 2026 22:42:04 -0600 Subject: [PATCH 5/9] feat(ui): add content hub with videos and blog posts Introduce /content as the marketing hub for videos and articles, update nav and footer links, and normalize page title punctuation. --- app/(marketing)/content/page.tsx | 152 +++++++++++++++++++++++ app/(marketing)/faq/page.tsx | 2 +- app/(marketing)/security/page.tsx | 2 +- app/globals.css | 2 +- components/marketing/MarketingFooter.tsx | 2 +- components/marketing/MarketingNav.tsx | 9 +- 6 files changed, 163 insertions(+), 6 deletions(-) create mode 100644 app/(marketing)/content/page.tsx diff --git a/app/(marketing)/content/page.tsx b/app/(marketing)/content/page.tsx new file mode 100644 index 0000000..1a9102e --- /dev/null +++ b/app/(marketing)/content/page.tsx @@ -0,0 +1,152 @@ +import type { Metadata } from "next"; +import { getAllPostMetas } from "@/lib/blog"; +import PostCard from "@/components/blog/PostCard"; +import { Play } from "lucide-react"; + +const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? ""; + +export const metadata: Metadata = { + title: "Content", + description: + "Videos, guides, and technical deep dives on LumenWipe — closing Stellar accounts, recovering XLM reserves, and DeFi position unwinding.", + openGraph: { + title: "Content | LumenWipe", + description: "Videos and articles on Stellar account management and reserve recovery.", + url: `${APP_URL}/content`, + type: "website", + }, + alternates: { + canonical: `${APP_URL}/content`, + }, +}; + +type Video = { + id: string; + title: string; + description: string; + lang: "EN" | "ES"; + duration: string; +}; + +const VIDEOS: Video[] = [ + { + id: "vD3xhPpqah8", + title: "What is LumenWipe?", + description: + "A 3-minute overview of what LumenWipe does, why Stellar reserves get locked, and how the non-custodial close flow works.", + lang: "EN", + duration: "3 min", + }, + { + id: "nVS2zI9mRzw", + title: "Full walkthrough: testnet, playground & mainnet", + description: + "An 11-minute demo covering the full demolition flow — from testnet dry run to a live mainnet account close.", + lang: "EN", + duration: "11 min", + }, + { + id: "eRkcNd9996c", + title: "Demo completo: testnet, playground y mainnet", + description: + "Demo de 14 minutos que cubre el flujo completo: testnet, playground y cierre real en mainnet.", + lang: "ES", + duration: "14 min", + }, +]; + +function Eyebrow({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +function LangBadge({ lang }: { lang: "EN" | "ES" }) { + return ( + + {lang} + + ); +} + +function DurationBadge({ duration }: { duration: string }) { + return ( + + + {duration} + + ); +} + +export default function ContentPage() { + const posts = getAllPostMetas(); + + return ( +
+ {/* Header */} +
+ Content +

+ Videos & articles +

+

+ Walkthroughs, deep dives, and guides on closing Stellar accounts and recovering locked + reserves. +

+
+ + {/* Videos */} +
+

Videos

+
+ {VIDEOS.map((v) => ( +
+ {/* 16:9 embed */} +
+