From cf3dee405ac7fcfb0be05b5963db386f7acc19c2 Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:38:38 +0700 Subject: [PATCH 01/43] Update package.json --- ui/collin/package.json | 38 ++++++++++++++++---------------------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/ui/collin/package.json b/ui/collin/package.json index 563e873c..40648699 100644 --- a/ui/collin/package.json +++ b/ui/collin/package.json @@ -1,32 +1,26 @@ { - "name": "collin", + "name": "sc-bridge-terminal-web", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "eslint .", - "preview": "vite preview" + "dev": "concurrently -k \"node server/index.js\" \"vite\"", + "build": "vite build", + "preview": "vite preview", + "start": "node server/index.js --serve" }, "dependencies": { - "@tanstack/react-virtual": "^3.13.18", - "idb": "^8.0.3", - "react": "^19.2.0", - "react-dom": "^19.2.0" + "express": "^4.19.2", + "lightweight-charts": "^4.2.1" }, "devDependencies": { - "@eslint/js": "^9.39.1", - "@types/node": "^24.10.1", - "@types/react": "^19.2.5", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "eslint": "^9.39.1", - "eslint-plugin-react-hooks": "^7.0.1", - "eslint-plugin-react-refresh": "^0.4.24", - "globals": "^16.5.0", - "typescript": "~5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.2.4" + "@types/node": "^22.10.10", + "@types/express": "^4.17.21", + "@vitejs/plugin-react": "^4.3.4", + "concurrently": "^9.1.2", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "typescript": "^5.6.3", + "vite": "^6.0.7" } } From 6c5e4ca96553ca8292c93ccf737d7fead677694e Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:39:29 +0700 Subject: [PATCH 02/43] Update tsconfig.json --- ui/collin/tsconfig.json | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/ui/collin/tsconfig.json b/ui/collin/tsconfig.json index 1ffef600..ba727ce4 100644 --- a/ui/collin/tsconfig.json +++ b/ui/collin/tsconfig.json @@ -1,7 +1,19 @@ { - "files": [], - "references": [ - { "path": "./tsconfig.app.json" }, - { "path": "./tsconfig.node.json" } - ] + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "types": ["node"] + }, + "include": ["src", "vite.config.ts"] } From 89b59e39baa2686b53e21233edcfbeb3f6f12214 Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:40:34 +0700 Subject: [PATCH 03/43] Update vite.config.ts --- ui/collin/vite.config.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/collin/vite.config.ts b/ui/collin/vite.config.ts index 057ad9e5..f567ad95 100644 --- a/ui/collin/vite.config.ts +++ b/ui/collin/vite.config.ts @@ -1,13 +1,11 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -// https://vite.dev/config/ export default defineConfig({ plugins: [react()], server: { proxy: { - '/v1': 'http://127.0.0.1:9333', - '/healthz': 'http://127.0.0.1:9333', - }, - }, + '/api': 'http://127.0.0.1:8787' + } + } }); From f27b4fc5f1efd277abab0f6f2ac6988a662eaf5b Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:41:02 +0700 Subject: [PATCH 04/43] Update index.html --- ui/collin/index.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/collin/index.html b/ui/collin/index.html index 145adbbb..3846c919 100644 --- a/ui/collin/index.html +++ b/ui/collin/index.html @@ -2,9 +2,8 @@ - - collin + SC-BRIDGE TERMINAL
From 510e5f46cca7ae322cebaa0e86bd71fa57a2f44e Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:42:31 +0700 Subject: [PATCH 05/43] Create index.js --- ui/collin/server/index.js | 122 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 ui/collin/server/index.js diff --git a/ui/collin/server/index.js b/ui/collin/server/index.js new file mode 100644 index 00000000..4a8aa23d --- /dev/null +++ b/ui/collin/server/index.js @@ -0,0 +1,122 @@ +import express from "express"; +import path from "path"; +import { fileURLToPath } from "url"; + +const app = express(); +app.use(express.json()); + +const PORT = Number(process.env.PORT || 8787); + +function isServeMode() { + return process.argv.includes("--serve"); +} + +function withTimeout(ms) { + const controller = new AbortController(); + const id = setTimeout(() => controller.abort(), ms); + return { controller, id }; +} + +async function fetchJson(url, opts = {}) { + const { controller, id } = withTimeout(12_000); + try { + const res = await fetch(url, { + ...opts, + signal: controller.signal, + headers: { + "accept": "application/json", + ...(opts.headers || {}) + } + }); + if (!res.ok) { + const t = await res.text().catch(() => ""); + return { ok: false, status: res.status, error: t.slice(0, 500) || res.statusText }; + } + const data = await res.json(); + return { ok: true, status: res.status, data }; + } catch (e) { + return { ok: false, status: 0, error: String(e?.message || e) }; + } finally { + clearTimeout(id); + } +} + +app.get("/api/health", (req, res) => { + res.json({ ok: true, ts: Date.now() }); +}); + +/** + * CoinGecko Simple Price (lightweight) + * /api/coingecko/simple_price?ids=bitcoin,ethereum,solana,trac-network&vs=usd + */ +app.get("/api/coingecko/simple_price", async (req, res) => { + const ids = String(req.query.ids || "").trim(); + const vs = String(req.query.vs || "usd").trim(); + if (!ids) return res.status(400).json({ ok: false, error: "Missing ids" }); + + const url = `https://api.coingecko.com/api/v3/simple/price?ids=${encodeURIComponent(ids)}&vs_currencies=${encodeURIComponent(vs)}&include_24hr_change=true`; + const out = await fetchJson(url); + if (!out.ok) return res.status(502).json({ ok: false, source: "coingecko", error: out.error, status: out.status }); + res.json({ ok: true, data: out.data }); +}); + +/** + * CoinGecko Market Chart + * /api/coingecko/market_chart?id=bitcoin&vs=usd&days=7 + */ +app.get("/api/coingecko/market_chart", async (req, res) => { + const id = String(req.query.id || "").trim(); + const vs = String(req.query.vs || "usd").trim(); + const days = String(req.query.days || "7").trim(); + if (!id) return res.status(400).json({ ok: false, error: "Missing id" }); + + const url = `https://api.coingecko.com/api/v3/coins/${encodeURIComponent(id)}/market_chart?vs_currency=${encodeURIComponent(vs)}&days=${encodeURIComponent(days)}`; + const out = await fetchJson(url); + if (!out.ok) return res.status(502).json({ ok: false, source: "coingecko", error: out.error, status: out.status }); + res.json({ ok: true, data: out.data }); +}); + +/** + * DexScreener Token Pairs + * /api/dex/token_pairs?chain=solana&address= + */ +app.get("/api/dex/token_pairs", async (req, res) => { + const chain = String(req.query.chain || "").trim(); + const address = String(req.query.address || "").trim(); + if (!chain) return res.status(400).json({ ok: false, error: "Missing chain" }); + if (!address) return res.status(400).json({ ok: false, error: "Missing address" }); + + const url = `https://api.dexscreener.com/token-pairs/v1/${encodeURIComponent(chain)}/${encodeURIComponent(address)}`; + const out = await fetchJson(url); + if (!out.ok) return res.status(502).json({ ok: false, source: "dexscreener", error: out.error, status: out.status }); + res.json({ ok: true, data: out.data }); +}); + +/** + * DexScreener Pair Snapshot + * /api/dex/pair?chain=solana&pair= + */ +app.get("/api/dex/pair", async (req, res) => { + const chain = String(req.query.chain || "").trim(); + const pair = String(req.query.pair || "").trim(); + if (!chain) return res.status(400).json({ ok: false, error: "Missing chain" }); + if (!pair) return res.status(400).json({ ok: false, error: "Missing pair" }); + + const url = `https://api.dexscreener.com/latest/dex/pairs/${encodeURIComponent(chain)}/${encodeURIComponent(pair)}`; + const out = await fetchJson(url); + if (!out.ok) return res.status(502).json({ ok: false, source: "dexscreener", error: out.error, status: out.status }); + res.json({ ok: true, data: out.data }); +}); + +if (isServeMode()) { + const __filename = fileURLToPath(import.meta.url); + const __dirname = path.dirname(__filename); + const dist = path.join(__dirname, "..", "dist"); + app.use(express.static(dist)); + app.get("*", (req, res) => res.sendFile(path.join(dist, "index.html"))); +} + +app.listen(PORT, "0.0.0.0", () => { + console.log(`[SC-BRIDGE] API listening on http://127.0.0.1:${PORT}`); + if (isServeMode()) console.log(`[SC-BRIDGE] Serving UI from /dist`); +}); From 31721441b21c5c4bb3220d46f6a42a5424b37c71 Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:43:28 +0700 Subject: [PATCH 06/43] Create tokenCatalog.ts --- ui/collin/src/lib/tokenCatalog.ts | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 ui/collin/src/lib/tokenCatalog.ts diff --git a/ui/collin/src/lib/tokenCatalog.ts b/ui/collin/src/lib/tokenCatalog.ts new file mode 100644 index 00000000..552b5d55 --- /dev/null +++ b/ui/collin/src/lib/tokenCatalog.ts @@ -0,0 +1,43 @@ +export type TokenKey = 'TNK' | 'BTC' | 'ETH' | 'SOL'; + +export type TokenDef = { + key: TokenKey; + name: string; + symbol: string; + coingeckoId: string; + isTracTask?: boolean; +}; + +export const TOKENS: TokenDef[] = [ + { + key: 'TNK', + name: 'Trac Network', + symbol: 'TNK', + coingeckoId: 'trac-network', + isTracTask: true + }, + { + key: 'BTC', + name: 'Bitcoin', + symbol: 'BTC', + coingeckoId: 'bitcoin' + }, + { + key: 'ETH', + name: 'Ethereum', + symbol: 'ETH', + coingeckoId: 'ethereum' + }, + { + key: 'SOL', + name: 'Solana', + symbol: 'SOL', + coingeckoId: 'solana' + } +]; + +export function tokenByKey(key: TokenKey): TokenDef { + const t = TOKENS.find(x => x.key === key); + if (!t) return TOKENS[0]; + return t; +} From bdd7135c268736fed8ff3d6da0451c2a2ab3aff6 Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:44:05 +0700 Subject: [PATCH 07/43] Create format.ts --- ui/collin/src/lib/format.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 ui/collin/src/lib/format.ts diff --git a/ui/collin/src/lib/format.ts b/ui/collin/src/lib/format.ts new file mode 100644 index 00000000..8f94cf7c --- /dev/null +++ b/ui/collin/src/lib/format.ts @@ -0,0 +1,17 @@ +export function fmtMoney(n: number | null | undefined): string { + if (n === null || n === undefined || Number.isNaN(n)) return '--'; + if (n >= 1000) return `$${n.toLocaleString(undefined, { maximumFractionDigits: 0 })}`; + if (n >= 1) return `$${n.toLocaleString(undefined, { maximumFractionDigits: 4 })}`; + return `$${n.toLocaleString(undefined, { maximumFractionDigits: 8 })}`; +} + +export function fmtPct(n: number | null | undefined): string { + if (n === null || n === undefined || Number.isNaN(n)) return '--'; + const sign = n > 0 ? '+' : ''; + return `${sign}${n.toFixed(2)}%`; +} + +export function clampStr(s: string, max = 20): string { + if (s.length <= max) return s; + return `${s.slice(0, max - 3)}...`; +} From 8a8a22685e3ede2dc1109468200de2e305c38c6b Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:45:09 +0700 Subject: [PATCH 08/43] Create PriceTicker.tsx --- ui/collin/src/components/PriceTicker.tsx | 45 ++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 ui/collin/src/components/PriceTicker.tsx diff --git a/ui/collin/src/components/PriceTicker.tsx b/ui/collin/src/components/PriceTicker.tsx new file mode 100644 index 00000000..a039cac3 --- /dev/null +++ b/ui/collin/src/components/PriceTicker.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import type { TokenDef } from '../lib/tokenCatalog'; +import { fmtMoney, fmtPct } from '../lib/format'; + +export type PricesMap = Record; + +export default function PriceTicker(props: { + active: TokenDef; + prices: PricesMap | null; +}) { + const p = props.prices?.[props.active.coingeckoId]; + const usd = p?.usd ?? null; + const ch = p?.usd_24h_change ?? null; + + return ( +
+
+
+ + Market Telemetry +
+ {props.active.isTracTask ? TRAC TASK : LIVE} +
+ +
+
{props.active.symbol}
+
{fmtMoney(usd ?? undefined)}
+
+ +
+
+
24h
+
= 0 ? 'v good' : 'v bad') : 'v'}> + {fmtPct(ch ?? undefined)} +
+
+ +
+
Source
+
CoinGecko
+
+
+
+ ); +} From 5c58341b408355b97469543dd1bdf709dc8675d7 Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:45:48 +0700 Subject: [PATCH 09/43] Create MarketChart.tsx --- ui/collin/src/components/MarketChart.tsx | 110 +++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 ui/collin/src/components/MarketChart.tsx diff --git a/ui/collin/src/components/MarketChart.tsx b/ui/collin/src/components/MarketChart.tsx new file mode 100644 index 00000000..e64bd35d --- /dev/null +++ b/ui/collin/src/components/MarketChart.tsx @@ -0,0 +1,110 @@ +import React, { useEffect, useMemo, useRef } from 'react'; +import { createChart, type IChartApi, type ISeriesApi, type LineData } from 'lightweight-charts'; +import type { TokenDef } from '../lib/tokenCatalog'; + +type MarketChartResp = { + prices?: [number, number][]; +}; + +function toLineData(prices: [number, number][]): LineData[] { + return prices + .filter(p => Array.isArray(p) && typeof p[0] === 'number' && typeof p[1] === 'number') + .map(p => ({ time: Math.floor(p[0] / 1000), value: p[1] })); +} + +export default function MarketChart(props: { + token: TokenDef; + days: number; + data: MarketChartResp | null; + loading: boolean; + error: string | null; +}) { + const wrapRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef | null>(null); + + const line = useMemo(() => { + const prices = props.data?.prices ?? []; + return toLineData(prices); + }, [props.data]); + + useEffect(() => { + if (!wrapRef.current) return; + + const el = wrapRef.current; + const chart = createChart(el, { + width: el.clientWidth, + height: 300, + layout: { + background: { color: 'transparent' }, + textColor: '#9AA6C6' + }, + grid: { + vertLines: { color: 'rgba(27,42,82,0.35)' }, + horzLines: { color: 'rgba(27,42,82,0.35)' } + }, + rightPriceScale: { + borderColor: 'rgba(27,42,82,0.6)' + }, + timeScale: { + borderColor: 'rgba(27,42,82,0.6)', + timeVisible: true + }, + crosshair: { + vertLine: { color: 'rgba(56,189,248,0.35)' }, + horzLine: { color: 'rgba(56,189,248,0.25)' } + } + }); + + const series = chart.addLineSeries({ + lineWidth: 2 + }); + + chartRef.current = chart; + seriesRef.current = series; + + const ro = new ResizeObserver(() => { + chart.applyOptions({ width: el.clientWidth }); + }); + ro.observe(el); + + return () => { + ro.disconnect(); + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + }; + }, []); + + useEffect(() => { + if (!seriesRef.current) return; + seriesRef.current.setData(line); + chartRef.current?.timeScale().fitContent(); + }, [line, props.token.key, props.days]); + + return ( +
+
+
+ + CoinGecko Chart +
+
+ {props.token.name} • {props.days}D • USD +
+
+ +
+ +
+ {props.loading ? ( + Loading chart… + ) : props.error ? ( + Error: {props.error} + ) : ( + Source: CoinGecko market_chart + )} +
+
+ ); +} From 1d8b6c5c61e41b8a97bdb6b2665f3c430f4c491c Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:46:29 +0700 Subject: [PATCH 10/43] Create DexScanner.tsx --- ui/collin/src/components/DexScanner.tsx | 259 ++++++++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 ui/collin/src/components/DexScanner.tsx diff --git a/ui/collin/src/components/DexScanner.tsx b/ui/collin/src/components/DexScanner.tsx new file mode 100644 index 00000000..6124b9cb --- /dev/null +++ b/ui/collin/src/components/DexScanner.tsx @@ -0,0 +1,259 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { createChart, type IChartApi, type ISeriesApi, type LineData } from 'lightweight-charts'; +import { clampStr, fmtMoney } from '../lib/format'; + +type DexPair = { + chainId?: string; + dexId?: string; + url?: string; + pairAddress?: string; + baseToken?: { symbol?: string; name?: string; address?: string }; + quoteToken?: { symbol?: string; name?: string; address?: string }; + priceUsd?: string; + liquidity?: { usd?: number }; + volume?: { h24?: number; h6?: number; h1?: number; m5?: number }; + txns?: { h24?: { buys?: number; sells?: number }; h1?: { buys?: number; sells?: number }; m5?: { buys?: number; sells?: number } }; +}; + +type DexTokenPairsResp = DexPair[]; // DexScreener token-pairs returns array + +function safeNum(s: unknown): number | null { + const n = typeof s === 'string' ? Number(s) : typeof s === 'number' ? s : NaN; + if (!Number.isFinite(n)) return null; + return n; +} + +export default function DexScanner() { + const [chain, setChain] = useState('solana'); + const [address, setAddress] = useState(''); + const [pairs, setPairs] = useState([]); + const [selectedPair, setSelectedPair] = useState(null); + const [status, setStatus] = useState('Idle'); + + // live chart from polling selected pair priceUsd + const wrapRef = useRef(null); + const chartRef = useRef(null); + const seriesRef = useRef | null>(null); + const bufRef = useRef([]); + + const pairLabel = useMemo(() => { + if (!selectedPair) return '--'; + const b = selectedPair.baseToken?.symbol || 'BASE'; + const q = selectedPair.quoteToken?.symbol || 'QUOTE'; + return `${b}/${q}`; + }, [selectedPair]); + + useEffect(() => { + if (!wrapRef.current) return; + const el = wrapRef.current; + + const chart = createChart(el, { + width: el.clientWidth, + height: 240, + layout: { background: { color: 'transparent' }, textColor: '#9AA6C6' }, + grid: { + vertLines: { color: 'rgba(27,42,82,0.35)' }, + horzLines: { color: 'rgba(27,42,82,0.35)' } + }, + rightPriceScale: { borderColor: 'rgba(27,42,82,0.6)' }, + timeScale: { borderColor: 'rgba(27,42,82,0.6)', timeVisible: true }, + crosshair: { + vertLine: { color: 'rgba(24,242,164,0.25)' }, + horzLine: { color: 'rgba(24,242,164,0.18)' } + } + }); + + const series = chart.addLineSeries({ lineWidth: 2 }); + chartRef.current = chart; + seriesRef.current = series; + + const ro = new ResizeObserver(() => chart.applyOptions({ width: el.clientWidth })); + ro.observe(el); + + return () => { + ro.disconnect(); + chart.remove(); + chartRef.current = null; + seriesRef.current = null; + }; + }, []); + + async function scan() { + const a = address.trim(); + if (!a) return; + setStatus('Scanning DexScreener…'); + + try { + const r = await fetch(`/api/dex/token_pairs?chain=${encodeURIComponent(chain)}&address=${encodeURIComponent(a)}`); + const j = await r.json(); + if (!j.ok) throw new Error(j.error || 'Dex error'); + const list = (j.data || []) as DexTokenPairsResp; + setPairs(list); + setSelectedPair(list?.[0] || null); + setStatus(`Found ${list.length} pair(s)`); + } catch (e: any) { + setPairs([]); + setSelectedPair(null); + setStatus(`Error: ${String(e?.message || e)}`); + } + } + + useEffect(() => { + // reset buffer on pair change + bufRef.current = []; + seriesRef.current?.setData([]); + chartRef.current?.timeScale().fitContent(); + }, [selectedPair?.pairAddress]); + + useEffect(() => { + let t: any = null; + let stopped = false; + + async function tick() { + if (!selectedPair?.pairAddress) return; + try { + const r = await fetch( + `/api/dex/pair?chain=${encodeURIComponent(selectedPair.chainId || chain)}&pair=${encodeURIComponent(selectedPair.pairAddress)}` + ); + const j = await r.json(); + if (!j.ok) return; + + const pairsArr = j.data?.pairs || []; + const p0 = (Array.isArray(pairsArr) ? pairsArr[0] : null) as DexPair | null; + const price = safeNum(p0?.priceUsd); + + if (price !== null && seriesRef.current) { + const now = Math.floor(Date.now() / 1000); + const buf = bufRef.current; + buf.push({ time: now, value: price }); + + // keep last 320 points + if (buf.length > 320) buf.splice(0, buf.length - 320); + + seriesRef.current.setData(buf); + chartRef.current?.timeScale().fitContent(); + + // update snapshot info + setSelectedPair(prev => (prev ? { ...prev, ...p0 } : p0)); + } + } catch { + // ignore + } + } + + function loop() { + if (stopped) return; + tick().finally(() => { + t = setTimeout(loop, 3500); + }); + } + + loop(); + return () => { + stopped = true; + if (t) clearTimeout(t); + }; + }, [selectedPair?.pairAddress, chain]); + + return ( +
+
+
+ + DexScreener CA Scanner +
+
Paste CA/Mint → pairs → live price polling chart
+
+ +
+
+
Chain
+ +
+ +
+
CA / Mint
+ setAddress(e.target.value)} + /> +
+ + +
+ +
+
+
Pairs
+
+ {pairs.length === 0 ? ( +
No pairs loaded. Paste CA/Mint then scan.
+ ) : ( + pairs.slice(0, 20).map((p) => { + const label = `${p.baseToken?.symbol || 'BASE'}/${p.quoteToken?.symbol || 'QUOTE'}`; + const liq = p.liquidity?.usd ?? null; + const vol = p.volume?.h24 ?? null; + const active = selectedPair?.pairAddress === p.pairAddress; + return ( + + ); + }) + )} +
+
{status}
+
+ +
+
Live Chart • {pairLabel}
+
+
+
+
Price
+
{fmtMoney(safeNum(selectedPair?.priceUsd) ?? undefined)}
+
+
+
Liquidity
+
{fmtMoney(selectedPair?.liquidity?.usd ?? undefined)}
+
+
+
Vol 24h
+
{fmtMoney(selectedPair?.volume?.h24 ?? undefined)}
+
+
+
URL
+
{selectedPair?.url ? DexScreener : '--'}
+
+
+
+ Source: DexScreener token-pairs + pair snapshot polling +
+
+
+
+ ); +} From 74df96ac697e7a2e9ff6bc6f6f2f72b5565d0844 Mon Sep 17 00:00:00 2001 From: nusnuga Date: Thu, 19 Feb 2026 20:53:00 +0700 Subject: [PATCH 11/43] Delete ui/collin/src/App.tsx --- ui/collin/src/App.tsx | 10368 ---------------------------------------- 1 file changed, 10368 deletions(-) delete mode 100644 ui/collin/src/App.tsx diff --git a/ui/collin/src/App.tsx b/ui/collin/src/App.tsx deleted file mode 100644 index 5a15ea26..00000000 --- a/ui/collin/src/App.tsx +++ /dev/null @@ -1,10368 +0,0 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; -import { useVirtualizer } from '@tanstack/react-virtual'; -import './app.css'; -import { - chatAdd, - chatClear, - chatListBefore, - chatListLatest, - COLLINS_ACTIVITY_RETENTION_MS, - COLLINS_SC_FEED_RETENTION_MS, - dbPruneRetention, - promptAdd, - promptListBefore, - promptListLatest, - scAdd, - scListBefore, - scListLatest, - setDbNamespace, -} from './lib/db'; - -type OracleSummary = { ok: boolean; ts: number | null; btc_usd: number | null; usdt_usd: number | null; btc_usdt: number | null }; - -const MAINNET_USDT_MINT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'; -const SOL_TX_FEE_BUFFER_LAMPORTS = 50_000; // best-effort guardrail for claim/refund/transfer tx fees -const LN_ROUTE_FEE_BUFFER_MIN_SATS = 50; -const LN_ROUTE_FEE_BUFFER_BPS = 10; // 0.10% -const LN_OPEN_TX_FEE_BUFFER_MIN_SATS = 1_000; -const LN_OPEN_TX_WEIGHT_BUFFER_VB = 600; // conservative wallet funding tx estimate for channel open -const LND_NEW_ANCHOR_RESERVE_SATS = 10_000; -const MS_PER_HOUR = 60 * 60 * 1000; -const SWAP_WATCH_RETENTION_MS = 2 * MS_PER_HOUR; - -type LnPeerSuggestion = { id: string; addr: string; uri: string; connected: boolean }; - -// Mainnet default channel peer (hub) used by Collin Channel Manager. -// This is intentionally opinionated to reduce "NO_ROUTE" incidents caused by isolated channel topology. -const ACINQ_NODE_ID = '03864ef025fde8fb587d989186ce6a4a186895ee44a926bfc370e2c366597a3f8f'; -const ACINQ_PEER_ADDR = '3.33.236.230:9735'; -const ACINQ_PEER_URI = `${ACINQ_NODE_ID}@${ACINQ_PEER_ADDR}`; - -function isConnectedPeerFlag(v: any): boolean { - if (v === undefined || v === null) return true; - if (v === true) return true; - if (v === false) return false; - if (typeof v === 'number') return Number.isFinite(v) && v > 0; - const s = String(v).trim().toLowerCase(); - if (!s) return true; - return s === '1' || s === 'true' || s === 'yes' || s === 'connected'; -} - -function parseNodeIdFromPeerUri(raw: any): string | null { - const peer = String(raw || '').trim(); - if (!peer) return null; - const node = peer.includes('@') ? peer.slice(0, peer.indexOf('@')) : peer; - const id = String(node || '').trim().toLowerCase(); - if (!/^[0-9a-f]{66}$/i.test(id)) return null; - return id; -} - -function collectLnPeerSuggestions(listPeersRaw: any): LnPeerSuggestion[] { - const out: LnPeerSuggestion[] = []; - const seen = new Set(); - const peers = Array.isArray(listPeersRaw?.peers) ? listPeersRaw.peers : []; - for (const p of peers) { - const id = String((p as any)?.id || (p as any)?.pub_key || '').trim().toLowerCase(); - if (!/^[0-9a-f]{66}$/i.test(id)) continue; - const connected = isConnectedPeerFlag((p as any)?.connected); - const addrsRaw = Array.isArray((p as any)?.netaddr) - ? (p as any).netaddr - : typeof (p as any)?.address === 'string' - ? [(p as any).address] - : []; - for (const a of addrsRaw) { - const addr = String(a || '').replace(/^\\+/, '').trim(); - if (!addr || !addr.includes(':')) continue; - const uri = `${id}@${addr}`; - if (seen.has(uri)) continue; - seen.add(uri); - out.push({ id, addr, uri, connected }); - } - } - out.sort((a, b) => { - if (a.connected !== b.connected) return a.connected ? -1 : 1; - return a.uri.localeCompare(b.uri); - }); - return out.slice(0, 20); -} - -function extractLnOpenTxHint(out: any): { txid: string; channelPoint: string; shortId: string } { - const txidCandidates = [ - out?.txid, - out?.funding_txid, - out?.fundingTxid, - out?.tx_hash, - out?.tx, - out?.transaction_id, - ]; - const pointCandidates = [ - out?.channel_point, - out?.channelPoint, - out?.channel_id, - out?.short_channel_id, - ]; - let txid = ''; - for (const c of txidCandidates) { - const s = String(c || '').trim(); - if (/^[0-9a-f]{64}$/i.test(s)) { - txid = s.toLowerCase(); - break; - } - // LND channel_point may contain ":". - const m = s.match(/^([0-9a-f]{64}):\d+$/i); - if (m) { - txid = String(m[1]).toLowerCase(); - break; - } - } - let channelPoint = ''; - for (const c of pointCandidates) { - const s = String(c || '').trim(); - if (!s) continue; - channelPoint = s; - if (!txid) { - const m = s.match(/^([0-9a-f]{64}):\d+$/i); - if (m) txid = String(m[1]).toLowerCase(); - } - break; - } - const shortId = String(out?.short_channel_id || '').trim(); - return { txid, channelPoint, shortId }; -} - -function isLnWalletLockedError(raw: any): boolean { - const s = String(raw || '').trim().toLowerCase(); - if (!s) return false; - return ( - s.includes('wallet locked') || - s.includes('wallet is locked') || - s.includes('wallet encrypted and locked') || - s.includes('unlock it to enable full rpc access') || - s.includes('unlock wallet') || - s.includes('must unlock wallet') - ); -} - -function parseToolSearchTokens(input: string): string[] { - const s = String(input || '').trim().toLowerCase(); - if (!s) return []; - const raw = s.split(/[^a-z0-9_]+/g).map((t) => t.trim()).filter(Boolean); - const out: string[] = []; - const seen = new Set(); - for (const t of raw) { - if (seen.has(t)) continue; - seen.add(t); - out.push(t); - } - return out; -} - -function toolSearchScore( - tool: { name: string; description?: string | null }, - rawQuery: string, - queryTokens: string[] -): number { - const name = String(tool?.name || '').toLowerCase(); - const desc = String(tool?.description || '').toLowerCase(); - const q = String(rawQuery || '').trim().toLowerCase(); - if (!q) return 0; - - let score = 0; - - if (name === q) score += 10_000; - else if (name.startsWith(q)) score += 6_000; - else if (name.includes(q)) score += 3_000; - else if (desc.includes(q)) score += 1_000; - - let matchedAnyToken = false; - for (const t of queryTokens) { - if (!t) continue; - if (name === t) { - score += 2_000; - matchedAnyToken = true; - continue; - } - if (name.startsWith(t)) { - score += 1_400; - matchedAnyToken = true; - continue; - } - if (name.includes(t)) { - score += 900; - matchedAnyToken = true; - continue; - } - if (desc.includes(t)) { - score += 300; - matchedAnyToken = true; - } - } - - // If there is a query and no name/description match at all, hide it. - if (score <= 0 && queryTokens.length > 0 && !matchedAnyToken) return -1; - return score; -} - -function deriveScEventDedupKey(evt: any): string { - if (!evt || typeof evt !== 'object') return ''; - const t = String((evt as any).type || '').trim(); - if (t !== 'sc_event') return ''; - const channel = String((evt as any).channel || '').trim(); - const kind = String((evt as any).kind || '').trim(); - const tradeId = String((evt as any).trade_id || '').trim(); - const msg = (evt as any).message && typeof (evt as any).message === 'object' ? (evt as any).message : null; - const signer = String(msg?.signer || '').trim().toLowerCase(); - const sig = String(msg?.sig || '').trim().toLowerCase(); - if (signer && sig) return `sig:${channel}:${kind}:${tradeId}:${signer}:${sig}`; - const seq = typeof (evt as any).seq === 'number' && Number.isFinite((evt as any).seq) ? Math.trunc((evt as any).seq) : 0; - if (seq > 0) return `seq:${seq}`; - if (!channel && !kind && !tradeId) return ''; - const ts = - typeof (evt as any).ts === 'number' && Number.isFinite((evt as any).ts) - ? Math.trunc((evt as any).ts) - : msg && typeof msg.ts === 'number' && Number.isFinite(msg.ts) - ? Math.trunc(msg.ts) - : 0; - return `evt:${channel}:${kind}:${tradeId}:${ts}`; -} - -function eventTsMs(evt: any): number { - if (!evt || typeof evt !== 'object') return 0; - const directTs = typeof (evt as any).ts === 'number' ? (evt as any).ts : null; - if (directTs !== null && Number.isFinite(directTs) && directTs > 0) return Math.trunc(directTs); - const startedAt = typeof (evt as any).started_at === 'number' ? (evt as any).started_at : null; - if (startedAt !== null && Number.isFinite(startedAt) && startedAt > 0) return Math.trunc(startedAt); - const msgTs = typeof (evt as any)?.message?.ts === 'number' ? (evt as any).message.ts : null; - if (msgTs !== null && Number.isFinite(msgTs) && msgTs > 0) return Math.trunc(msgTs); - return 0; -} - -function compareScEventsNewestFirst(a: any, b: any): number { - const at = eventTsMs(a); - const bt = eventTsMs(b); - if (bt !== at) return bt - at; - const adb = typeof a?.db_id === 'number' && Number.isFinite(a.db_id) ? Math.trunc(a.db_id) : -1; - const bdb = typeof b?.db_id === 'number' && Number.isFinite(b.db_id) ? Math.trunc(b.db_id) : -1; - if (bdb !== adb) return bdb - adb; - const asq = typeof a?.seq === 'number' && Number.isFinite(a.seq) ? Math.trunc(a.seq) : -1; - const bsq = typeof b?.seq === 'number' && Number.isFinite(b.seq) ? Math.trunc(b.seq) : -1; - return bsq - asq; -} - -function stableKeyStringify(value: any, depth = 0): string { - if (depth > 24) return '"[max-depth]"'; - if (value === null) return 'null'; - if (value === undefined) return '"[undefined]"'; - if (Array.isArray(value)) { - return `[${value.map((v) => stableKeyStringify(v, depth + 1)).join(',')}]`; - } - const t = typeof value; - if (t === 'string') return JSON.stringify(value); - if (t === 'number') return Number.isFinite(value) ? `n:${String(value)}` : '"[non-finite-number]"'; - if (t === 'boolean') return value ? 'true' : 'false'; - if (t === 'bigint') return `bi:${value.toString(10)}`; - if (t === 'object') { - const obj = value as Record; - const keys = Object.keys(obj).sort((a, b) => a.localeCompare(b)); - const parts: string[] = []; - for (const k of keys) { - const v = obj[k]; - if (v === undefined) continue; - parts.push(`${JSON.stringify(k)}:${stableKeyStringify(v, depth + 1)}`); - } - return `{${parts.join(',')}}`; - } - return JSON.stringify(String(value)); -} - -function listingBodyFingerprint(evt: any): string { - try { - const msg = evt && typeof evt === 'object' ? (evt as any).message : null; - const body = msg && typeof msg === 'object' && msg.body && typeof msg.body === 'object' ? msg.body : null; - if (!body) return ''; - return stableKeyStringify(body); - } catch (_e) { - return ''; - } -} - -function listingRepostKey(evt: any): string { - if (!evt || typeof evt !== 'object') return ''; - const kind = String((evt as any)?.kind || (evt as any)?.message?.kind || '').trim().toLowerCase(); - if (kind !== 'swap.rfq' && kind !== 'swap.svc_announce' && kind !== 'swap.quote') return ''; - const tradeId = String((evt as any)?.trade_id || (evt as any)?.message?.trade_id || '').trim().toLowerCase(); - if (!tradeId) return ''; - const channel = String((evt as any)?.channel || '').trim().toLowerCase(); - const signer = String((evt as any)?.message?.signer || (evt as any)?.from || '').trim().toLowerCase(); - const bodyFp = listingBodyFingerprint(evt); - if (bodyFp) return `${kind}|${channel}|${tradeId}|${signer}|${bodyFp}`; - return `${kind}|${channel}|${tradeId}|${signer}`; -} - -function dedupeListingRepostsLatest(events: any[]): any[] { - if (!Array.isArray(events) || events.length < 2) return Array.isArray(events) ? events : []; - const sorted = [...events].sort(compareScEventsNewestFirst); - const seen = new Set(); - const out: any[] = []; - for (const e of sorted) { - const key = listingRepostKey(e); - if (!key) { - out.push(e); - continue; - } - if (seen.has(key)) continue; - seen.add(key); - out.push(e); - } - return out; -} - -const SC_UI_KIND_ALLOWLIST = new Set([ - 'swap.rfq', - 'swap.svc_announce', - 'swap.quote', - 'swap.accept', - 'swap.swap_invite', - 'swap.trade_lock', - 'swap.ln_paid', - 'swap.sol_claimed', - 'swap.sol_refunded', - 'swap.cancel', -]); - -function shouldKeepScEventInUi(evt: any): boolean { - if (!evt || typeof evt !== 'object') return false; - const t = String((evt as any).type || '').trim(); - if (t !== 'sc_event') return true; - const kind = String((evt as any).kind || (evt as any)?.message?.kind || '').trim(); - if (!kind) return false; - return SC_UI_KIND_ALLOWLIST.has(kind); -} - -function isSwapTradeChannelName(raw: any): boolean { - const ch = String(raw || '').trim().toLowerCase(); - return ch.startsWith('swap:'); -} - -function isPendingLnChannelState(raw: any): boolean { - const s = String(raw || '').trim().toLowerCase(); - return s.includes('pending'); -} - -function normalizeChannels( - input: any, - opts: { max?: number; dropSwapTradeChannels?: boolean } = {} -): string[] { - const max = Number.isFinite(opts?.max as any) && Number(opts.max) > 0 ? Math.max(1, Math.trunc(Number(opts.max))) : 50; - const dropSwapTradeChannels = opts?.dropSwapTradeChannels !== false; - const out: string[] = []; - const seen = new Set(); - const list = Array.isArray(input) ? input : []; - for (const cRaw of list) { - const c = String(cRaw || '').trim(); - if (!c) continue; - if (dropSwapTradeChannels && isSwapTradeChannelName(c)) continue; - if (seen.has(c)) continue; - seen.add(c); - out.push(c); - if (out.length >= max) break; - } - return out; -} - -function App() { - const [activeTab, setActiveTab] = useState< - | 'overview' - | 'prompt' - | 'sell_usdt' - | 'sell_btc' - | 'invites' - | 'trade_actions' - | 'refunds' - | 'wallets' - | 'console' - | 'settings' - >('overview'); - - const [navOpen, setNavOpen] = useState(true); - - const [health, setHealth] = useState<{ ok: boolean; ts: number } | null>(null); - const [tools, setTools] = useState | null>(null); - - const [sessionId, setSessionId] = useState(() => { - try { - const v = String(window.localStorage.getItem('collin_prompt_session_id') || '').trim(); - if (v) return v; - } catch (_e) {} - const gen = - (globalThis.crypto && typeof (globalThis.crypto as any).randomUUID === 'function' - ? (globalThis.crypto as any).randomUUID() - : `sess-${Date.now()}-${Math.random().toString(16).slice(2)}`) || `sess-${Date.now()}`; - try { - window.localStorage.setItem('collin_prompt_session_id', gen); - } catch (_e) {} - return gen; - }); - const [autoApprove, setAutoApprove] = useState(() => { - try { - const raw = String(window.localStorage.getItem('collin_auto_approve') || '').trim(); - if (!raw) return true; // default ON on first run - return raw === '1'; - } catch (_e) { - return true; - } - }); - const [runMode, setRunMode] = useState<'tool' | 'llm'>('tool'); - - const [scConnected, setScConnected] = useState(false); - const [scConnecting, setScConnecting] = useState(false); - const [scStreamErr, setScStreamErr] = useState(null); - const [scChannels, setScChannels] = useState('0000intercomswapbtcusdt'); - const [scSwapWatchChannels, setScSwapWatchChannelsState] = useState([]); - const scSwapWatchFirstSeenAtRef = useRef>(new Map()); - const [scFilter, setScFilter] = useState<{ channel: string; kind: string }>({ channel: '', kind: '' }); - const [showExpiredInvites, setShowExpiredInvites] = useState(() => { - try { - return String(window.localStorage.getItem('collin_show_expired_invites') || '') === '1'; - } catch (_e) { - return false; - } - }); - const [showDismissedInvites, setShowDismissedInvites] = useState(() => { - try { - return String(window.localStorage.getItem('collin_show_dismissed_invites') || '') === '1'; - } catch (_e) { - return false; - } - }); - // Used for expiry-based UI (invites, etc.). Without this, useMemo() caching can prevent items - // from ever transitioning into "expired" if no new events arrive. - const [uiNowMs, setUiNowMs] = useState(() => Date.now()); - const [dismissedInviteTradeIds, setDismissedInviteTradeIds] = useState>(() => { - try { - const raw = String(window.localStorage.getItem('collin_dismissed_invites') || '').trim(); - if (!raw) return {}; - const obj: any = JSON.parse(raw); - if (!obj || typeof obj !== 'object') return {}; - const out: Record = {}; - for (const [k, v] of Object.entries(obj)) { - const key = String(k || '').trim(); - if (!key) continue; - const n = typeof v === 'number' ? v : typeof v === 'string' && /^[0-9]+$/.test(v.trim()) ? Number.parseInt(v.trim(), 10) : 0; - out[key] = Number.isFinite(n) && n > 0 ? n : Date.now(); - } - return out; - } catch (_e) { - return {}; - } - }); - - const [selected, setSelected] = useState(null); - - const [promptInput, setPromptInput] = useState(''); - const [promptChat, setPromptChat] = useState>([]); - const promptChatListRef = useRef(null); - const [promptChatFollowTail, setPromptChatFollowTail] = useState(true); - const [promptChatUnseen, setPromptChatUnseen] = useState(0); - const promptChatFollowTailRef = useRef(true); - const [toolFilter, setToolFilter] = useState(''); - const [toolName, setToolName] = useState(''); - const [toolArgsText, setToolArgsText] = useState('{\n \n}'); - const [toolInputMode, setToolInputMode] = useState<'form' | 'json'>('form'); - const [toolArgsObj, setToolArgsObj] = useState>({}); - const [toolArgsParseErr, setToolArgsParseErr] = useState(null); - - const [promptEvents, setPromptEvents] = useState([]); - const [scEvents, setScEvents] = useState([]); - const scEventsMax = 3000; - const scEventDedupTtlMs = 20 * 60 * 1000; - const scEventDedupMax = 20_000; - const scEventDedupPruneEveryMs = 5000; - const scEventDedupOverflowFactor = 1.2; - const scEventUiFlushMs = 120; - const scEventUiFlushBatch = 240; - const scEventDedupRef = useRef>(new Map()); - const scEventDedupNextPruneAtRef = useRef(0); - const scPendingUiEventsRef = useRef([]); - const scUiFlushTimerRef = useRef(null); - const promptEventsMax = 3000; - const promptChatMax = 1200; - - const [runBusy, setRunBusy] = useState(false); - const [runErr, setRunErr] = useState(null); - const [stackOpBusy, setStackOpBusy] = useState(false); - const [consoleEvents, setConsoleEvents] = useState([]); - const consoleEventsMax = 500; - const consoleListRef = useRef(null); - - const [preflight, setPreflight] = useState(null); - const [preflightBusy, setPreflightBusy] = useState(false); - const [tradeAutoWorkerEnabled, setTradeAutoWorkerEnabled] = useState(true); - const [tradeAutoTraceEnabled, setTradeAutoTraceEnabled] = useState(false); - const [envInfo, setEnvInfo] = useState(null); - const [envBusy, setEnvBusy] = useState(false); - const [envErr, setEnvErr] = useState(null); - - type ToastKind = 'info' | 'success' | 'error'; - type Toast = { id: string; kind: ToastKind; message: string; ts: number }; - const [toasts, setToasts] = useState([]); - const toastTimersRef = useRef>(new Map()); - function pushToast(kind: ToastKind, message: string, { ttlMs }: { ttlMs?: number } = {}) { - const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`; - const ts = Date.now(); - const toast: Toast = { id, kind, message, ts }; - setToasts((prev) => [toast].concat(prev).slice(0, 6)); - const ttl = typeof ttlMs === 'number' ? ttlMs : kind === 'error' ? 10_000 : 4_500; - const timer = setTimeout(() => { - setToasts((prev) => prev.filter((t) => t.id !== id)); - toastTimersRef.current.delete(id); - }, ttl); - toastTimersRef.current.set(id, timer); - } - function dismissToast(id: string) { - const t = toastTimersRef.current.get(id); - if (t) clearTimeout(t); - toastTimersRef.current.delete(id); - setToasts((prev) => prev.filter((x) => x.id !== id)); - } - useEffect(() => { - return () => { - for (const t of toastTimersRef.current.values()) clearTimeout(t); - toastTimersRef.current.clear(); - }; - }, []); - - // Human-friendly funding helpers (so operators don’t have to fish JSON out of logs). - const [lnFundingAddr, setLnFundingAddr] = useState(null); - const [lnFundingAddrErr, setLnFundingAddrErr] = useState(null); - const [solBalance, setSolBalance] = useState(null); - const [solBalanceErr, setSolBalanceErr] = useState(null); - const [walletUsdtMint, setWalletUsdtMint] = useState(() => { - try { - return String(window.localStorage.getItem('collin_wallet_usdt_mint') || '').trim(); - } catch (_e) { - return ''; - } - }); - const walletUsdtMintEnvRef = useRef(''); - const [walletUsdtAta, setWalletUsdtAta] = useState(null); - const [walletUsdtAtomic, setWalletUsdtAtomic] = useState(null); - const [walletUsdtErr, setWalletUsdtErr] = useState(null); - - const [lnWithdrawTo, setLnWithdrawTo] = useState(''); - const [lnWithdrawAmountSats, setLnWithdrawAmountSats] = useState(null); - const [lnWithdrawSatPerVbyte, setLnWithdrawSatPerVbyte] = useState(2); - const [lnRebalanceAmountSats, setLnRebalanceAmountSats] = useState(10_000); - const [lnRebalanceFeeLimitSat, setLnRebalanceFeeLimitSat] = useState(50); - const [lnRebalanceOutgoingChanId, setLnRebalanceOutgoingChanId] = useState(''); - const [lnRebalanceOpen, setLnRebalanceOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_ln_rebalance_open') || '') === '1'; - } catch (_e) { - return false; - } - }); - - const [solSendTo, setSolSendTo] = useState(''); - const [solSendLamports, setSolSendLamports] = useState(null); - - const [usdtSendToOwner, setUsdtSendToOwner] = useState(''); - const [usdtSendAtomic, setUsdtSendAtomic] = useState(null); - - // Optional Solana priority fee overrides (applied to UI-driven Solana transactions). - const [solCuLimit, setSolCuLimit] = useState(() => { - try { - const v = Number.parseInt(String(window.localStorage.getItem('collin_sol_cu_limit') || '0'), 10); - return Number.isFinite(v) ? Math.max(0, Math.min(1_400_000, Math.trunc(v))) : 0; - } catch (_e) { - return 0; - } - }); - const [solCuPrice, setSolCuPrice] = useState(() => { - try { - const v = Number.parseInt(String(window.localStorage.getItem('collin_sol_cu_price') || '0'), 10); - return Number.isFinite(v) ? Math.max(0, Math.min(1_000_000_000, Math.trunc(v))) : 0; - } catch (_e) { - return 0; - } - }); - useEffect(() => { - try { - window.localStorage.setItem('collin_sol_cu_limit', String(solCuLimit || 0)); - window.localStorage.setItem('collin_sol_cu_price', String(solCuPrice || 0)); - } catch (_e) {} - }, [solCuLimit, solCuPrice]); - - useEffect(() => { - try { - window.localStorage.setItem('collin_prompt_session_id', String(sessionId || '')); - } catch (_e) {} - }, [sessionId]); - - useEffect(() => { - try { - const mint = walletUsdtMint.trim(); - if (!mint) return; - window.localStorage.setItem('collin_wallet_usdt_mint', mint); - const kind = String(envInfo?.env_kind || '').trim().toLowerCase(); - if (kind) window.localStorage.setItem(`collin_wallet_usdt_mint:${kind}`, mint); - } catch (_e) {} - }, [walletUsdtMint, envInfo?.env_kind]); - - useEffect(() => { - // Keep the configured USDT mint scoped per env_kind (test vs mainnet). - const kind = String(envInfo?.env_kind || '').trim().toLowerCase(); - if (!kind) return; - if (walletUsdtMintEnvRef.current === kind) return; - walletUsdtMintEnvRef.current = kind; - try { - const saved = String(window.localStorage.getItem(`collin_wallet_usdt_mint:${kind}`) || '').trim(); - if (saved) { - setWalletUsdtMint(saved); - return; - } - } catch (_e) {} - if (kind === 'mainnet') setWalletUsdtMint(MAINNET_USDT_MINT); - }, [envInfo?.env_kind]); - - useEffect(() => { - try { - window.localStorage.setItem('collin_show_expired_invites', showExpiredInvites ? '1' : '0'); - } catch (_e) {} - }, [showExpiredInvites]); - - useEffect(() => { - try { - window.localStorage.setItem('collin_show_dismissed_invites', showDismissedInvites ? '1' : '0'); - } catch (_e) {} - }, [showDismissedInvites]); - - useEffect(() => { - try { - window.localStorage.setItem('collin_dismissed_invites', JSON.stringify(dismissedInviteTradeIds || {})); - } catch (_e) {} - }, [dismissedInviteTradeIds]); - - useEffect(() => { - // Best-effort UX: infer token mint from the most recent receipt, so operators immediately see the right USDT mint. - if (walletUsdtMint.trim()) return; - const rec = Array.isArray(preflight?.receipts) ? preflight.receipts[0] : null; - const mint = String(rec?.sol_mint || '').trim(); - if (mint) setWalletUsdtMint(mint); - }, [preflight?.receipts, walletUsdtMint]); - - useEffect(() => { - // Platform fee is set by the Solana program config (not negotiated). Mirror it into the RFQ/Offer caps. - const bps = typeof preflight?.sol_config?.fee_bps === 'number' ? Number(preflight.sol_config.fee_bps) : null; - if (typeof bps === 'number' && Number.isFinite(bps) && bps >= 0) { - setOfferMaxPlatformFeeBps(Math.trunc(bps)); - setRfqMaxPlatformFeeBps(Math.trunc(bps)); - } - }, [preflight?.sol_config?.fee_bps]); - - const [lnPeerInput, setLnPeerInput] = useState(''); - const [lnPeerAdvancedOpen, setLnPeerAdvancedOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_ln_peer_advanced_open') || '') === '1'; - } catch (_e) { - return false; - } - }); - const [lnAutoPeerFailover, setLnAutoPeerFailover] = useState(() => { - try { - // Default off: do not silently change operator-selected peers. - return String(window.localStorage.getItem('collin_ln_auto_peer_failover') || '0') === '1'; - } catch (_e) { - return false; - } - }); - const [lnChannelAmountSats, setLnChannelAmountSats] = useState(1_000_000); - const [lnChannelPushSats, setLnChannelPushSats] = useState(0); - const [lnChannelSatPerVbyte, setLnChannelSatPerVbyte] = useState(2); - const [lnChannelCloseSatPerVbyte, setLnChannelCloseSatPerVbyte] = useState(2); - const [lnSpliceChannelId, setLnSpliceChannelId] = useState(''); - const [lnSpliceRelativeSats, setLnSpliceRelativeSats] = useState(100_000); - const [lnSpliceSatPerVbyte, setLnSpliceSatPerVbyte] = useState(2); - const [lnSpliceMaxRounds, setLnSpliceMaxRounds] = useState(24); - const [lnSpliceSignFirst, setLnSpliceSignFirst] = useState(false); - const [lnSpliceOpen, setLnSpliceOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_ln_splice_open') || '') === '1'; - } catch (_e) { - return false; - } - }); - const [lnLiquidityMode, setLnLiquidityMode] = useState<'single_channel' | 'aggregate'>(() => { - try { - const v = String(window.localStorage.getItem('collin_ln_liquidity_mode') || '').trim(); - if (v === 'aggregate') return 'aggregate'; - } catch (_e) {} - return 'single_channel'; - }); - const [lnShowInactiveChannels, setLnShowInactiveChannels] = useState(() => { - try { - return String(window.localStorage.getItem('collin_ln_show_inactive_channels') || '') === '1'; - } catch (_e) { - return false; - } - }); - useEffect(() => { - try { - window.localStorage.setItem('collin_ln_liquidity_mode', lnLiquidityMode); - } catch (_e) {} - }, [lnLiquidityMode]); - useEffect(() => { - try { - window.localStorage.setItem('collin_ln_auto_peer_failover', lnAutoPeerFailover ? '1' : '0'); - } catch (_e) {} - }, [lnAutoPeerFailover]); - useEffect(() => { - try { - window.localStorage.setItem('collin_ln_peer_advanced_open', lnPeerAdvancedOpen ? '1' : '0'); - } catch (_e) {} - }, [lnPeerAdvancedOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_ln_splice_open', lnSpliceOpen ? '1' : '0'); - } catch (_e) {} - }, [lnSpliceOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_ln_rebalance_open', lnRebalanceOpen ? '1' : '0'); - } catch (_e) {} - }, [lnRebalanceOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_ln_show_inactive_channels', lnShowInactiveChannels ? '1' : '0'); - } catch (_e) {} - }, [lnShowInactiveChannels]); - useEffect(() => { - // UX helper: default peer on mainnet is ACINQ (reduces NO_ROUTE incidents). On non-mainnet, - // prefill from connected peers if available so operators don't start from an empty field. - if (lnPeerInput.trim()) return; - const kind = String(envInfo?.env_kind || '').trim().toLowerCase(); - if (kind === 'mainnet') { - setLnPeerInput(ACINQ_PEER_URI); - return; - } - const peers = collectLnPeerSuggestions(preflight?.ln_listpeers); - const next = peers.find((p) => p.connected) || peers[0]; - if (next?.uri) setLnPeerInput(next.uri); - }, [preflight?.ln_listpeers, lnPeerInput, envInfo?.env_kind]); - - useEffect(() => { - // Auto-pick a channel for splice UI so operators are not blocked on an empty field. - if (lnSpliceChannelId.trim()) return; - const rows = Array.isArray((preflight as any)?.ln_summary?.channel_rows) ? (preflight as any).ln_summary.channel_rows : []; - const next = rows.find((r: any) => Boolean(r?.active) && String(r?.id || '').trim()) || rows.find((r: any) => String(r?.id || '').trim()); - const id = String(next?.id || '').trim(); - if (id) setLnSpliceChannelId(id); - }, [preflight?.ln_summary?.channel_rows, lnSpliceChannelId]); - - useEffect(() => { - // For self-rebalance helper (LND), prefill a numeric chan_id if available. - if (lnRebalanceOutgoingChanId.trim()) return; - const rows = Array.isArray((preflight as any)?.ln_summary?.channel_rows) ? (preflight as any).ln_summary.channel_rows : []; - const next = - rows.find((r: any) => Boolean(r?.active) && /^[0-9]+$/.test(String(r?.chan_id || '').trim())) || - rows.find((r: any) => /^[0-9]+$/.test(String(r?.chan_id || '').trim())); - const id = String(next?.chan_id || '').trim(); - if (id) setLnRebalanceOutgoingChanId(id); - }, [preflight?.ln_summary?.channel_rows, lnRebalanceOutgoingChanId]); - - // Stack observer: lightweight operator signal when a previously-ready stack degrades. - const stackOkRef = useRef(null); - const [, setStackLastOkTs] = useState(null); - - // Sell USDT: offer announcer (non-binding discovery message). - type OfferLine = { id: string; btc_sats: number; usdt_amount: string }; - const [offerName, setOfferName] = useState(''); - const [offerLines, setOfferLines] = useState(() => [ - { id: `offer-${Date.now()}-0`, btc_sats: 10_000, usdt_amount: '1000000' }, // 1.000000 USDT - ]); - const [offerMaxPlatformFeeBps, setOfferMaxPlatformFeeBps] = useState(10); // 0.1% - const [offerMaxTradeFeeBps, setOfferMaxTradeFeeBps] = useState(10); // 0.1% - const [offerMaxTotalFeeBps, setOfferMaxTotalFeeBps] = useState(20); // 0.2% - const [offerMinSolRefundWindowSec, setOfferMinSolRefundWindowSec] = useState(72 * 3600); - const [offerMaxSolRefundWindowSec, setOfferMaxSolRefundWindowSec] = useState(7 * 24 * 3600); - const [offerValidUntilUnix, setOfferValidUntilUnix] = useState(() => Math.floor(Date.now() / 1000) + 72 * 3600); - const [offerBusy, setOfferBusy] = useState(false); - const [offerRunAsBot, setOfferRunAsBot] = useState(false); - const [offerBotIntervalSec, setOfferBotIntervalSec] = useState(60); - - // Sell BTC: RFQ poster (binding direction BTC_LN->USDT_SOL). - type RfqLine = { id: string; trade_id: string; btc_sats: number; usdt_amount: string }; - const [rfqChannel, setRfqChannel] = useState('0000intercomswapbtcusdt'); - const [rfqLines, setRfqLines] = useState(() => [ - { id: `rfq-${Date.now()}-0`, trade_id: `rfq-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`, btc_sats: 10_000, usdt_amount: '1000000' }, // 1.000000 USDT - ]); - const [rfqMaxPlatformFeeBps, setRfqMaxPlatformFeeBps] = useState(10); // 0.1% - const [rfqMaxTradeFeeBps, setRfqMaxTradeFeeBps] = useState(10); // 0.1% - const [rfqMaxTotalFeeBps, setRfqMaxTotalFeeBps] = useState(20); // 0.2% - const [rfqMinSolRefundWindowSec, setRfqMinSolRefundWindowSec] = useState(72 * 3600); - const [rfqMaxSolRefundWindowSec, setRfqMaxSolRefundWindowSec] = useState(7 * 24 * 3600); - const [rfqValidUntilUnix, setRfqValidUntilUnix] = useState(() => Math.floor(Date.now() / 1000) + 72 * 3600); - const [rfqBusy, setRfqBusy] = useState(false); - const [rfqRunAsBot, setRfqRunAsBot] = useState(false); - const [rfqBotIntervalSec, setRfqBotIntervalSec] = useState(60); - const [sellUsdtInboxOpen, setSellUsdtInboxOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_sell_usdt_inbox_open') || '1') !== '0'; - } catch (_e) { - return true; - } - }); - const [sellUsdtMyOpen, setSellUsdtMyOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_sell_usdt_my_open') || '1') !== '0'; - } catch (_e) { - return true; - } - }); - const [sellBtcInboxOpen, setSellBtcInboxOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_sell_btc_inbox_open') || '1') !== '0'; - } catch (_e) { - return true; - } - }); - const [sellBtcQuotesOpen, setSellBtcQuotesOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_sell_btc_quotes_open') || '1') !== '0'; - } catch (_e) { - return true; - } - }); - const [sellBtcMyOpen, setSellBtcMyOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_sell_btc_my_open') || '1') !== '0'; - } catch (_e) { - return true; - } - }); - const [knownChannelsOpen, setKnownChannelsOpen] = useState(() => { - try { - return String(window.localStorage.getItem('collin_known_channels_open') || '') === '1'; - } catch (_e) { - return false; - } - }); - useEffect(() => { - try { - window.localStorage.setItem('collin_auto_approve', autoApprove ? '1' : '0'); - } catch (_e) {} - }, [autoApprove]); - useEffect(() => { - try { - window.localStorage.setItem('collin_sell_usdt_inbox_open', sellUsdtInboxOpen ? '1' : '0'); - } catch (_e) {} - }, [sellUsdtInboxOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_sell_usdt_my_open', sellUsdtMyOpen ? '1' : '0'); - } catch (_e) {} - }, [sellUsdtMyOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_sell_btc_inbox_open', sellBtcInboxOpen ? '1' : '0'); - } catch (_e) {} - }, [sellBtcInboxOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_sell_btc_quotes_open', sellBtcQuotesOpen ? '1' : '0'); - } catch (_e) {} - }, [sellBtcQuotesOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_sell_btc_my_open', sellBtcMyOpen ? '1' : '0'); - } catch (_e) {} - }, [sellBtcMyOpen]); - useEffect(() => { - try { - window.localStorage.setItem('collin_known_channels_open', knownChannelsOpen ? '1' : '0'); - } catch (_e) {} - }, [knownChannelsOpen]); - const [leaveChannel, setLeaveChannel] = useState(''); - const [leaveBusy, setLeaveBusy] = useState(false); - - // Local receipts-driven views (paginated; memory-safe). - const [trades, setTrades] = useState([]); - const [tradesOffset, setTradesOffset] = useState(0); - const [tradesHasMore, setTradesHasMore] = useState(true); - const [tradesLoading, setTradesLoading] = useState(false); - const tradesLimit = 50; - const tradesListRef = useRef(null); - - const [openRefunds, setOpenRefunds] = useState([]); - const [openRefundsOffset, setOpenRefundsOffset] = useState(0); - const [openRefundsHasMore, setOpenRefundsHasMore] = useState(true); - const [openRefundsLoading, setOpenRefundsLoading] = useState(false); - const openRefundsLimit = 50; - const openRefundsListRef = useRef(null); - - const [openClaims, setOpenClaims] = useState([]); - const [openClaimsOffset, setOpenClaimsOffset] = useState(0); - const [openClaimsHasMore, setOpenClaimsHasMore] = useState(true); - const [openClaimsLoading, setOpenClaimsLoading] = useState(false); - const openClaimsLimit = 50; - const openClaimsListRef = useRef(null); - - // Receipts source picker: lets operators inspect receipts written by other helpers/bots too. - type ReceiptsSource = { key: string; label: string; db: string; exists?: boolean }; - const [receiptsSourceKey, setReceiptsSourceKey] = useState('default'); - const receiptsSources: ReceiptsSource[] = useMemo(() => { - const out: ReceiptsSource[] = []; - const srcRaw: any[] = Array.isArray((envInfo as any)?.receipts?.sources) ? ((envInfo as any).receipts.sources as any[]) : []; - for (const s of srcRaw) { - if (!s || typeof s !== 'object') continue; - const key = String((s as any).key || '').trim(); - const label = String((s as any).label || '').trim() || key; - const db = String((s as any).db || '').trim(); - const exists = (s as any).exists === undefined ? undefined : Boolean((s as any).exists); - if (!key || !db) continue; - out.push({ key, label, db, exists }); - } - const envDb = String((envInfo as any)?.receipts?.db || '').trim(); - if (!out.find((s) => s.key === 'default') && envDb) { - out.push({ key: 'default', label: 'default (setup.json)', db: envDb, exists: true }); - } - out.sort((a, b) => { - if (a.key === 'default' && b.key !== 'default') return -1; - if (b.key === 'default' && a.key !== 'default') return 1; - return a.label.localeCompare(b.label); - }); - return out; - }, [envInfo]); - const selectedReceiptsSource: ReceiptsSource | null = useMemo(() => { - if (receiptsSources.length < 1) return null; - return receiptsSources.find((s) => s.key === receiptsSourceKey) || receiptsSources.find((s) => s.key === 'default') || receiptsSources[0] || null; - }, [receiptsSources, receiptsSourceKey]); - useEffect(() => { - if (receiptsSources.length < 1) return; - if (!receiptsSources.some((s) => s.key === receiptsSourceKey)) { - setReceiptsSourceKey(receiptsSources.find((s) => s.key === 'default')?.key || receiptsSources[0].key); - } - }, [receiptsSources, receiptsSourceKey]); - const receiptsDbArg = useMemo(() => { - if (!selectedReceiptsSource) return {}; - if (selectedReceiptsSource.key === 'default') return {}; - return { db: selectedReceiptsSource.db }; - }, [selectedReceiptsSource]); - - const scAbortRef = useRef(null); - const scStreamGenRef = useRef(0); - const scStreamWantedRef = useRef(true); - const promptAbortRef = useRef(null); - - const scListRef = useRef(null); - const promptListRef = useRef(null); - - const scLoadingOlderRef = useRef(false); - const promptLoadingOlderRef = useRef(false); - const chatLoadingOlderRef = useRef(false); - - // Logs render newest-first. If the operator is scrolled away from the top, keep their viewport stable - // when new events arrive (avoid jumpiness). - useEffect(() => { - promptChatFollowTailRef.current = promptChatFollowTail; - }, [promptChatFollowTail]); - - const filteredScEvents = useMemo(() => { - const chan = scFilter.channel.trim().toLowerCase(); - const kind = scFilter.kind.trim().toLowerCase(); - return scEvents.filter((e) => { - const c = String((e as any)?.channel || (e as any)?.message?.channel || '').toLowerCase(); - const k = String((e as any)?.kind || (e as any)?.message?.kind || '').toLowerCase(); - if (chan && !c.includes(chan)) return false; - if (kind && !k.includes(kind)) return false; - return true; - }); - }, [scEvents, scFilter]); - const filteredScEventsDisplay = useMemo(() => dedupeListingRepostsLatest(filteredScEvents), [filteredScEvents]); - - const localPeerPubkeyHex = useMemo(() => { - try { - const scInfo = preflight?.sc_info && typeof preflight.sc_info === 'object' ? (preflight.sc_info as any) : null; - const info = scInfo?.info && typeof scInfo.info === 'object' ? scInfo.info : null; - const hex = info ? String(info.peerPubkey || '').trim() : ''; - return hex ? hex.toLowerCase() : ''; - } catch (_e) { - return ''; - } - }, [preflight?.sc_info]); - - const evtSignerHex = (evt: any) => { - try { - const s = String(evt?.message?.signer || evt?.from || '').trim(); - return s ? s.toLowerCase() : ''; - } catch (_e) { - return ''; - } - }; - - const normalizeChatRole = (v: any): 'user' | 'assistant' => { - return String(v || '').trim() === 'assistant' ? 'assistant' : 'user'; - }; - - function finalEventContentJson(e: any) { - // promptd emits {type:"final", content_json: {...}} (not wrapped). - if (!e || typeof e !== 'object') return null; - if (String((e as any).type || '') !== 'final') return null; - const cj = (e as any).content_json; - if (cj && typeof cj === 'object') return cj; - const c = (e as any).content; - if (typeof c === 'string' && c.trim().startsWith('{')) { - try { - const parsed = JSON.parse(c); - return parsed && typeof parsed === 'object' ? parsed : null; - } catch (_e) {} - } - return null; - } - - function toolEventResultJson(e: any) { - // promptd emits tool steps as {type:"tool", name:"...", result:{...}}. - if (!e || typeof e !== 'object') return null; - if (String((e as any).type || '') !== 'tool') return null; - const r = (e as any).result; - return r && typeof r === 'object' ? r : null; - } - - const localPostedSigKeys = useMemo(() => { - // Best-effort local outbox detection. Used to keep inboxes clean even before sc_info loads. - const set = new Set(); - const add = (env: any) => { - try { - const signer = String(env?.signer || '').trim().toLowerCase(); - const sig = String(env?.sig || '').trim().toLowerCase(); - if (signer && sig) set.add(`${signer}:${sig}`); - } catch (_e) {} - }; - - // Prompt tool results (works even if the SC feed is down). - for (const e of promptEvents) { - try { - const fin = finalEventContentJson(e); - if (fin && typeof fin === 'object') { - const t = String((fin as any).type || '').trim(); - if (t === 'offer_posted' || t === 'rfq_posted') add((fin as any).envelope); - } - const toolRes = toolEventResultJson(e); - if (toolRes && typeof toolRes === 'object') { - const t = String((toolRes as any).type || '').trim(); - if (t === 'offer_posted' || t === 'rfq_posted') add((toolRes as any).envelope); - } - } catch (_e) {} - } - - // Also include outbound network echoes when we know the local signer. - if (localPeerPubkeyHex) { - for (const e of scEvents) { - try { - const k = String((e as any)?.kind || ''); - if (k !== 'swap.rfq' && k !== 'swap.svc_announce') continue; - const signer = evtSignerHex(e); - if (!signer || signer !== localPeerPubkeyHex) continue; - add((e as any)?.message); - } catch (_e) {} - } - } - - return set; - }, [promptEvents, scEvents, localPeerPubkeyHex]); - - const rfqEvents = useMemo(() => { - const out = filteredScEvents.filter((e) => { - const k = String((e as any)?.kind || (e as any)?.message?.kind || ''); - if (k !== 'swap.rfq') return false; - const signer = evtSignerHex(e); - if (localPeerPubkeyHex && signer && signer === localPeerPubkeyHex) return false; - try { - const env = (e as any)?.message; - const s = String(env?.signer || '').trim().toLowerCase(); - const sig = String(env?.sig || '').trim().toLowerCase(); - const key = s && sig ? `${s}:${sig}` : ''; - if (key && localPostedSigKeys.has(key)) return false; - } catch (_e) {} - return true; - }); - return dedupeListingRepostsLatest(out); - }, [filteredScEvents, localPeerPubkeyHex, localPostedSigKeys]); - - const offerEvents = useMemo(() => { - const out = filteredScEvents.filter((e) => { - const k = String((e as any)?.kind || (e as any)?.message?.kind || ''); - if (k !== 'swap.svc_announce') return false; - const signer = evtSignerHex(e); - if (localPeerPubkeyHex && signer && signer === localPeerPubkeyHex) return false; - try { - const env = (e as any)?.message; - const s = String(env?.signer || '').trim().toLowerCase(); - const sig = String(env?.sig || '').trim().toLowerCase(); - const key = s && sig ? `${s}:${sig}` : ''; - if (key && localPostedSigKeys.has(key)) return false; - } catch (_e) {} - return true; - }); - return dedupeListingRepostsLatest(out); - }, [filteredScEvents, localPeerPubkeyHex, localPostedSigKeys]); - - const myOfferPosts = useMemo(() => { - // Offer announcements we posted locally. - // Primary source: prompt tool results (works even when sc/stream isn't connected yet). - // Secondary source: outbound sidechannel log (covers autopost/bots which don't create prompt history entries). - const out: any[] = []; - const seen = new Set(); - const envSigKey = (env: any) => { - try { - const signer = String(env?.signer || '').trim().toLowerCase(); - const sig = String(env?.sig || '').trim().toLowerCase(); - return signer && sig ? `${signer}:${sig}` : ''; - } catch (_e) { - return ''; - } - }; - for (const e of promptEvents) { - try { - const cj = finalEventContentJson(e); - const tr = toolEventResultJson(e); - const obj = cj && String((cj as any).type || '') === 'offer_posted' - ? cj - : tr && String((tr as any).type || '') === 'offer_posted' - ? tr - : null; - if (!obj) continue; - const env = (obj as any).envelope; - if (!env || typeof env !== 'object') continue; - const id = String(obj.svc_announce_id || '').trim(); - const key = - id || - envSigKey(env) || - String(env.trade_id || env.tradeId || '') || - String((e as any).db_id || '') || - String(e.ts || ''); - if (!key) continue; - if (seen.has(key)) continue; - seen.add(key); - - const chans = Array.isArray(obj.channels) ? obj.channels.map((c: any) => String(c || '').trim()).filter(Boolean) : []; - out.push({ - channel: chans[0] || '', - channels: chans, - rfq_channels: Array.isArray(obj.rfq_channels) ? obj.rfq_channels : [], - trade_id: String(env.trade_id || env.tradeId || '').trim(), - ts: typeof env.ts === 'number' ? env.ts : typeof e.ts === 'number' ? e.ts : Date.now(), - message: env, - kind: String(env.kind || ''), - dir: 'out', - local: true, - svc_announce_id: id || null, - }); - } catch (_e) {} - } - // Include outbound sc events (autopost/bots). - for (const e of scEvents) { - try { - if (String((e as any)?.kind || '') !== 'swap.svc_announce') continue; - const signer = evtSignerHex(e); - if (!localPeerPubkeyHex || signer !== localPeerPubkeyHex) continue; - const env = (e as any)?.message; - const key = envSigKey(env) || String((e as any).db_id || (e as any).seq || (e as any).ts || ''); - if (!key) continue; - if (seen.has(key)) continue; - seen.add(key); - out.push(e); - } catch (_e) {} - } - out.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0)); - return dedupeListingRepostsLatest(out); - }, [promptEvents, scEvents, localPeerPubkeyHex]); - - const myRfqPosts = useMemo(() => { - // RFQs we posted locally. - // Primary source: prompt tool results (works even when sc/stream isn't connected yet). - // Secondary source: outbound sidechannel log (covers autopost/bots). - const out: any[] = []; - const seen = new Set(); - const envSigKey = (env: any) => { - try { - const signer = String(env?.signer || '').trim().toLowerCase(); - const sig = String(env?.sig || '').trim().toLowerCase(); - return signer && sig ? `${signer}:${sig}` : ''; - } catch (_e) { - return ''; - } - }; - for (const e of promptEvents) { - try { - const cj = finalEventContentJson(e); - const tr = toolEventResultJson(e); - const obj = cj && String((cj as any).type || '') === 'rfq_posted' - ? cj - : tr && String((tr as any).type || '') === 'rfq_posted' - ? tr - : null; - if (!obj) continue; - const env = (obj as any).envelope; - if (!env || typeof env !== 'object') continue; - const rfqId = String(obj.rfq_id || '').trim(); - const key = - rfqId || - envSigKey(env) || - String(env.trade_id || env.tradeId || '') || - String((e as any).db_id || '') || - String(e.ts || ''); - if (!key) continue; - if (seen.has(key)) continue; - seen.add(key); - out.push({ - channel: String((obj as any).channel || '').trim(), - trade_id: String(env.trade_id || env.tradeId || '').trim(), - ts: typeof env.ts === 'number' ? env.ts : typeof e.ts === 'number' ? e.ts : Date.now(), - message: env, - kind: String(env.kind || ''), - dir: 'out', - local: true, - rfq_id: rfqId || null, - }); - } catch (_e) {} - } - for (const e of scEvents) { - try { - if (String((e as any)?.kind || '') !== 'swap.rfq') continue; - const signer = evtSignerHex(e); - if (!localPeerPubkeyHex || signer !== localPeerPubkeyHex) continue; - const env = (e as any)?.message; - const key = envSigKey(env) || String((e as any).db_id || (e as any).seq || (e as any).ts || ''); - if (!key) continue; - if (seen.has(key)) continue; - seen.add(key); - out.push(e); - } catch (_e) {} - } - out.sort((a, b) => Number(b?.ts || 0) - Number(a?.ts || 0)); - return dedupeListingRepostsLatest(out); - }, [promptEvents, scEvents, localPeerPubkeyHex]); - - const myRfqTradeIds = useMemo(() => { - const out = new Set(); - for (const e of myRfqPosts) { - const tradeId = String((e as any)?.trade_id || (e as any)?.message?.trade_id || '').trim(); - if (tradeId) out.add(tradeId); - } - return out; - }, [myRfqPosts]); - - const myRfqIds = useMemo(() => { - const out = new Set(); - for (const e of myRfqPosts) { - const rfqId = String((e as any)?.rfq_id || '').trim().toLowerCase(); - if (/^[0-9a-f]{64}$/i.test(rfqId)) out.add(rfqId); - } - return out; - }, [myRfqPosts]); - - const quoteEvents = useMemo(() => { - const out: any[] = []; - const seen = new Set(); - for (const e of filteredScEvents) { - try { - const kind = String((e as any)?.kind || (e as any)?.message?.kind || '').trim(); - if (kind !== 'swap.quote') continue; - const signer = evtSignerHex(e); - if (localPeerPubkeyHex && signer && signer === localPeerPubkeyHex) continue; - const msg = (e as any)?.message; - const body = msg?.body && typeof msg.body === 'object' ? msg.body : {}; - const tradeId = String((e as any)?.trade_id || msg?.trade_id || '').trim(); - const rfqId = String(body?.rfq_id || '').trim().toLowerCase(); - const isMine = (tradeId && myRfqTradeIds.has(tradeId)) || (/^[0-9a-f]{64}$/i.test(rfqId) && myRfqIds.has(rfqId)); - if (!isMine) continue; - const sig = String(msg?.sig || '').trim().toLowerCase(); - const key = sig || `${tradeId}|${String((e as any)?.db_id || (e as any)?.seq || (e as any)?.ts || '')}`; - if (seen.has(key)) continue; - seen.add(key); - out.push(e); - } catch (_e) {} - } - out.sort((a, b) => Number((b as any)?.ts || 0) - Number((a as any)?.ts || 0)); - return dedupeListingRepostsLatest(out); - }, [filteredScEvents, localPeerPubkeyHex, myRfqTradeIds, myRfqIds]); - - const joinedChannels = useMemo(() => { - try { - const chans = Array.isArray(preflight?.sc_stats?.channels) ? (preflight.sc_stats.channels as any[]) : []; - const out = chans.map((c) => String(c || '').trim()).filter(Boolean); - out.sort(); - return out; - } catch (_e) { - return []; - } - }, [preflight?.sc_stats]); - - const joinedChannelsSet = useMemo(() => new Set(joinedChannels), [joinedChannels]); - - // Terminal trades seen in local receipts. Use this in invite hygiene so stale invites disappear even if - // we did not observe the terminal sidechannel message in the current stream window. - const terminalReceiptTradeIdsSet = useMemo(() => { - const out = new Set(); - const addTrade = (t: any) => { - const tradeId = String(t?.trade_id || '').trim(); - if (!tradeId) return; - const state = String(t?.state || '').trim().toLowerCase(); - if (state === 'claimed' || state === 'refunded' || state === 'canceled' || state === 'cancelled') out.add(tradeId); - }; - for (const t of trades) addTrade(t); - for (const t of openClaims) addTrade(t); - for (const t of openRefunds) addTrade(t); - if (selected?.type === 'trade') addTrade(selected?.trade); - return out; - }, [trades, openClaims, openRefunds, selected]); - - // Terminal swap events observed on sidechannels and/or receipts. Once a trade is terminal, any lingering - // swap_invite is treated as stale and auto-hygiene will leave its swap:* channel + hide the invite. - const terminalTradeIdsSet = useMemo(() => { - const out = new Set(terminalReceiptTradeIdsSet); - for (const e of scEvents) { - try { - const kind = String((e as any)?.kind || '').trim(); - if (kind !== 'swap.sol_claimed' && kind !== 'swap.sol_refunded' && kind !== 'swap.cancel') continue; - const msg = (e as any)?.message; - const tradeId = String((e as any)?.trade_id || msg?.trade_id || '').trim(); - if (tradeId) out.add(tradeId); - } catch (_e) {} - } - return out; - }, [scEvents, terminalReceiptTradeIdsSet]); - - const isTerminalTrade = (trade: any): boolean => { - const tradeId = String(trade?.trade_id || '').trim(); - const state = String(trade?.state || '').trim().toLowerCase(); - if ( - state === 'claimed' || - state === 'refunded' || - state === 'canceled' || - state === 'cancelled' || - state === 'failed' || - state === 'expired' - ) { - return true; - } - return Boolean(tradeId && terminalTradeIdsSet.has(tradeId)); - }; - - const isTerminalTradeState = (stateRaw: any): boolean => { - const state = String(stateRaw || '').trim().toLowerCase(); - return ( - state === 'claimed' || - state === 'refunded' || - state === 'canceled' || - state === 'cancelled' || - state === 'failed' || - state === 'expired' - ); - }; - - const tradeUpdatedAtMs = (trade: any): number => { - const raw = trade?.updated_at; - if (typeof raw === 'number' && Number.isFinite(raw)) return raw; - if (typeof raw === 'string' && /^[0-9]+$/.test(raw.trim())) { - const n = Number.parseInt(raw.trim(), 10); - if (Number.isFinite(n)) return n; - } - return 0; - }; - - const listingEventTradeId = (evt: any): string => { - try { - return String(evt?.trade_id || evt?.message?.trade_id || '').trim(); - } catch (_e) { - return ''; - } - }; - - const listingEventTsMs = (evt: any): number => { - const cands = [evt?.ts, evt?.message?.ts, evt?.updated_at]; - for (const c of cands) { - const ms = epochToMs(c); - if (ms && Number.isFinite(ms) && ms > 0) return ms; - if (typeof c === 'number' && Number.isFinite(c) && c > 0) return c; - if (typeof c === 'string' && /^[0-9]+$/.test(c.trim())) { - const n = Number.parseInt(c.trim(), 10); - if (Number.isFinite(n) && n > 0) return n; - } - } - return 0; - }; - - // Keep the most recent known terminal timestamp per trade id. - // Listings older than or equal to this cutoff are hidden from activity panels. - const terminalTradeCutoffMsById = useMemo(() => { - const out = new Map(); - const put = (tradeIdRaw: any, tsRaw: any) => { - const tradeId = String(tradeIdRaw || '').trim(); - if (!tradeId) return; - const ms = epochToMs(tsRaw) || (typeof tsRaw === 'number' && Number.isFinite(tsRaw) ? tsRaw : 0); - const prev = Number(out.get(tradeId) || 0); - if (ms > prev) out.set(tradeId, ms); - else if (!out.has(tradeId)) out.set(tradeId, prev); - }; - for (const t of trades) { - if (!isTerminalTradeState(t?.state)) continue; - put(t?.trade_id, t?.updated_at ?? t?.created_at); - } - for (const t of openClaims) { - if (!isTerminalTradeState(t?.state)) continue; - put(t?.trade_id, t?.updated_at ?? t?.created_at); - } - for (const t of openRefunds) { - if (!isTerminalTradeState(t?.state)) continue; - put(t?.trade_id, t?.updated_at ?? t?.created_at); - } - if (selected?.type === 'trade' && isTerminalTradeState(selected?.trade?.state)) { - put(selected?.trade?.trade_id, selected?.trade?.updated_at ?? selected?.trade?.created_at); - } - for (const e of scEvents) { - try { - const kind = String((e as any)?.kind || '').trim(); - if (kind !== 'swap.sol_claimed' && kind !== 'swap.sol_refunded' && kind !== 'swap.cancel') continue; - const msg = (e as any)?.message; - const tradeId = String((e as any)?.trade_id || msg?.trade_id || '').trim(); - if (!tradeId) continue; - put(tradeId, (e as any)?.ts ?? msg?.ts); - } catch (_e) {} - } - for (const tradeId of terminalTradeIdsSet) { - if (!out.has(tradeId)) out.set(tradeId, 0); - } - return out; - }, [terminalTradeIdsSet, trades, openClaims, openRefunds, selected, scEvents]); - - const shouldHideTerminalListingEvent = (evt: any): boolean => { - const tradeId = listingEventTradeId(evt); - if (!tradeId) return false; - if (!terminalTradeIdsSet.has(tradeId)) return false; - const cutoffMs = Number(terminalTradeCutoffMsById.get(tradeId) || 0); - if (!Number.isFinite(cutoffMs) || cutoffMs <= 0) return true; - const evtMs = listingEventTsMs(evt); - if (!Number.isFinite(evtMs) || evtMs <= 0) return true; - return evtMs <= cutoffMs; - }; - - useEffect(() => { - if (terminalTradeIdsSet.size < 1) return; - setOpenRefunds((prev) => prev.filter((t) => !terminalTradeIdsSet.has(String(t?.trade_id || '').trim()))); - setOpenClaims((prev) => prev.filter((t) => !terminalTradeIdsSet.has(String(t?.trade_id || '').trim()))); - }, [terminalTradeIdsSet]); - - // Keep selected trade view in sync when receipt rows are refreshed with newer state. - useEffect(() => { - if (selected?.type !== 'trade') return; - const tradeId = String(selected?.trade?.trade_id || '').trim(); - if (!tradeId) return; - let freshest: any = null; - for (const t of trades) { - if (String(t?.trade_id || '').trim() !== tradeId) continue; - if (!freshest || tradeUpdatedAtMs(t) >= tradeUpdatedAtMs(freshest)) freshest = t; - } - for (const t of openRefunds) { - if (String(t?.trade_id || '').trim() !== tradeId) continue; - if (!freshest || tradeUpdatedAtMs(t) >= tradeUpdatedAtMs(freshest)) freshest = t; - } - for (const t of openClaims) { - if (String(t?.trade_id || '').trim() !== tradeId) continue; - if (!freshest || tradeUpdatedAtMs(t) >= tradeUpdatedAtMs(freshest)) freshest = t; - } - if (!freshest) return; - const current = selected?.trade; - const changed = - tradeUpdatedAtMs(freshest) !== tradeUpdatedAtMs(current) || - String(freshest?.state || '') !== String(current?.state || '') || - String(freshest?.last_error || '') !== String(current?.last_error || ''); - if (changed) setSelected({ type: 'trade', trade: freshest }); - }, [selected, trades, openRefunds, openClaims]); - - const rendezvousChannels = useMemo( - () => normalizeChannels(scChannels.split(',').map((s) => s.trim()).filter(Boolean), { max: 50, dropSwapTradeChannels: true }), - [scChannels] - ); - - const rendezvousChannelsSet = useMemo(() => new Set(rendezvousChannels), [rendezvousChannels]); - - const watchedChannelsSet = useMemo(() => { - const set = new Set(); - for (const c of rendezvousChannels) set.add(c); - for (const c of scSwapWatchChannels) set.add(c); - return set; - }, [rendezvousChannels, scSwapWatchChannels]); - - const inviteEvents = useMemo(() => { - const now = uiNowMs; - const out: any[] = []; - const seen = new Set(); - for (const e of scEvents) { - try { - if (String((e as any)?.kind || '') !== 'swap.swap_invite') continue; - const msg = (e as any)?.message; - const tradeId = String(msg?.trade_id || (e as any)?.trade_id || '').trim(); - const done = Boolean(tradeId && terminalTradeIdsSet.has(tradeId)); - const swapCh = String(msg?.body?.swap_channel || '').trim(); - const joined = Boolean(swapCh && (joinedChannelsSet.has(swapCh) || watchedChannelsSet.has(swapCh))); - const expiresAtRaw = msg?.body?.invite?.payload?.expiresAt; - const expiresAtMs = epochToMs(expiresAtRaw); - const expired = expiresAtMs && Number.isFinite(expiresAtMs) && expiresAtMs > 0 ? now > expiresAtMs : false; - if (expired && !showExpiredInvites) continue; - // Once we've already joined a swap channel, keep the invites inbox actionable by default. - if (joined && !showDismissedInvites) continue; - // Trades that are already done (claimed/refunded/canceled) should not linger in the invites inbox. - if (done && !showDismissedInvites) continue; - if (tradeId && dismissedInviteTradeIds && dismissedInviteTradeIds[tradeId] && !showDismissedInvites) continue; - - const key = `${tradeId || ''}|${swapCh || ''}|${String((e as any)?.from || '')}|${String((e as any)?.seq || '')}`; - if (key && seen.has(key)) continue; - if (key) seen.add(key); - out.push({ ...e, _invite_expires_at_ms: expiresAtMs, _invite_expired: expired, _invite_joined: joined, _invite_done: done }); - } catch (_e) {} - } - return out; - }, [scEvents, showExpiredInvites, dismissedInviteTradeIds, showDismissedInvites, uiNowMs, joinedChannelsSet, watchedChannelsSet, terminalTradeIdsSet]); - - const knownChannels = useMemo(() => { - const set = new Set(); - for (const e of scEvents) { - const c = String((e as any)?.channel || '').trim(); - if (c) set.add(c); - } - for (const c of rendezvousChannels) set.add(c); - for (const c of scSwapWatchChannels) set.add(c); - try { - const joined = Array.isArray(preflight?.sc_stats?.channels) ? (preflight.sc_stats.channels as any[]) : []; - for (const c of joined) { - const ch = String(c || '').trim(); - if (ch) set.add(ch); - } - } catch (_e) {} - return Array.from(set).sort(); - }, [scEvents, rendezvousChannels, scSwapWatchChannels, preflight?.sc_stats]); - const knownChannelsForInputs = useMemo(() => knownChannels.slice(0, 500), [knownChannels]); - const knownRendezvousChannelsForInputs = useMemo( - () => knownChannelsForInputs.filter((c) => !isSwapTradeChannelName(c)), - [knownChannelsForInputs] - ); - - useEffect(() => { - if (!isSwapTradeChannelName(rfqChannel)) return; - const next = knownRendezvousChannelsForInputs[0] || rendezvousChannels[0] || '0000intercomswapbtcusdt'; - if (next !== rfqChannel) setRfqChannel(next); - }, [rfqChannel, knownRendezvousChannelsForInputs, rendezvousChannels]); - - const lnWalletLocked = useMemo(() => { - const errs = [ - preflight?.ln_info_error, - preflight?.ln_listfunds_error, - preflight?.ln_listpeers_error, - preflight?.ln_docker_ps_error, - lnFundingAddrErr, - ]; - return errs.some((e) => isLnWalletLockedError(e)); - }, [preflight?.ln_info_error, preflight?.ln_listfunds_error, preflight?.ln_listpeers_error, preflight?.ln_docker_ps_error, lnFundingAddrErr]); - - const lnWalletLockedMessage = useMemo(() => { - const errs = [ - preflight?.ln_info_error, - preflight?.ln_listfunds_error, - preflight?.ln_listpeers_error, - preflight?.ln_docker_ps_error, - lnFundingAddrErr, - ]; - for (const e of errs) { - if (isLnWalletLockedError(e)) { - return String(e || '').trim(); - } - } - return ''; - }, [preflight?.ln_info_error, preflight?.ln_listfunds_error, preflight?.ln_listpeers_error, preflight?.ln_docker_ps_error, lnFundingAddrErr]); - - // Stale swap-channel auto-leave is handled by backend tradeauto worker. - // UI only auto-dismisses stale invites for a cleaner actionable inbox. - - // Auto-dismiss stale invites even if we aren't joined. This keeps the invites inbox actionable. - const autoDismissTradeRef = useRef>(new Set()); - useEffect(() => { - if (!health?.ok) return; - const now = uiNowMs; - const toDismiss: string[] = []; - for (const e of scEvents) { - try { - if (String((e as any)?.kind || '') !== 'swap.swap_invite') continue; - const msg = (e as any)?.message; - const tradeId = String(msg?.trade_id || (e as any)?.trade_id || '').trim(); - if (!tradeId) continue; - if (dismissedInviteTradeIds && dismissedInviteTradeIds[tradeId]) continue; - if (autoDismissTradeRef.current.has(tradeId)) continue; - const done = terminalTradeIdsSet.has(tradeId); - const swapCh = String(msg?.body?.swap_channel || '').trim(); - const joined = Boolean(swapCh && (joinedChannelsSet.has(swapCh) || watchedChannelsSet.has(swapCh))); - const expiresAtMs = epochToMs(msg?.body?.invite?.payload?.expiresAt) || 0; - const expired = expiresAtMs && Number.isFinite(expiresAtMs) && expiresAtMs > 0 ? now > expiresAtMs : false; - if (!done && !expired && !joined) continue; - autoDismissTradeRef.current.add(tradeId); - toDismiss.push(tradeId); - } catch (_e) {} - } - if (toDismiss.length === 0) return; - for (const tid of toDismiss.slice(0, 20)) dismissInviteTrade(tid); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [health?.ok, scEvents, uiNowMs, dismissedInviteTradeIds, terminalTradeIdsSet, joinedChannelsSet, watchedChannelsSet]); - function dismissInviteTrade(tradeIdRaw: string) { - const tradeId = String(tradeIdRaw || '').trim(); - if (!tradeId) return; - setDismissedInviteTradeIds((prev) => ({ ...(prev || {}), [tradeId]: Date.now() })); - } - - function undismissInviteTrade(tradeIdRaw: string) { - const tradeId = String(tradeIdRaw || '').trim(); - if (!tradeId) return; - setDismissedInviteTradeIds((prev) => { - const next = { ...(prev || {}) }; - delete next[tradeId]; - return next; - }); - } - - function setRendezvousChannels(next: string[]) { - const uniq = normalizeChannels(next.map((s) => String(s || '').trim()).filter(Boolean), { max: 50, dropSwapTradeChannels: true }); - setScChannels(uniq.join(',')); - } - - function setRendezvousChannelsFromInput(raw: string) { - const uniq = normalizeChannels( - String(raw || '') - .split(',') - .map((s) => s.trim()) - .filter(Boolean), - { max: 50, dropSwapTradeChannels: true } - ); - setScChannels(uniq.join(',')); - } - - function setSwapWatchChannels(next: string[]) { - const uniq = normalizeChannels(next.map((s) => String(s || '').trim()).filter(Boolean), { max: 50, dropSwapTradeChannels: false }).filter((c) => - isSwapTradeChannelName(c) - ); - const now = Date.now(); - const prevSeen = scSwapWatchFirstSeenAtRef.current; - const nextSeen = new Map(); - for (const c of uniq) { - const seenAt = Number(prevSeen.get(c) || 0); - nextSeen.set(c, seenAt > 0 ? seenAt : now); - } - scSwapWatchFirstSeenAtRef.current = nextSeen; - setScSwapWatchChannelsState(uniq); - } - - function watchChannel(channelRaw: string) { - const channel = String(channelRaw || '').trim(); - if (!channel) return; - if (isSwapTradeChannelName(channel)) { - if (scSwapWatchChannels.includes(channel)) return; - setSwapWatchChannels([...scSwapWatchChannels, channel]); - } else { - if (rendezvousChannelsSet.has(channel)) return; - setRendezvousChannels([...rendezvousChannels, channel]); - } - // Restart the stream quickly so the new channel appears without requiring a manual reconnect. - setTimeout(() => void startScStream(), 150); - } - - function unwatchChannel(channelRaw: string) { - const channel = String(channelRaw || '').trim(); - if (!channel) return; - if (isSwapTradeChannelName(channel)) { - setSwapWatchChannels(scSwapWatchChannels.filter((c) => c !== channel)); - } else { - setRendezvousChannels(rendezvousChannels.filter((c) => c !== channel)); - } - setTimeout(() => void startScStream(), 150); - } - - // Keep UI watchlist aligned with actual joined swap channels. - // This prevents "joined but unwatched" confusion and ensures swap channel events are streamed in UI. - useEffect(() => { - if (!health?.ok) return; - if (!Array.isArray(joinedChannels) || joinedChannels.length < 1) return; - const missing: string[] = []; - for (const chRaw of joinedChannels) { - const ch = String(chRaw || '').trim(); - if (!ch || !ch.startsWith('swap:')) continue; - if (watchedChannelsSet.has(ch)) continue; - missing.push(ch); - } - if (missing.length < 1) return; - setSwapWatchChannels([...scSwapWatchChannels, ...missing]); - setTimeout(() => void startScStream(), 150); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [health?.ok, joinedChannels, watchedChannelsSet, scSwapWatchChannels]); - - // Invite/swap watch hygiene: drop swap:* watch channels once first seen age exceeds 2h. - useEffect(() => { - if (!Array.isArray(scSwapWatchChannels) || scSwapWatchChannels.length < 1) return; - const now = uiNowMs; - const keep: string[] = []; - const firstSeen = scSwapWatchFirstSeenAtRef.current; - for (const ch of scSwapWatchChannels) { - const seenAt = Number(firstSeen.get(ch) || 0); - if (seenAt > 0 && now - seenAt > SWAP_WATCH_RETENTION_MS) continue; - keep.push(ch); - } - if (keep.length === scSwapWatchChannels.length) return; - setSwapWatchChannels(keep); - setTimeout(() => void startScStream(), 150); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uiNowMs, scSwapWatchChannels]); - - async function acceptQuoteEnvelope(quoteEvt: any, opts: { origin: 'manual' }) { - const channel = String((quoteEvt as any)?.channel || '').trim(); - const msg = quoteEvt?.message; - if (!channel || !msg || typeof msg !== 'object') throw new Error('quote event missing channel/message'); - if (opts.origin === 'manual' && toolRequiresApproval('intercomswap_quote_accept') && !autoApprove) { - const ok = window.confirm(`Accept quote now?\n\nchannel: ${channel}`); - if (!ok) return null; - } - const out = await runToolFinal( - 'intercomswap_quote_accept', - { channel, quote_envelope: msg, ln_liquidity_mode: lnLiquidityMode }, - { auto_approve: true } - ); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && String((cj as any).type || '') === 'error') { - throw new Error(String((cj as any).error || 'quote_accept failed')); - } - return cj && typeof cj === 'object' ? cj : null; - } - - async function quoteFromRfqEnvelope(rfqEvt: any, opts: { origin: 'manual' }) { - const channel = String((rfqEvt as any)?.channel || '').trim(); - const msg = rfqEvt?.message; - if (!channel || !msg || typeof msg !== 'object') throw new Error('rfq event missing channel/message'); - const body = msg?.body && typeof msg.body === 'object' ? msg.body : {}; - const tradeFeeCollector = String(solSignerPubkey || '').trim(); - if (!tradeFeeCollector) { - throw new Error('Solana signer pubkey unavailable (cannot set trade_fee_collector)'); - } - - const nowSec = Math.floor(Date.now() / 1000); - const quoteTtlSec = 180; - const rfqValidUntilRaw = Number.parseInt(String((body as any)?.valid_until_unix ?? ''), 10); - const rfqValidUntil = Number.isFinite(rfqValidUntilRaw) && rfqValidUntilRaw > 0 ? rfqValidUntilRaw : null; - const candidateUntil = nowSec + quoteTtlSec; - const validUntilUnix = - rfqValidUntil && rfqValidUntil > nowSec + 5 - ? Math.max(nowSec + 10, Math.min(candidateUntil, rfqValidUntil)) - : candidateUntil; - - const minWinRaw = Number.parseInt(String((body as any)?.min_sol_refund_window_sec ?? ''), 10); - const maxWinRaw = Number.parseInt(String((body as any)?.max_sol_refund_window_sec ?? ''), 10); - const minWin = Number.isFinite(minWinRaw) ? minWinRaw : null; - const maxWin = Number.isFinite(maxWinRaw) ? maxWinRaw : null; - let refundWindowSec = 72 * 3600; - if (typeof minWin === 'number') refundWindowSec = Math.max(refundWindowSec, minWin); - if (typeof maxWin === 'number') refundWindowSec = Math.min(refundWindowSec, maxWin); - refundWindowSec = Math.max(3600, Math.min(7 * 24 * 3600, Math.trunc(refundWindowSec))); - - if (opts.origin === 'manual' && toolRequiresApproval('intercomswap_quote_post_from_rfq') && !autoApprove) { - const ok = window.confirm(`Post quote for this RFQ now?\n\nchannel: ${channel}`); - if (!ok) return null; - } - const out = await runToolFinal( - 'intercomswap_quote_post_from_rfq', - { - channel, - rfq_envelope: msg, - trade_fee_collector: tradeFeeCollector, - sol_refund_window_sec: refundWindowSec, - valid_until_unix: validUntilUnix, - }, - { auto_approve: true } - ); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && String((cj as any).type || '') === 'error') { - throw new Error(String((cj as any).error || 'quote_post_from_rfq failed')); - } - return cj && typeof cj === 'object' ? cj : null; - } - - function listingChannelsForCancel(evt: any): string[] { - const out: string[] = []; - const seen = new Set(); - const add = (raw: any) => { - const ch = String(raw || '').trim(); - if (!ch) return; - if (isSwapTradeChannelName(ch)) return; - if (seen.has(ch)) return; - seen.add(ch); - out.push(ch); - }; - const chans = Array.isArray((evt as any)?.channels) ? (evt as any).channels : []; - for (const c of chans) add(c); - add((evt as any)?.channel); - return out; - } - - async function cancelListingFromEvent(evt: any, opts: { kindLabel: 'Offer' | 'RFQ' }) { - const kindLabel = String(opts?.kindLabel || 'Listing'); - const tradeId = String((evt as any)?.trade_id || (evt as any)?.message?.trade_id || '').trim(); - if (!tradeId) throw new Error(`${kindLabel} cancel: missing trade_id`); - - const body = (evt as any)?.message?.body && typeof (evt as any).message.body === 'object' ? (evt as any).message.body : {}; - const validUntilRaw = Number.parseInt(String((body as any)?.valid_until_unix ?? ''), 10); - const validUntil = Number.isFinite(validUntilRaw) && validUntilRaw > 0 ? validUntilRaw : null; - const nowSec = Math.floor(Date.now() / 1000); - if (validUntil && validUntil <= nowSec) { - throw new Error(`${kindLabel} cancel: listing is expired`); - } - - const channels = listingChannelsForCancel(evt); - if (channels.length < 1) throw new Error(`${kindLabel} cancel: missing listing channel`); - - if (toolRequiresApproval('intercomswap_swap_cancel_post') && !autoApprove) { - const ok = window.confirm( - `Cancel this ${kindLabel.toLowerCase()} now?\n\ntrade_id: ${tradeId}\nchannels: ${channels.join(', ')}` - ); - if (!ok) return; - } - - let okCount = 0; - let firstErr = ''; - for (const channel of channels) { - try { - const out = await runToolFinal( - 'intercomswap_swap_cancel_post', - { channel, trade_id: tradeId, reason: `${kindLabel.toLowerCase()} canceled manually` }, - { auto_approve: true } - ); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && String((cj as any).type || '') === 'error') { - throw new Error(String((cj as any).error || 'cancel failed')); - } - okCount += 1; - } catch (e: any) { - if (!firstErr) firstErr = e?.message || String(e); - } - } - - if (okCount < 1) { - throw new Error(firstErr || `${kindLabel} cancel failed`); - } - pushToast('success', `${kindLabel} canceled on ${okCount}/${channels.length} channel${channels.length === 1 ? '' : 's'}.`); - if (okCount < channels.length) { - pushToast('error', `${kindLabel} cancel partial failure: ${firstErr || 'unknown error'}`); - } - void refreshPreflight(); - } - - function feedEventId(prefix: string, e: any, fallbackIndex?: number) { - const db = typeof e?.db_id === 'number' ? e.db_id : null; - if (db !== null) return `${prefix}db:${db}`; - const seq = typeof e?.seq === 'number' ? e.seq : null; - if (seq !== null) return `${prefix}seq:${seq}`; - const sig = String(e?.message?.sig || '').trim().toLowerCase(); - if (sig) return `${prefix}sig:${sig}`; - const trade = String(e?.trade_id || e?.message?.trade_id || '').trim().toLowerCase(); - const ts = typeof e?.ts === 'number' ? e.ts : ''; - const kind = String(e?.kind || e?.message?.kind || '').trim().toLowerCase(); - if (trade || ts || kind) return `${prefix}${kind}:${trade}:${ts}`; - if (typeof fallbackIndex === 'number') return `${prefix}idx:${fallbackIndex}`; - return `${prefix}x`; - } - - function toggleSellUsdtSection(section: 'inbox' | 'mine') { - if (section === 'inbox') { - setSellUsdtInboxOpen((prev) => { - const next = !prev; - if (next) setSellUsdtMyOpen(false); - return next; - }); - return; - } - setSellUsdtMyOpen((prev) => { - const next = !prev; - if (next) setSellUsdtInboxOpen(false); - return next; - }); - } - - function toggleSellBtcSection(section: 'offers' | 'quotes' | 'mine') { - if (section === 'offers') { - setSellBtcInboxOpen((prev) => { - const next = !prev; - if (next) { - setSellBtcQuotesOpen(false); - setSellBtcMyOpen(false); - } - return next; - }); - return; - } - if (section === 'quotes') { - setSellBtcQuotesOpen((prev) => { - const next = !prev; - if (next) { - setSellBtcInboxOpen(false); - setSellBtcMyOpen(false); - } - return next; - }); - return; - } - setSellBtcMyOpen((prev) => { - const next = !prev; - if (next) { - setSellBtcInboxOpen(false); - setSellBtcQuotesOpen(false); - } - return next; - }); - } - - const sellUsdtFeedItems = useMemo(() => { - const visibleRfqEvents = rfqEvents.filter((e) => !shouldHideTerminalListingEvent(e)); - const visibleMyOfferPosts = myOfferPosts.filter((e) => !shouldHideTerminalListingEvent(e)); - const out: any[] = []; - out.push({ _t: 'header', id: 'h:inboxrfqs', title: 'BTC Sales', count: visibleRfqEvents.length, open: sellUsdtInboxOpen, onToggle: () => toggleSellUsdtSection('inbox') }); - if (sellUsdtInboxOpen) { - for (let i = 0; i < visibleRfqEvents.length; i += 1) { - const e = visibleRfqEvents[i]; - out.push({ _t: 'rfq', id: feedEventId('inrfq:', e, i), evt: e }); - } - } - out.push({ _t: 'header', id: 'h:myoffers', title: 'My Offers', count: visibleMyOfferPosts.length, open: sellUsdtMyOpen, onToggle: () => toggleSellUsdtSection('mine') }); - if (sellUsdtMyOpen) { - for (let i = 0; i < visibleMyOfferPosts.length; i += 1) { - const e = visibleMyOfferPosts[i]; - out.push({ _t: 'offer', id: feedEventId('myoffer:', e, i), evt: e, badge: 'outbox' }); - } - } - return out; - }, [myOfferPosts, rfqEvents, sellUsdtInboxOpen, sellUsdtMyOpen, terminalTradeIdsSet, terminalTradeCutoffMsById]); - - const sellBtcFeedItems = useMemo(() => { - const visibleOfferEvents = offerEvents.filter((e) => !shouldHideTerminalListingEvent(e)); - const visibleQuoteEvents = quoteEvents.filter((e) => !shouldHideTerminalListingEvent(e)); - const visibleMyRfqPosts = myRfqPosts.filter((e) => !shouldHideTerminalListingEvent(e)); - const out: any[] = []; - out.push({ _t: 'header', id: 'h:inboxoffers', title: 'USDT Sales', count: visibleOfferEvents.length, open: sellBtcInboxOpen, onToggle: () => toggleSellBtcSection('offers') }); - if (sellBtcInboxOpen) { - for (let i = 0; i < visibleOfferEvents.length; i += 1) { - const e = visibleOfferEvents[i]; - out.push({ _t: 'offer', id: feedEventId('inoffer:', e, i), evt: e }); - } - } - out.push({ _t: 'header', id: 'h:inboxquotes', title: 'Exact Matches', count: visibleQuoteEvents.length, open: sellBtcQuotesOpen, onToggle: () => toggleSellBtcSection('quotes') }); - if (sellBtcQuotesOpen) { - for (let i = 0; i < visibleQuoteEvents.length; i += 1) { - const e = visibleQuoteEvents[i]; - out.push({ _t: 'quote', id: feedEventId('inq:', e, i), evt: e }); - } - } - out.push({ _t: 'header', id: 'h:myrfqs', title: 'My Offers', count: visibleMyRfqPosts.length, open: sellBtcMyOpen, onToggle: () => toggleSellBtcSection('mine') }); - if (sellBtcMyOpen) { - for (let i = 0; i < visibleMyRfqPosts.length; i += 1) { - const e = visibleMyRfqPosts[i]; - out.push({ _t: 'rfq', id: feedEventId('myrfq:', e, i), evt: e, badge: 'outbox' }); - } - } - return out; - }, [offerEvents, quoteEvents, myRfqPosts, sellBtcInboxOpen, sellBtcQuotesOpen, sellBtcMyOpen, terminalTradeIdsSet, terminalTradeCutoffMsById]); - - function oldestDbId(list: any[]) { - let min = Number.POSITIVE_INFINITY; - for (const e of list) { - const id = typeof e?.db_id === 'number' ? e.db_id : null; - if (id !== null && Number.isFinite(id) && id < min) min = id; - } - return Number.isFinite(min) ? min : null; - } - - async function loadOlderScEvents({ limit = 200 } = {}) { - if (scLoadingOlderRef.current) return; - const beforeId = oldestDbId(scEvents); - if (!beforeId) return; - scLoadingOlderRef.current = true; - try { - const older = await scListBefore({ beforeId, limit }); - if (!older || older.length === 0) return; - const cutoff = Date.now() - COLLINS_SC_FEED_RETENTION_MS; - const mapped = older - .map((r) => ({ ...(r.evt || {}), db_id: r.id })) - .filter((e) => { - const ts = eventTsMs(e); - return ts <= 0 || ts >= cutoff; - }); - if (mapped.length < 1) return; - setScEvents((prev) => { - const seen = new Set(prev.map((e) => e?.db_id).filter((n) => typeof n === 'number')); - const toAdd = mapped.filter((e) => typeof e?.db_id === 'number' && !seen.has(e.db_id)); - const next = prev.concat(toAdd); - if (next.length <= scEventsMax) return next; - // Keep newest window and drop oldest. - return next.slice(0, scEventsMax); - }); - } finally { - scLoadingOlderRef.current = false; - } - } - - async function loadOlderPromptEvents({ limit = 200 } = {}) { - if (promptLoadingOlderRef.current) return; - const beforeId = oldestDbId(promptEvents); - if (!beforeId) return; - promptLoadingOlderRef.current = true; - try { - const older = await promptListBefore({ beforeId, limit }); - if (!older || older.length === 0) return; - const cutoff = Date.now() - COLLINS_ACTIVITY_RETENTION_MS; - const mapped = older - .map((r) => ({ ...(r.evt || {}), db_id: r.id })) - .filter((e) => { - const ts = eventTsMs(e); - return ts <= 0 || ts >= cutoff; - }); - if (mapped.length < 1) return; - setPromptEvents((prev) => { - const seen = new Set(prev.map((e) => e?.db_id).filter((n) => typeof n === 'number')); - const toAdd = mapped.filter((e) => typeof e?.db_id === 'number' && !seen.has(e.db_id)); - const next = prev.concat(toAdd); - if (next.length <= promptEventsMax) return next; - return next.slice(0, promptEventsMax); - }); - } finally { - promptLoadingOlderRef.current = false; - } - } - - function oldestChatId(list: any[]) { - if (!Array.isArray(list) || list.length === 0) return null; - const first = list[0]; - const id = typeof first?.id === 'number' ? first.id : null; - return id !== null && Number.isFinite(id) ? id : null; - } - - async function loadOlderChatMessages({ limit = 200 } = {}) { - if (chatLoadingOlderRef.current) return; - const beforeId = oldestChatId(promptChat); - if (!beforeId) return; - chatLoadingOlderRef.current = true; - - const el = promptChatListRef.current; - const prevHeight = el ? el.scrollHeight : 0; - const prevTop = el ? el.scrollTop : 0; - - try { - const older = await chatListBefore({ beforeId, limit }); - if (!older || older.length === 0) return; - const cutoff = Date.now() - COLLINS_ACTIVITY_RETENTION_MS; - // DB returns newest-first; chat UI wants oldest-first. - const mapped = older - .map((r: any) => ({ id: Number(r.id), role: normalizeChatRole(r.role), ts: Number(r.ts), text: String(r.text || '') })) - .filter((m) => Number.isFinite(m.ts) && m.ts >= cutoff) - .reverse(); - if (mapped.length < 1) return; - setPromptChat((prev) => { - const seen = new Set(prev.map((m) => m?.id).filter((n) => typeof n === 'number')); - const toAdd = mapped.filter((m) => typeof m?.id === 'number' && !seen.has(m.id)); - const next = toAdd.concat(prev); - // Keep chat memory bounded. If we exceed the window, drop newest items (operators can jump back to latest). - if (next.length <= promptChatMax) return next; - return next.slice(0, promptChatMax); - }); - requestAnimationFrame(() => { - const el2 = promptChatListRef.current; - if (!el2) return; - const delta = el2.scrollHeight - prevHeight; - if (delta > 0) el2.scrollTop = prevTop + delta; - }); - } finally { - chatLoadingOlderRef.current = false; - } - } - - function normalizeToolList(raw: any): Array<{ name: string; description: string; parameters: any }> { - const list = Array.isArray(raw?.tools) ? raw.tools : Array.isArray(raw) ? raw : []; - const out: Array<{ name: string; description: string; parameters: any }> = []; - for (const t of list) { - const fn = t?.function; - const name = String(fn?.name || '').trim(); - if (!name) continue; - out.push({ - name, - description: String(fn?.description || '').trim(), - parameters: fn?.parameters ?? null, - }); - } - out.sort((a, b) => a.name.localeCompare(b.name)); - return out; - } - - const activeTool = useMemo(() => { - if (!tools || !toolName) return null; - return (tools as any[]).find((t: any) => t?.name === toolName) || null; - }, [tools, toolName]); - - const scoredTools = useMemo(() => { - const list = Array.isArray(tools) ? tools : []; - const q = toolFilter.trim(); - const qTokens = parseToolSearchTokens(q); - return list - .map((t: any) => { - const name = String(t?.name || ''); - const desc = String(t?.description || ''); - const score = toolSearchScore({ name, description: desc }, q, qTokens); - return { tool: t, score, name }; - }) - .filter((row) => row.score >= 0) - .sort((a, b) => (b.score - a.score) || String(a.name).localeCompare(String(b.name))); - }, [tools, toolFilter]); - - const groupedTools = useMemo(() => { - const groups: Record = {}; - for (const row of scoredTools) { - const t = row.tool; - const name = String((t as any)?.name || ''); - const g = toolGroup(name); - (groups[g] ||= []).push(t); - } - const order = [ - 'SC-Bridge', - 'Peers', - 'RFQ Protocol', - 'Swap Helpers', - 'RFQ Bots', - 'Lightning', - 'Solana', - 'Receipts/Recovery', - 'Other', - ]; - const out = []; - for (const g of order) { - const arr = groups[g]; - if (!arr || arr.length === 0) continue; - arr.sort((a: any, b: any) => String(a.name).localeCompare(String(b.name))); - out.push({ group: g, tools: arr }); - } - return out; - }, [scoredTools]); - - const toolSuggestions = useMemo(() => { - const q = toolFilter.trim(); - if (!q) return []; - return scoredTools.slice(0, 10).map((row) => row.tool); - }, [scoredTools, toolFilter]); - - const activePeerState = useMemo(() => { - const scPort = (() => { - try { - const u = new URL(String(envInfo?.sc_bridge?.url || '').trim() || 'ws://127.0.0.1:49222'); - const p = u.port ? Number.parseInt(u.port, 10) : 0; - return Number.isFinite(p) && p > 0 ? p : 49222; - } catch (_e) { - return 49222; - } - })(); - const peers = Array.isArray(preflight?.peer_status?.peers) ? preflight.peer_status.peers : []; - return peers.find((p: any) => Boolean(p?.alive) && Number(p?.sc_bridge?.port) === scPort) || null; - }, [preflight?.peer_status, envInfo?.sc_bridge?.url]); - - const invitePolicy = useMemo(() => { - const args = activePeerState?.args && typeof activePeerState.args === 'object' ? activePeerState.args : {}; - const inviteRequired = Boolean(args?.sidechannel_invite_required); - const invitePrefixes = Array.isArray(args?.sidechannel_invite_prefixes) - ? args.sidechannel_invite_prefixes.map((v: any) => String(v || '').trim()).filter(Boolean) - : []; - const inviterKeys = new Set( - (Array.isArray(args?.sidechannel_inviter_keys) ? args.sidechannel_inviter_keys : []) - .map((v: any) => String(v || '').trim().toLowerCase()) - .filter((v: string) => /^[0-9a-f]{64}$/.test(v)) - ); - const appliesToSwap = - inviteRequired && - (invitePrefixes.length === 0 || invitePrefixes.some((p: string) => String('swap:trade').startsWith(String(p)))); - return { - inviteRequired, - invitePrefixes, - inviterKeys, - appliesToSwap, - missingTrustedInviters: appliesToSwap && inviterKeys.size === 0, - }; - }, [activePeerState]); - - const stackGate = useMemo(() => { - const reasons: string[] = []; - const okPromptd = Boolean(health?.ok); - if (!okPromptd) reasons.push('promptd offline'); - - const okChecklist = Boolean(preflight && typeof preflight === 'object'); - if (!okChecklist) reasons.push('checklist not run'); - - const scPort = (() => { - try { - const u = new URL(String(envInfo?.sc_bridge?.url || '').trim() || 'ws://127.0.0.1:49222'); - const p = u.port ? Number.parseInt(u.port, 10) : 0; - return Number.isFinite(p) && p > 0 ? p : 49222; - } catch (_e) { - return 49222; - } - })(); - const okPeer = Boolean( - preflight?.peer_status?.peers?.some?.((p: any) => Boolean(p?.alive) && Number(p?.sc_bridge?.port) === scPort) - ); - if (okChecklist && !okPeer) reasons.push('peer not running'); - - // Treat "connecting" as ok for gating so the UI doesn't hard-block on transient feed reconnects. - const okStream = Boolean(scConnected || scConnecting); - if (okChecklist && !okStream) reasons.push('sc/stream not connected'); - - const okLnChannels = Boolean(preflight?.ln_summary?.channels_active && Number(preflight.ln_summary.channels_active) > 0); - const okLn = okLnChannels && !preflight?.ln_listfunds_error && !lnWalletLocked; - if (okChecklist && !okLn) { - if (lnWalletLocked) reasons.push('Lightning wallet locked (unlock required)'); - else reasons.push('Lightning not ready (no channels)'); - } - - const solKind = String(preflight?.env?.solana?.classify?.kind || envInfo?.solana?.classify?.kind || ''); - const okSolRpc = solKind !== 'local' || Boolean(preflight?.sol_local_status?.rpc_listening); - const okSolSigner = Boolean(preflight?.sol_signer?.pubkey) && !preflight?.sol_signer_error; - const okSolConfig = !preflight?.sol_config_error; - const okSol = okSolRpc && okSolSigner && okSolConfig; - if (okChecklist && !okSol) reasons.push('Solana not ready'); - - const okReceipts = !preflight?.receipts_error; - if (okChecklist && !okReceipts) reasons.push('receipts not ready'); - - const okApp = Boolean(preflight?.app?.app_hash) && !preflight?.app_error; - if (okChecklist && !okApp) reasons.push('app binding missing'); - - const invitePolicyWarning = - okChecklist && invitePolicy.missingTrustedInviters - ? 'No trusted inviter keys are preloaded yet. First valid signed swap-invite join will auto-learn and persist the inviter key.' - : null; - - return { - ok: okPromptd && okChecklist && okPeer && okStream && okLn && okSol && okReceipts && okApp, - reasons, - okPromptd, - okChecklist, - okPeer, - okStream, - okLn, - lnWalletLocked, - okSol, - okReceipts, - okApp, - invitePolicyWarning, - }; - }, [health, preflight, scConnected, scConnecting, envInfo, invitePolicy.missingTrustedInviters, lnWalletLocked]); - - const stackAnyRunning = useMemo(() => { - try { - const scPort = (() => { - try { - const u = new URL(String(envInfo?.sc_bridge?.url || '').trim() || 'ws://127.0.0.1:49222'); - const p = u.port ? Number.parseInt(u.port, 10) : 0; - return Number.isFinite(p) && p > 0 ? p : 49222; - } catch (_e) { - return 49222; - } - })(); - // Only consider the peer that matches *this* promptd instance (sc_bridge.url). - const peerUp = Boolean( - preflight?.peer_status?.peers?.some?.((p: any) => Boolean(p?.alive) && Number(p?.sc_bridge?.port) === scPort) - ); - const solUp = Boolean(preflight?.sol_local_status?.alive) || Boolean(preflight?.sol_local_status?.rpc_listening); - const dockerUp = Array.isArray(preflight?.ln_docker_ps?.services) && preflight.ln_docker_ps.services.length > 0; - const lnUp = Boolean(preflight?.ln_summary?.channels_active && Number(preflight.ln_summary.channels_active) > 0) || dockerUp; - return peerUp || solUp || lnUp; - } catch (_e) { - return false; - } - }, [preflight, envInfo?.sc_bridge?.url]); - - async function fetchJson(path: string, init?: RequestInit) { - const res = await fetch(path, { - ...init, - headers: { - 'content-type': 'application/json', - ...(init?.headers || {}), - }, - }); - if (!res.ok) { - const text = await res.text().catch(() => ''); - throw new Error(`${res.status} ${res.statusText}${text ? `: ${text}` : ''}`); - } - return await res.json(); - } - - function setToolArgsBoth(obj: any) { - const o = obj && typeof obj === 'object' ? obj : {}; - setToolArgsObj(o as any); - setToolArgsText(JSON.stringify(o, null, 2)); - } - - async function runDirectToolOnce(name: string, args: any, { auto_approve = false } = {}) { - const prompt = JSON.stringify({ type: 'tool', name, arguments: args && typeof args === 'object' ? args : {} }); - const out = await fetchJson('/v1/run', { - method: 'POST', - body: JSON.stringify({ prompt, session_id: sessionId, auto_approve, dry_run: false }), - }); - if (out && typeof out === 'object') { - if (out.content_json !== undefined) return out.content_json; - if (typeof out.content === 'string') { - try { - return JSON.parse(out.content); - } catch (_e) {} - } - } - return out; - } - - async function runToolFinal(name: string, args: any, { auto_approve = true } = {}) { - const prompt = JSON.stringify({ type: 'tool', name, arguments: args && typeof args === 'object' ? args : {} }); - const out = await fetchJson('/v1/run', { - method: 'POST', - body: JSON.stringify({ prompt, session_id: sessionId, auto_approve, dry_run: false }), - }); - if (out && typeof out === 'object' && out.session_id) setSessionId(String(out.session_id)); - // Persist a synthetic "final" event so local outbox lists work even if sc/stream doesn't echo. - try { - const ts = Date.now(); - await appendPromptEvent( - { - type: 'final', - session_id: out?.session_id || sessionId, - content: out?.content ?? null, - content_json: out?.content_json ?? null, - steps: Array.isArray(out?.steps) ? out.steps.length : out?.steps ? 1 : 0, - ts, - }, - { persist: true } - ); - } catch (_e) {} - return out; - } - - async function stackStart() { - if (stackOpBusy) return; - setStackOpBusy(true); - setRunErr(null); - try { - pushToast('info', 'Starting stack (peer + LN + Solana). This can take ~1-2 minutes...', { ttlMs: 9000 }); - const sidechannels = rendezvousChannels.slice(0, 50); - - setRunMode('tool'); - setToolName('intercomswap_stack_start'); - setToolArgsBoth({ sidechannels }); - - const final = await runPromptStream({ - prompt: JSON.stringify({ type: 'tool', name: 'intercomswap_stack_start', arguments: { sidechannels } }), - session_id: sessionId, - auto_approve: true, - dry_run: false, - }); - - const cj = final && typeof final === 'object' ? (final as any).content_json : null; - const lnErr = cj && typeof cj === 'object' ? String((cj as any).ln_error || '').trim() : ''; - const solErr = cj && typeof cj === 'object' ? String((cj as any).solana_error || '').trim() : ''; - const tradeAutoErr = - cj && typeof cj === 'object' && (cj as any).trade_auto && typeof (cj as any).trade_auto === 'object' - ? String(((cj as any).trade_auto as any).error || '').trim() - : ''; - if (lnErr || solErr || tradeAutoErr) { - pushToast('error', `Stack started with issues:\n${lnErr ? `- LN: ${lnErr}\n` : ''}${solErr ? `- Solana: ${solErr}\n` : ''}${tradeAutoErr ? `- Trade worker: ${tradeAutoErr}` : ''}`.trim(), { - ttlMs: 12_000, - }); - } else if (final && typeof final === 'object' && String((final as any).type || '') === 'error') { - pushToast('error', String((final as any).error || 'stack start failed')); - } else { - pushToast('success', 'Stack started'); - } - - if (tradeAutoWorkerEnabled) { - try { - const channels = rendezvousChannels.slice(0, 64); - await runToolFinal( - 'intercomswap_tradeauto_start', - { - channels: channels.length > 0 ? channels : ['0000intercomswapbtcusdt'], - trace_enabled: tradeAutoTraceEnabled, - ln_liquidity_mode: 'aggregate', - enable_quote_from_offers: true, - // Safety default: only quote RFQs when they match a local Offer line. - enable_quote_from_rfqs: false, - enable_accept_quotes: true, - enable_invite_from_accepts: true, - enable_join_invites: true, - enable_settlement: true, - }, - { auto_approve: true } - ); - await runToolFinal('intercomswap_tradeauto_trace_set', { trace_enabled: tradeAutoTraceEnabled }, { auto_approve: true }); - } catch (_e) {} - } - - // Refresh status and auto-connect the sidechannel stream once SC-Bridge is up. - await refreshPreflight({ includeTradeAuto: true }); - if (!scConnected && !scConnecting && scStreamWantedRef.current) { - await new Promise((r) => setTimeout(r, 250)); - void startScStream(); - } - } finally { - setStackOpBusy(false); - } - } - - async function stackStop() { - if (stackOpBusy) return; - setStackOpBusy(true); - setRunErr(null); - try { - pushToast('info', 'Stopping stack...', { ttlMs: 6500 }); - stopScStream(); - - setRunMode('tool'); - setToolName('intercomswap_stack_stop'); - setToolArgsBoth({}); - - const final = await runPromptStream({ - prompt: JSON.stringify({ type: 'tool', name: 'intercomswap_stack_stop', arguments: {} }), - session_id: sessionId, - auto_approve: true, - dry_run: false, - }); - if (final && typeof final === 'object' && String((final as any).type || '') === 'error') { - pushToast('error', String((final as any).error || 'stack stop failed')); - } else { - pushToast('success', 'Stack stopped'); - } - await refreshPreflight(); - } finally { - setStackOpBusy(false); - } - } - - - async function setTradeWorkerMode(next: boolean) { - if (runBusy || stackOpBusy) return; - setRunBusy(true); - setRunErr(null); - try { - if (next) { - const channels = rendezvousChannels.slice(0, 64); - await runToolFinal( - 'intercomswap_tradeauto_start', - { - channels: channels.length > 0 ? channels : ['0000intercomswapbtcusdt'], - trace_enabled: tradeAutoTraceEnabled, - ln_liquidity_mode: 'aggregate', - enable_quote_from_offers: true, - // Safety default: only quote RFQs when they match a local Offer line. - enable_quote_from_rfqs: false, - enable_accept_quotes: true, - enable_invite_from_accepts: true, - enable_join_invites: true, - enable_settlement: true, - }, - { auto_approve: true } - ); - await runToolFinal('intercomswap_tradeauto_trace_set', { trace_enabled: tradeAutoTraceEnabled }, { auto_approve: true }); - } else { - await runToolFinal('intercomswap_tradeauto_stop', { reason: 'manual_stop' }, { auto_approve: true }); - } - pushToast('success', `Trade worker ${next ? 'enabled' : 'disabled'}`); - await refreshPreflight({ includeTradeAuto: true }); - } catch (e: any) { - const msg = e?.message || String(e); - setRunErr(msg); - pushToast('error', `Trade worker toggle failed: ${msg}`); - } finally { - setRunBusy(false); - } - } - - async function setTradeTraceMode(next: boolean) { - if (runBusy || stackOpBusy) return; - setRunBusy(true); - setRunErr(null); - try { - // Trace requires a running worker. If the worker is off, best-effort start it first. - if (next && !(tradeAutoStatus?.running === true)) { - const channels = rendezvousChannels.slice(0, 64); - await runToolFinal( - 'intercomswap_tradeauto_start', - { - channels: channels.length > 0 ? channels : ['0000intercomswapbtcusdt'], - trace_enabled: true, - ln_liquidity_mode: 'aggregate', - enable_quote_from_offers: true, - // Safety default: only quote RFQs when they match a local Offer line. - enable_quote_from_rfqs: false, - enable_accept_quotes: true, - enable_invite_from_accepts: true, - enable_join_invites: true, - enable_settlement: true, - }, - { auto_approve: true } - ); - } - await runToolFinal('intercomswap_tradeauto_trace_set', { trace_enabled: next }, { auto_approve: true }); - pushToast('success', `Trade trace ${next ? 'enabled' : 'disabled'}`); - await refreshPreflight({ includeTradeAuto: true }); - } catch (e: any) { - const msg = e?.message || String(e); - setRunErr(msg); - pushToast('error', `Trade trace toggle failed: ${msg}`); - } finally { - setRunBusy(false); - } - } - - function stackBlockedToast(actionLabel: string) { - const missing = stackGate.reasons.length > 0 ? stackGate.reasons.map((r) => `- ${r}`).join('\n') : '- unknown'; - pushToast('error', `${actionLabel}: stack not ready\n\nMissing:\n${missing}`); - } - - async function recoverClaimForTrade(trade: any) { - if (!stackGate.ok) return void stackBlockedToast('Claim'); - const trade_id = String(trade?.trade_id || '').trim(); - if (!trade_id) return void pushToast('error', 'Claim: missing trade_id'); - const state = String(trade?.state || '').trim(); - const preimage = String(trade?.ln_preimage_hex || '').trim(); - - if (state === 'claimed') return void pushToast('success', `Already claimed (${trade_id})`); - if (state === 'refunded') return void pushToast('info', `Already refunded (${trade_id})`); - if (isTerminalTrade(trade)) return void pushToast('info', `Trade already finalized (${trade_id})`); - if (state !== 'ln_paid') return void pushToast('info', `Not claimable yet (state=${state || '?'})`); - if (!preimage) return void pushToast('error', `Cannot claim: missing LN preimage in receipts (${trade_id})`); - - try { - if (toolRequiresApproval('intercomswap_swaprecover_claim') && !autoApprove) { - const ok = window.confirm(`Claim escrow now?\n\ntrade_id: ${trade_id}`); - if (!ok) return; - } - await runToolFinal( - 'intercomswap_swaprecover_claim', - { - ...receiptsDbArg, - trade_id, - ...(solCuLimit > 0 ? { cu_limit: solCuLimit } : {}), - ...(solCuPrice > 0 ? { cu_price: solCuPrice } : {}), - }, - { auto_approve: true } - ); - pushToast('success', `Claim submitted (${trade_id})`); - void loadTradesPage({ reset: true }); - void loadOpenClaimsPage({ reset: true }); - void loadOpenRefundsPage({ reset: true }); - } catch (err: any) { - pushToast('error', err?.message || String(err)); - } - } - - async function recoverRefundForTrade(trade: any) { - if (!stackGate.ok) return void stackBlockedToast('Refund'); - const trade_id = String(trade?.trade_id || '').trim(); - if (!trade_id) return void pushToast('error', 'Refund: missing trade_id'); - const state = String(trade?.state || '').trim(); - const refundAfterRaw = trade?.sol_refund_after_unix; - const refundAfter = - typeof refundAfterRaw === 'number' - ? refundAfterRaw - : typeof refundAfterRaw === 'string' && /^[0-9]+$/.test(refundAfterRaw.trim()) - ? Number.parseInt(refundAfterRaw.trim(), 10) - : null; - const nowSec = Math.floor(Date.now() / 1000); - - if (state === 'refunded') return void pushToast('success', `Already refunded (${trade_id})`); - if (state === 'claimed') return void pushToast('info', `Already claimed (${trade_id})`); - if (isTerminalTrade(trade)) return void pushToast('info', `Trade already finalized (${trade_id})`); - if (state !== 'escrow') return void pushToast('info', `Not refundable yet (state=${state || '?'})`); - if (!refundAfter || !Number.isFinite(refundAfter) || refundAfter <= 0) { - return void pushToast('info', `Refund not yet available (missing refund_after_unix) (${trade_id})`); - } - if (nowSec < refundAfter) { - return void pushToast( - 'info', - `Refund available after ${unixSecToUtcIso(refundAfter)} (${refundAfter}).\n\nWait: ${secToHuman(refundAfter - nowSec)}` - ); - } - - try { - if (toolRequiresApproval('intercomswap_swaprecover_refund') && !autoApprove) { - const ok = window.confirm(`Refund escrow now?\n\ntrade_id: ${trade_id}`); - if (!ok) return; - } - await runToolFinal( - 'intercomswap_swaprecover_refund', - { - ...receiptsDbArg, - trade_id, - ...(solCuLimit > 0 ? { cu_limit: solCuLimit } : {}), - ...(solCuPrice > 0 ? { cu_price: solCuPrice } : {}), - }, - { auto_approve: true } - ); - pushToast('success', `Refund submitted (${trade_id})`); - void loadTradesPage({ reset: true }); - void loadOpenRefundsPage({ reset: true }); - void loadOpenClaimsPage({ reset: true }); - } catch (err: any) { - pushToast('error', err?.message || String(err)); - } - } - - async function stopAutopostJob(nameRaw: string) { - const name = String(nameRaw || '').trim(); - if (!name) return; - try { - const jobs = Array.isArray((preflight as any)?.autopost?.jobs) ? (preflight as any).autopost.jobs : []; - const exists = jobs.some((j: any) => String(j?.name || '').trim() === name); - if (!exists) { - pushToast('info', `Bot already stopped or missing (${name})`); - return; - } - } catch (_e) {} - try { - if (toolRequiresApproval('intercomswap_autopost_stop') && !autoApprove) { - const ok = window.confirm(`Stop bot?\n\n${name}`); - if (!ok) return; - } - await runToolFinal('intercomswap_autopost_stop', { name }, { auto_approve: true }); - pushToast('success', `Bot stopped (${name})`); - void refreshPreflight(); - } catch (err: any) { - pushToast('error', err?.message || String(err)); - } - } - - async function ensureLnRegtestChannel() { - const lnBackend = String(envInfo?.ln?.backend || ''); - const lnNetwork = String(envInfo?.ln?.network || ''); - const isRegtestDocker = lnBackend === 'docker' && lnNetwork === 'regtest'; - if (!isRegtestDocker) { - pushToast('error', 'LN regtest bootstrap is only available in docker+regtest mode.'); - return; - } - if (runBusy || stackOpBusy) return; - const channels = Number(preflight?.ln_summary?.channels || 0); - const listfundsErr = String(preflight?.ln_listfunds_error || '').trim(); - if (channels > 0 && !listfundsErr) { - pushToast('success', 'Lightning channel already exists'); - return; - } - const ok = - autoApprove || - window.confirm( - 'Bootstrap LN regtest now?\n\nThis will mine blocks, fund both LN node wallets, and open a channel (docker-only).' - ); - if (!ok) return; - pushToast('info', 'Bootstrapping LN regtest (mine+fund+open). This can take ~1 minute...', { ttlMs: 9000 }); - const final = await runPromptStream({ - prompt: JSON.stringify({ type: 'tool', name: 'intercomswap_ln_regtest_init', arguments: {} }), - session_id: sessionId, - auto_approve: true, - dry_run: false, - }); - if (final && typeof final === 'object' && String((final as any).type || '') === 'error') { - pushToast('error', String((final as any).error || 'LN bootstrap failed')); - } else { - pushToast('success', 'Lightning ready'); - } - void refreshPreflight(); - } - - async function ensureSolLocalValidator() { - const solKind = String(envInfo?.solana?.classify?.kind || ''); - if (solKind !== 'local') { - pushToast('error', 'Local Solana bootstrap is only available when solana.rpc_url is localhost.'); - return; - } - if (runBusy || stackOpBusy) return; - const ok = - autoApprove || - window.confirm('Start local Solana validator now?\n\nThis will load the escrow program into solana-test-validator.'); - if (!ok) return; - pushToast('info', 'Starting local Solana validator...', { ttlMs: 9000 }); - const final = await runPromptStream({ - prompt: JSON.stringify({ type: 'tool', name: 'intercomswap_sol_local_start', arguments: {} }), - session_id: sessionId, - auto_approve: true, - dry_run: false, - }); - if (final && typeof final === 'object' && String((final as any).type || '') === 'error') { - pushToast('error', String((final as any).error || 'Solana local start failed')); - } else { - pushToast('success', 'Solana local validator ready'); - } - void refreshPreflight(); - } - - async function unlockLnWallet() { - const impl = String(envInfo?.ln?.impl || '').trim().toLowerCase(); - const backend = String(envInfo?.ln?.backend || '').trim().toLowerCase(); - if (impl !== 'lnd' || backend !== 'docker') { - pushToast('error', 'Unlock helper supports LND+docker only. Unlock your LN wallet in backend, then refresh BTC.'); - return; - } - if (runBusy || stackOpBusy) return; - const ok = - autoApprove || - window.confirm( - 'Unlock Lightning wallet now?\n\nThis uses ln.wallet_password_file from prompt setup, or inferred files under onchain/lnd// (maker.wallet-password.txt / taker.wallet-password.txt / wallet.pw).' - ); - if (!ok) return; - try { - await runToolFinal('intercomswap_ln_unlock', {}, { auto_approve: true }); - setLnFundingAddrErr(null); - pushToast('success', 'Lightning wallet unlocked'); - await refreshPreflight({ includeTradeAuto: tradeAutoTraceEnabled }); - } catch (err: any) { - pushToast('error', err?.message || String(err)); - } - } - - function ensureLnLiquidityForLines({ - role, - lines, - actionLabel, - lnSummaryOverride, - }: { - role: 'send' | 'receive'; - lines: Array<{ btc_sats: number }>; - actionLabel: string; - lnSummaryOverride?: any; - }): boolean { - const lnSummary = - lnSummaryOverride && typeof lnSummaryOverride === 'object' - ? lnSummaryOverride - : preflight && typeof preflight === 'object' - ? (preflight as any).ln_summary - : null; - const required = lines - .map((l) => Number(l?.btc_sats || 0)) - .filter((n) => Number.isInteger(n) && n > 0) - .sort((a, b) => b - a); - if (required.length < 1) return true; - const lnActiveChannelCountNow = Number((lnSummary as any)?.channels_active || 0); - if (lnActiveChannelCountNow < 1) { - pushToast('error', `${actionLabel}: no active Lightning channels`); - return false; - } - - const maxSingle = - role === 'send' - ? typeof (lnSummary as any)?.max_outbound_sats === 'number' - ? (lnSummary as any).max_outbound_sats - : null - : typeof (lnSummary as any)?.max_inbound_sats === 'number' - ? (lnSummary as any).max_inbound_sats - : null; - const total = - role === 'send' - ? typeof (lnSummary as any)?.total_outbound_sats === 'number' - ? (lnSummary as any).total_outbound_sats - : null - : typeof (lnSummary as any)?.total_inbound_sats === 'number' - ? (lnSummary as any).total_inbound_sats - : null; - const roleLabel = role === 'send' ? 'outbound' : 'inbound'; - const rawNeeded = required[0]; - const lnFeeBuffer = role === 'send' ? Math.max(LN_ROUTE_FEE_BUFFER_MIN_SATS, Math.ceil(rawNeeded * (LN_ROUTE_FEE_BUFFER_BPS / 10_000))) : 0; - const needed = rawNeeded + lnFeeBuffer; - - if (lnLiquidityMode === 'single_channel') { - if (typeof maxSingle !== 'number' || maxSingle < needed) { - pushToast( - 'error', - `${actionLabel}: insufficient LN ${roleLabel} liquidity (mode=single_channel).\nneed ${needed} sats (includes ${lnFeeBuffer} sats LN fee buffer), have max ${ - typeof maxSingle === 'number' ? `${maxSingle} sats` : 'unknown' - }.` - ); - return false; - } - return true; - } - - if (typeof total !== 'number' || total < needed) { - pushToast( - 'error', - `${actionLabel}: insufficient LN ${roleLabel} liquidity (mode=aggregate).\nneed ${needed} sats (includes ${lnFeeBuffer} sats LN fee buffer), have total ${ - typeof total === 'number' ? `${total} sats` : 'unknown' - }.` - ); - return false; - } - return true; - } - - function ensureOfferFundingForLines({ - lines, - maxTotalFeeBps, - actionLabel, - usdtAvailableOverrideAtomic, - }: { - lines: Array<{ btc_sats: number; usdt_amount: string }>; - maxTotalFeeBps: number; - actionLabel: string; - usdtAvailableOverrideAtomic?: bigint | null; - }): boolean { - const usdtAvailableAtomic = - typeof usdtAvailableOverrideAtomic === 'bigint' - ? usdtAvailableOverrideAtomic - : parseAtomicBigInt(walletUsdtAtomic) ?? parseAtomicBigInt((preflight as any)?.sol_usdt?.amount); - if (usdtAvailableAtomic === null) { - pushToast('error', `${actionLabel}: USDT wallet balance unavailable (refresh status first)`); - return false; - } - - const lamportsRaw = (preflight as any)?.sol_balance; - const lamportsNum = - typeof lamportsRaw === 'number' - ? lamportsRaw - : typeof lamportsRaw === 'string' && /^[0-9]+$/.test(lamportsRaw.trim()) - ? Number.parseInt(lamportsRaw.trim(), 10) - : typeof (solBalance as any)?.lamports === 'number' - ? Number((solBalance as any).lamports) - : null; - if (!Number.isFinite(lamportsNum as any) || Number(lamportsNum) < SOL_TX_FEE_BUFFER_LAMPORTS) { - pushToast( - 'error', - `${actionLabel}: low SOL for transaction fees (need at least ${SOL_TX_FEE_BUFFER_LAMPORTS} lamports buffer)` - ); - return false; - } - - const bps = Number.isFinite(maxTotalFeeBps) ? Math.max(0, Math.min(1500, Math.trunc(maxTotalFeeBps))) : 0; - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i]; - const usdtAtomic = parseAtomicBigInt(line?.usdt_amount); - if (usdtAtomic === null) continue; - const required = applyBpsCeilAtomic(usdtAtomic, bps); - if (required > usdtAvailableAtomic) { - pushToast( - 'error', - `${actionLabel}: line ${i + 1} exceeds USDT balance (need ${required.toString()} incl. fees, have ${usdtAvailableAtomic.toString()})` - ); - return false; - } - } - return true; - } - - async function fetchFreshTradeGuardrailSnapshot({ - needLn, - needUsdt, - }: { - needLn: boolean; - needUsdt: boolean; - }): Promise<{ lnSummary?: any; usdtAvailableAtomic?: bigint | null }> { - const out: { lnSummary?: any; usdtAvailableAtomic?: bigint | null } = {}; - const tasks: Promise[] = []; - - if (needLn) { - tasks.push( - (async () => { - try { - const [listfunds, listchannels] = await Promise.all([ - runDirectToolOnce('intercomswap_ln_listfunds', {}, { auto_approve: false }), - runDirectToolOnce('intercomswap_ln_listchannels', {}, { auto_approve: false }), - ]); - const impl = String((envInfo as any)?.ln?.impl || (preflight as any)?.env?.ln?.impl || (preflight as any)?.ln_info?.implementation || ''); - const lnSummary = summarizeLn(listfunds, listchannels, impl); - out.lnSummary = lnSummary; - setPreflight((prev: any) => - prev && typeof prev === 'object' - ? { - ...prev, - ln_listfunds: listfunds, - ln_channels: listchannels, - ln_summary: lnSummary, - } - : prev - ); - } catch (_e) {} - })() - ); - } - - if (needUsdt) { - tasks.push( - (async () => { - const owner = String(solSignerPubkey || '').trim(); - const mint = String(walletUsdtMint || '').trim(); - if (!owner || !mint) return; - try { - const bal = await runDirectToolOnce( - 'intercomswap_sol_token_balance', - { owner, mint }, - { auto_approve: false } - ); - const amountAtomic = parseAtomicBigInt((bal as any)?.amount); - if (amountAtomic === null) return; - const ata = String((bal as any)?.ata || '').trim() || null; - out.usdtAvailableAtomic = amountAtomic; - setWalletUsdtAta(ata); - setWalletUsdtAtomic(amountAtomic.toString()); - setWalletUsdtErr(null); - setPreflight((prev: any) => - prev && typeof prev === 'object' - ? { - ...prev, - sol_usdt: { - ...(prev?.sol_usdt && typeof prev.sol_usdt === 'object' ? prev.sol_usdt : {}), - ata, - amount: amountAtomic.toString(), - }, - } - : prev - ); - } catch (_e) {} - })() - ); - } - - if (tasks.length > 0) await Promise.all(tasks); - return out; - } - - function adoptOfferIntoRfqDraft(offerEvt: any) { - try { - const msg = offerEvt?.message; - const body = msg?.body; - const offers = Array.isArray(body?.offers) ? body.offers : []; - if (offers.length < 1) throw new Error('Offer has no offers[]'); - - const rfqChans = Array.isArray(body?.rfq_channels) - ? body.rfq_channels.map((c: any) => String(c || '').trim()).filter(Boolean) - : []; - const channelRaw = - rfqChans[0] || String(offerEvt?.channel || '').trim() || rendezvousChannels[0] || '0000intercomswapbtcusdt'; - const channel = isSwapTradeChannelName(channelRaw) - ? (rendezvousChannels[0] || '0000intercomswapbtcusdt') - : channelRaw; - - // Adopt all offer lines (max 20). Each RFQ line has its own trade_id so multiple can run in parallel. - const adoptedLines: RfqLine[] = []; - const now = Date.now(); - for (let i = 0; i < offers.length && adoptedLines.length < 20; i += 1) { - const o = offers[i] && typeof offers[i] === 'object' ? offers[i] : null; - if (!o) continue; - const btcSats = Number((o as any)?.btc_sats); - const usdtAmount = String((o as any)?.usdt_amount || '').trim(); - if (!Number.isInteger(btcSats) || btcSats < 1) continue; - if (!/^[0-9]+$/.test(usdtAmount)) continue; - adoptedLines.push({ - id: `rfqline-${now}-${i}-${Math.random().toString(16).slice(2)}`, - trade_id: `rfq-${now}-${i}-${Math.random().toString(16).slice(2, 10)}`, - btc_sats: btcSats, - usdt_amount: usdtAmount, - }); - } - if (adoptedLines.length < 1) throw new Error('Offer has no valid lines (btc_sats/usdt_amount)'); - - const o0 = offers[0] && typeof offers[0] === 'object' ? offers[0] : null; - const maxTrade = Number((o0 as any)?.max_trade_fee_bps); - const maxTotal = Number((o0 as any)?.max_total_fee_bps); - const minWin = Number((o0 as any)?.min_sol_refund_window_sec); - const maxWin = Number((o0 as any)?.max_sol_refund_window_sec); - - const nowSec = Math.floor(Date.now() / 1000); - const offerUntil = Number(body?.valid_until_unix); - const until = Number.isFinite(offerUntil) && offerUntil > nowSec + 60 ? Math.trunc(offerUntil) : nowSec + 24 * 3600; - - setRfqChannel(channel); - setRfqLines(adoptedLines); - const SOL_REFUND_MIN_SEC = 3600; - const SOL_REFUND_MAX_SEC = 7 * 24 * 3600; - - const warnings: string[] = []; - const clampBps = (raw: number, cap: number, label: string) => { - const n = Math.trunc(raw); - const c = Math.max(0, Math.min(cap, n)); - if (c !== n) warnings.push(`${label} fee cap clamped: ${n} -> ${c} bps`); - return c; - }; - const clampSec = (raw: number, label: string) => { - const n = Math.trunc(raw); - const c = Math.min(SOL_REFUND_MAX_SEC, Math.max(SOL_REFUND_MIN_SEC, n)); - if (c !== n) warnings.push(`${label} sol window clamped: ${n} -> ${c} sec`); - return c; - }; - - if (Number.isFinite(maxTrade)) setRfqMaxTradeFeeBps(clampBps(maxTrade, 1000, 'trade')); - if (Number.isFinite(maxTotal)) setRfqMaxTotalFeeBps(clampBps(maxTotal, 1500, 'total')); - - let nextMinWin = Number.isFinite(minWin) ? clampSec(minWin, 'min') : rfqMinSolRefundWindowSec; - let nextMaxWin = Number.isFinite(maxWin) ? clampSec(maxWin, 'max') : rfqMaxSolRefundWindowSec; - if (nextMinWin > nextMaxWin) { - warnings.push(`sol window invalid (min > max); adjusted max to ${nextMinWin}s`); - nextMaxWin = nextMinWin; - } - setRfqMinSolRefundWindowSec(nextMinWin); - setRfqMaxSolRefundWindowSec(nextMaxWin); - setRfqValidUntilUnix(until); - - setActiveTab('sell_btc'); - if (warnings.length > 0) pushToast('info', warnings.join('\n'), { ttlMs: 10_000 }); - pushToast('info', `Offer loaded into New RFQ (${adoptedLines.length} line${adoptedLines.length === 1 ? '' : 's'}). Review then click “Post RFQ”.`, { ttlMs: 6500 }); - } catch (e: any) { - pushToast('error', e?.message || String(e)); - } - } - - function rotateRfqDraftTradeIds() { - setRfqLines((prev) => { - const now = Date.now(); - return prev.map((l, i) => ({ - ...l, - id: `rfq-${now}-${i}`, - trade_id: `rfq-${now}-${i}-${Math.random().toString(16).slice(2, 10)}`, - })); - }); - } - - function isSoftAutopostStopReason(reason: string) { - const r = String(reason || '').trim().toLowerCase(); - return r === 'filled' || r === 'expired'; - } - - async function postOffer() { - if (offerBusy) return; - if (!stackGate.ok) return void stackBlockedToast('Post offer'); - - const SOL_REFUND_MIN_SEC = 3600; // 1h - const SOL_REFUND_MAX_SEC = 7 * 24 * 3600; // 1w - - const name = offerName.trim(); - - const lines = Array.isArray(offerLines) ? offerLines : []; - if (lines.length < 1) return void pushToast('error', 'Offer must include at least 1 line'); - if (lines.length > 20) return void pushToast('error', 'Offer has too many lines (max 20)'); - for (let i = 0; i < lines.length; i += 1) { - const l = lines[i]; - const btc = Number((l as any)?.btc_sats); - const usdt = String((l as any)?.usdt_amount || '').trim(); - if (!Number.isInteger(btc) || btc < 1) return void pushToast('error', `Offer line ${i + 1}: BTC must be >= 1 sat`); - if (!/^[0-9]+$/.test(usdt)) return void pushToast('error', `Offer line ${i + 1}: USDT must be a base-unit integer`); - } - const offerActionLabel = offerRunAsBot ? 'Start offer bot' : 'Post offer'; - const freshSnapshot = await fetchFreshTradeGuardrailSnapshot({ needLn: true, needUsdt: true }); - if (!ensureLnLiquidityForLines({ role: 'receive', lines, actionLabel: offerActionLabel, lnSummaryOverride: freshSnapshot.lnSummary })) return; - if ( - !ensureOfferFundingForLines({ - lines, - maxTotalFeeBps: offerMaxTotalFeeBps, - actionLabel: offerActionLabel, - usdtAvailableOverrideAtomic: freshSnapshot.usdtAvailableAtomic, - }) - ) { - return; - } - - if (offerMaxPlatformFeeBps + offerMaxTradeFeeBps > offerMaxTotalFeeBps) { - return void pushToast('error', 'Fee caps invalid: total must be >= platform + trade'); - } - if (offerMinSolRefundWindowSec > offerMaxSolRefundWindowSec) { - return void pushToast('error', 'Solana refund window invalid: min must be <= max'); - } - if ( - !Number.isInteger(offerMinSolRefundWindowSec) || - offerMinSolRefundWindowSec < SOL_REFUND_MIN_SEC || - offerMinSolRefundWindowSec > SOL_REFUND_MAX_SEC - ) { - return void pushToast('error', `Solana refund window invalid: min must be ${SOL_REFUND_MIN_SEC}s..${SOL_REFUND_MAX_SEC}s`); - } - if ( - !Number.isInteger(offerMaxSolRefundWindowSec) || - offerMaxSolRefundWindowSec < SOL_REFUND_MIN_SEC || - offerMaxSolRefundWindowSec > SOL_REFUND_MAX_SEC - ) { - return void pushToast('error', `Solana refund window invalid: max must be ${SOL_REFUND_MIN_SEC}s..${SOL_REFUND_MAX_SEC}s`); - } - const nowSec = Math.floor(Date.now() / 1000); - if (!Number.isInteger(offerValidUntilUnix) || offerValidUntilUnix <= nowSec) { - return void pushToast('error', 'Expiry must be in the future'); - } - - const channels = rendezvousChannels.slice(0, 20); - if (channels.length < 1) return void pushToast('error', 'No rendezvous channels configured'); - - const autoName = - name || - (localPeerPubkeyHex - ? `maker:${localPeerPubkeyHex.slice(0, 8)}` - : `maker:${Math.random().toString(16).slice(2, 10)}`); - - if (toolRequiresApproval('intercomswap_offer_post') && !autoApprove) { - const first = lines[0]; - const ok = window.confirm( - `Post offer now?\n\nchannels: ${channels.join(', ')}\nlines: ${lines.length}\nline1 BTC: ${first?.btc_sats} sats\nline1 USDT: ${first?.usdt_amount}` - ); - if (!ok) return; - } - - setOfferBusy(true); - try { - const baseArgs = { - channels, - name: autoName, - rfq_channels: channels, - offers: lines.map((l) => ({ - pair: 'BTC_LN/USDT_SOL', - have: 'USDT_SOL', - want: 'BTC_LN', - btc_sats: Number((l as any)?.btc_sats) || 0, - usdt_amount: String((l as any)?.usdt_amount || ''), - max_platform_fee_bps: offerMaxPlatformFeeBps, - max_trade_fee_bps: offerMaxTradeFeeBps, - max_total_fee_bps: offerMaxTotalFeeBps, - min_sol_refund_window_sec: offerMinSolRefundWindowSec, - max_sol_refund_window_sec: offerMaxSolRefundWindowSec, - })), - }; - - if (!offerRunAsBot) { - const args = { ...baseArgs, valid_until_unix: offerValidUntilUnix }; - const out = await runToolFinal('intercomswap_offer_post', args, { auto_approve: true }); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && cj.type === 'error') throw new Error(String(cj.error || 'offer_post failed')); - const id = String(cj?.svc_announce_id || '').trim(); - pushToast('success', `Offer posted${id ? ` (${id.slice(0, 12)}…)` : ''}`); - } else { - const nowSec = Math.floor(Date.now() / 1000); - const ttlSec = Math.max(10, Math.min(7 * 24 * 3600, Math.trunc(offerValidUntilUnix - nowSec))); - const safeLabel = autoName.replaceAll(/[^A-Za-z0-9._-]/g, '_').slice(0, 28); - const botName = `offer_${safeLabel}_${Date.now()}`.slice(0, 64); - - if (toolRequiresApproval('intercomswap_autopost_start') && !autoApprove) { - const ok = window.confirm(`Start offer bot now?\n\nname: ${botName}\ninterval_sec: ${offerBotIntervalSec}\nttl_sec: ${ttlSec}`); - if (!ok) return; - } - - const out = await runToolFinal( - 'intercomswap_autopost_start', - { - name: botName, - tool: 'intercomswap_offer_post', - interval_sec: offerBotIntervalSec, - ttl_sec: ttlSec, - valid_until_unix: offerValidUntilUnix, - args: baseArgs, - }, - { auto_approve: true } - ); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && cj.type === 'error') throw new Error(String(cj.error || 'autopost_start failed')); - if (cj && typeof cj === 'object' && String((cj as any).type || '') === 'autopost_stopped') { - const reason = String((cj as any).reason || 'stopped'); - if (isSoftAutopostStopReason(reason)) { - pushToast('info', `Offer bot ended immediately (${botName}): ${reason}`); - } else { - pushToast('error', `Offer bot not started (${botName}): ${reason}`); - } - } else { - pushToast('success', `Offer bot started (${botName})`); - } - void refreshPreflight(); - } - } catch (e: any) { - pushToast('error', e?.message || String(e)); - } finally { - setOfferBusy(false); - } - } - - async function postRfq() { - if (rfqBusy) return; - if (!stackGate.ok) return void stackBlockedToast('Post RFQ'); - - const SOL_REFUND_MIN_SEC = 3600; // 1h - const SOL_REFUND_MAX_SEC = 7 * 24 * 3600; // 1w - - const channel = rfqChannel.trim() || rendezvousChannels[0] || ''; - if (!channel) return void pushToast('error', 'RFQ channel is required'); - const solRecipient = String(solSignerPubkey || '').trim(); - if (!solRecipient) return void pushToast('error', 'Solana signer pubkey unavailable (cannot set RFQ sol_recipient)'); - - const lines = Array.isArray(rfqLines) ? rfqLines : []; - if (lines.length < 1) return void pushToast('error', 'RFQ must include at least 1 line'); - if (lines.length > 20) return void pushToast('error', 'RFQ has too many lines (max 20)'); - for (let i = 0; i < lines.length; i += 1) { - const l = lines[i]; - const trade_id = String(l?.trade_id || '').trim(); - if (!trade_id) return void pushToast('error', `RFQ line ${i + 1}: trade_id missing`); - if (!/^[A-Za-z0-9_.:-]+$/.test(trade_id)) return void pushToast('error', `RFQ line ${i + 1}: trade_id invalid`); - const btc = Number((l as any)?.btc_sats); - const usdt = String((l as any)?.usdt_amount || '').trim(); - if (!Number.isInteger(btc) || btc < 1) return void pushToast('error', `RFQ line ${i + 1}: BTC must be >= 1 sat`); - if (!/^[0-9]+$/.test(usdt)) return void pushToast('error', `RFQ line ${i + 1}: USDT must be a base-unit integer`); - } - const rfqActionLabel = rfqRunAsBot ? 'Start RFQ bot' : 'Post RFQ'; - const freshSnapshot = await fetchFreshTradeGuardrailSnapshot({ needLn: true, needUsdt: false }); - if (!ensureLnLiquidityForLines({ role: 'send', lines, actionLabel: rfqActionLabel, lnSummaryOverride: freshSnapshot.lnSummary })) return; - if (!Number.isFinite(solLamportsAvailable as any) || Number(solLamportsAvailable) < SOL_TX_FEE_BUFFER_LAMPORTS) { - return void pushToast( - 'error', - `Post RFQ: low SOL for claim/refund transactions (need at least ${SOL_TX_FEE_BUFFER_LAMPORTS} lamports buffer)` - ); - } - - if (rfqMaxPlatformFeeBps + rfqMaxTradeFeeBps > rfqMaxTotalFeeBps) { - return void pushToast('error', 'Fee caps invalid: total must be >= platform + trade'); - } - if (rfqMinSolRefundWindowSec > rfqMaxSolRefundWindowSec) { - return void pushToast('error', 'Solana refund window invalid: min must be <= max'); - } - if ( - !Number.isInteger(rfqMinSolRefundWindowSec) || - rfqMinSolRefundWindowSec < SOL_REFUND_MIN_SEC || - rfqMinSolRefundWindowSec > SOL_REFUND_MAX_SEC - ) { - return void pushToast('error', `Solana refund window invalid: min must be ${SOL_REFUND_MIN_SEC}s..${SOL_REFUND_MAX_SEC}s`); - } - if ( - !Number.isInteger(rfqMaxSolRefundWindowSec) || - rfqMaxSolRefundWindowSec < SOL_REFUND_MIN_SEC || - rfqMaxSolRefundWindowSec > SOL_REFUND_MAX_SEC - ) { - return void pushToast('error', `Solana refund window invalid: max must be ${SOL_REFUND_MIN_SEC}s..${SOL_REFUND_MAX_SEC}s`); - } - const nowSec = Math.floor(Date.now() / 1000); - if (!Number.isInteger(rfqValidUntilUnix) || rfqValidUntilUnix <= nowSec) { - return void pushToast('error', 'Expiry must be in the future'); - } - - if (toolRequiresApproval('intercomswap_rfq_post') && !autoApprove) { - const first = lines[0]; - const ok = window.confirm( - `Post ${lines.length} RFQ${lines.length === 1 ? '' : 's'} now?\n\nchannel: ${channel}\nfirst.trade_id: ${String(first?.trade_id || '')}\nfirst.BTC: ${Number(first?.btc_sats || 0)} sats\nfirst.USDT: ${String(first?.usdt_amount || '')}` - ); - if (!ok) return; - } - - setRfqBusy(true); - try { - const baseArgs = { - channel, - sol_recipient: solRecipient, - max_platform_fee_bps: rfqMaxPlatformFeeBps, - max_trade_fee_bps: rfqMaxTradeFeeBps, - max_total_fee_bps: rfqMaxTotalFeeBps, - min_sol_refund_window_sec: rfqMinSolRefundWindowSec, - max_sol_refund_window_sec: rfqMaxSolRefundWindowSec, - }; - - if (!rfqRunAsBot) { - let okCount = 0; - let firstId = ''; - for (let i = 0; i < lines.length; i += 1) { - const l = lines[i]; - const args = { - ...baseArgs, - trade_id: String(l.trade_id), - btc_sats: Number(l.btc_sats), - usdt_amount: String(l.usdt_amount), - valid_until_unix: rfqValidUntilUnix, - ln_liquidity_mode: lnLiquidityMode, - }; - const out = await runToolFinal('intercomswap_rfq_post', args, { auto_approve: true }); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && cj.type === 'error') throw new Error(String(cj.error || 'rfq_post failed')); - const id = String(cj?.rfq_id || '').trim(); - if (!firstId && id) firstId = id; - okCount += 1; - } - pushToast( - 'success', - `RFQ${okCount === 1 ? '' : 's'} posted: ${okCount}${firstId ? ` (first ${firstId.slice(0, 12)}…)` : ''}` - ); - // Always rotate trade ids so the next post is a brand new RFQ set. - rotateRfqDraftTradeIds(); - } else { - const nowSec = Math.floor(Date.now() / 1000); - const ttlSec = Math.max(10, Math.min(7 * 24 * 3600, Math.trunc(rfqValidUntilUnix - nowSec))); - let okCount = 0; - let softStopCount = 0; - for (let i = 0; i < lines.length; i += 1) { - const l = lines[i]; - const trade_id = String(l.trade_id).trim(); - const safeLabel = trade_id.replaceAll(/[^A-Za-z0-9._-]/g, '_').slice(0, 30); - const botName = `rfq_${safeLabel}_${Date.now()}_${i + 1}`.slice(0, 64); - - if (toolRequiresApproval('intercomswap_autopost_start') && !autoApprove) { - const ok = window.confirm(`Start RFQ bot now?\n\nname: ${botName}\ninterval_sec: ${rfqBotIntervalSec}\nttl_sec: ${ttlSec}`); - if (!ok) return; - } - - const subArgs = { - ...baseArgs, - trade_id, - btc_sats: Number(l.btc_sats), - usdt_amount: String(l.usdt_amount), - ln_liquidity_mode: lnLiquidityMode, - }; - - const out = await runToolFinal( - 'intercomswap_autopost_start', - { - name: botName, - tool: 'intercomswap_rfq_post', - interval_sec: rfqBotIntervalSec, - ttl_sec: ttlSec, - valid_until_unix: rfqValidUntilUnix, - args: subArgs, - }, - { auto_approve: true } - ); - const cj = out?.content_json; - if (cj && typeof cj === 'object' && cj.type === 'error') throw new Error(String(cj.error || 'autopost_start failed')); - if (cj && typeof cj === 'object' && String((cj as any).type || '') === 'autopost_stopped') { - const reason = String((cj as any).reason || 'stopped'); - if (isSoftAutopostStopReason(reason)) { - softStopCount += 1; - continue; - } - throw new Error(`RFQ bot not started (${botName}): ${reason}`); - } - okCount += 1; - } - if (okCount > 0) pushToast('success', `RFQ bot${okCount === 1 ? '' : 's'} started: ${okCount}`); - if (softStopCount > 0) { - pushToast('info', `RFQ bot skipped ${softStopCount} line${softStopCount === 1 ? '' : 's'} (already filled/expired).`); - } - if (okCount > 0 || softStopCount > 0) { - // Rotate draft ids in bot mode too, so repeated starts do not reuse stale trade_ids. - rotateRfqDraftTradeIds(); - } - void refreshPreflight(); - } - } catch (e: any) { - pushToast('error', e?.message || String(e)); - } finally { - setRfqBusy(false); - } - } - - function validateToolArgs(tool: any, args: any): string[] { - if (!tool) return ['Tools not loaded (click Reload tools).']; - if (!args || typeof args !== 'object') return ['Arguments must be an object.']; - const params = tool?.parameters; - const props: Record = - params?.properties && typeof params.properties === 'object' ? (params.properties as any) : {}; - const reqList: string[] = Array.isArray(params?.required) ? params.required.map((v: any) => String(v)) : []; - const req = new Set(reqList); - const errs: string[] = []; - - for (const k of req) { - const v = (args as any)[k]; - const sch = (props as any)[k] || {}; - if (v === undefined || v === null) { - errs.push(`${k}: required`); - continue; - } - if (typeof v === 'string' && !v.trim()) { - errs.push(`${k}: required`); - continue; - } - if (Array.isArray(v) && typeof sch?.minItems === 'number' && v.length < sch.minItems) { - errs.push(`${k}: must have at least ${sch.minItems} item(s)`); - continue; - } - } - - for (const [k, v] of Object.entries(args || {})) { - const sch: any = (props as any)[k]; - if (!sch || typeof sch !== 'object') continue; - if (Array.isArray(sch.anyOf)) continue; // too complex; server validates - - const t = sch.type; - if (Array.isArray(sch.enum) && sch.enum.length > 0) { - const ok = sch.enum.some((ev: any) => String(ev) === String(v)); - if (!ok) errs.push(`${k}: must be one of ${sch.enum.map((x: any) => JSON.stringify(x)).join(', ')}`); - } - - if (t === 'string') { - if (typeof v !== 'string') { - errs.push(`${k}: must be a string`); - continue; - } - const s = v.trim(); - if (typeof sch.minLength === 'number' && s.length < sch.minLength) errs.push(`${k}: too short (min ${sch.minLength})`); - if (typeof sch.maxLength === 'number' && s.length > sch.maxLength) errs.push(`${k}: too long (max ${sch.maxLength})`); - if (typeof sch.pattern === 'string') { - try { - const re = new RegExp(sch.pattern); - if (!re.test(s)) errs.push(`${k}: invalid format`); - } catch (_e) { - // ignore invalid regex from schema - } - } - } else if (t === 'integer') { - if (typeof v !== 'number' || !Number.isInteger(v)) { - errs.push(`${k}: must be an integer`); - continue; - } - if (typeof sch.minimum === 'number' && v < sch.minimum) errs.push(`${k}: must be >= ${sch.minimum}`); - if (typeof sch.maximum === 'number' && v > sch.maximum) errs.push(`${k}: must be <= ${sch.maximum}`); - } else if (t === 'boolean') { - if (typeof v !== 'boolean') errs.push(`${k}: must be true/false`); - } else if (t === 'array') { - if (!Array.isArray(v)) { - errs.push(`${k}: must be an array`); - continue; - } - if (typeof sch.minItems === 'number' && v.length < sch.minItems) errs.push(`${k}: must have >= ${sch.minItems} item(s)`); - if (typeof sch.maxItems === 'number' && v.length > sch.maxItems) errs.push(`${k}: must have <= ${sch.maxItems} item(s)`); - } else if (t === 'object') { - if (!v || typeof v !== 'object' || Array.isArray(v)) errs.push(`${k}: must be an object`); - } - } - - return errs; - } - - async function refreshHealth() { - try { - const out = await fetchJson('/healthz', { method: 'GET', headers: {} }); - setHealth({ ok: Boolean(out?.ok), ts: Date.now() }); - } catch (_e) { - setHealth({ ok: false, ts: Date.now() }); - } - } - - async function refreshTools() { - try { - const out = await fetchJson('/v1/tools', { method: 'GET' }); - const list = normalizeToolList(out); - setTools(list); - if (!toolName && list.length > 0) setToolName(list[0].name); - } catch (err: any) { - setTools(null); - void appendPromptEvent( - { type: 'ui', ts: Date.now(), message: `tools fetch failed (promptd offline?): ${err?.message || String(err)}` }, - { persist: false } - ); - } - } - - const preflightBusyRef = useRef(false); - const preflightRunSeqRef = useRef(0); - useEffect(() => { - preflightBusyRef.current = preflightBusy; - }, [preflightBusy]); - - function summarizeLn(listfunds: any, listchannels: any, implRaw: string) { - try { - const impl = String(implRaw || '').trim().toLowerCase(); - if (!listfunds || typeof listfunds !== 'object') return { ok: false, channels: 0 }; - - const parseMsat = (v: any): bigint | null => { - if (v === null || v === undefined) return null; - if (typeof v === 'bigint') return v; - if (typeof v === 'number') return Number.isFinite(v) ? BigInt(Math.trunc(v)) : null; - if (typeof v === 'object') { - for (const k of ['msat', 'amount_msat', 'to_us_msat', 'to_them_msat', 'spendable_msat', 'receivable_msat']) { - if ((v as any)[k] !== undefined) { - const r = parseMsat((v as any)[k]); - if (r !== null) return r; - } - } - for (const k of ['sat', 'amount_sat']) { - if ((v as any)[k] !== undefined) { - const r = parseSats((v as any)[k]); - if (r !== null) return r * 1000n; - } - } - return null; - } - const s = String(v).trim().toLowerCase(); - if (!s) return null; - const m = s.match(/^([0-9]+)(msat|sat)?$/); - if (!m) return null; - const n = BigInt(m[1]); - const unit = m[2] || 'msat'; - return unit === 'sat' ? n * 1000n : n; - }; - - const parseSats = (v: any): bigint | null => { - if (v === null || v === undefined) return null; - if (typeof v === 'bigint') return v; - if (typeof v === 'number') return Number.isFinite(v) ? BigInt(Math.trunc(v)) : null; - if (typeof v === 'object') { - for (const k of ['sat', 'amount_sat', 'capacity', 'local_balance', 'remote_balance']) { - if ((v as any)[k] !== undefined) { - const r = parseSats((v as any)[k]); - if (r !== null) return r; - } - } - for (const k of ['msat', 'amount_msat']) { - if ((v as any)[k] !== undefined) { - const r = parseMsat((v as any)[k]); - if (r !== null) return r / 1000n; - } - } - return null; - } - const s = String(v).trim().toLowerCase(); - if (!s) return null; - const m = s.match(/^([0-9]+)(sat|msat)?$/); - if (!m) return null; - const n = BigInt(m[1]); - const unit = m[2] || 'sat'; - return unit === 'msat' ? n / 1000n : n; - }; - - const toSafe = (bn: bigint | null): number | null => { - if (bn === null) return null; - const max = BigInt(Number.MAX_SAFE_INTEGER); - if (bn < 0n || bn > max) return null; - return Number(bn); - }; - - const rows: Array<{ - id: string; - chan_id: string; - peer: string; - state: string; - active: boolean; - private: boolean; - capacity_sats: number | null; - local_sats: number | null; - remote_sats: number | null; - funding_txid: string; - channel_point: string; - }> = []; - - const clnChannels = Array.isArray((listchannels as any)?.channels) - ? (listchannels as any).channels - : Array.isArray((listfunds as any)?.channels) - ? (listfunds as any).channels - : []; - const lndChannels = Array.isArray((listchannels as any)?.channels) - ? (listchannels as any).channels - : Array.isArray((listfunds as any)?.channels?.channels) - ? (listfunds as any).channels.channels - : []; - - if (impl === 'lnd') { - for (const ch of lndChannels) { - const local = parseSats((ch as any)?.local_balance) ?? 0n; - const remote = parseSats((ch as any)?.remote_balance) ?? 0n; - const cap = parseSats((ch as any)?.capacity) ?? local + remote; - rows.push({ - id: String((ch as any)?.channel_point || (ch as any)?.chan_id || '').trim(), - chan_id: String((ch as any)?.chan_id || '').trim(), - peer: String((ch as any)?.remote_pubkey || '').trim().toLowerCase(), - state: (ch as any)?.active ? 'active' : 'inactive', - active: Boolean((ch as any)?.active), - private: Boolean((ch as any)?.private), - capacity_sats: toSafe(cap), - local_sats: toSafe(local), - remote_sats: toSafe(remote), - funding_txid: (() => { - const cp = String((ch as any)?.channel_point || '').trim(); - const m = cp.match(/^([0-9a-f]{64}):\d+$/i); - if (m) return String(m[1] || '').toLowerCase(); - return ''; - })(), - channel_point: String((ch as any)?.channel_point || '').trim(), - }); - } - const pendingRaw = (listfunds as any)?.pending; - const pendingOpen = Array.isArray((pendingRaw as any)?.pending_open_channels) - ? (pendingRaw as any).pending_open_channels - : []; - for (const p of pendingOpen) { - const chan = (p as any)?.channel && typeof (p as any).channel === 'object' ? (p as any).channel : {}; - const cp = String((chan as any)?.channel_point || '').trim(); - const cap = parseSats((chan as any)?.capacity); - const local = parseSats((chan as any)?.local_balance); - const remote = parseSats((chan as any)?.remote_balance); - const txid = (() => { - const m = cp.match(/^([0-9a-f]{64}):\d+$/i); - return m ? String(m[1] || '').toLowerCase() : ''; - })(); - const id = cp || txid || `pending:${String((chan as any)?.remote_node_pub || '').trim().toLowerCase()}`; - rows.push({ - id, - chan_id: '', - peer: String((chan as any)?.remote_node_pub || '').trim().toLowerCase(), - state: 'pending_open', - active: false, - private: Boolean((chan as any)?.private), - capacity_sats: toSafe(cap), - local_sats: toSafe(local), - remote_sats: toSafe(remote), - funding_txid: txid, - channel_point: cp, - }); - } - } else { - for (const ch of clnChannels) { - const state = String((ch as any)?.state || '').trim(); - const active = state === 'CHANNELD_NORMAL'; - const localMsat = - parseMsat((ch as any)?.spendable_msat) ?? - parseMsat((ch as any)?.to_us_msat) ?? - parseMsat((ch as any)?.our_amount_msat) ?? - 0n; - const amountMsat = parseMsat((ch as any)?.total_msat) ?? parseMsat((ch as any)?.amount_msat); - const remoteMsat = parseMsat((ch as any)?.receivable_msat) ?? parseMsat((ch as any)?.to_them_msat) ?? (amountMsat !== null ? amountMsat - localMsat : 0n); - const capMsat = amountMsat ?? localMsat + remoteMsat; - const fundingTxid = String((ch as any)?.funding_txid || '').trim().toLowerCase(); - const fundingOutnum = Number.isInteger((ch as any)?.funding_outnum) ? (ch as any).funding_outnum : null; - const idFromFunding = fundingTxid && fundingOutnum !== null ? `${fundingTxid}:${fundingOutnum}` : ''; - rows.push({ - id: String((ch as any)?.channel_id || (ch as any)?.short_channel_id || idFromFunding || (ch as any)?.peer_id || '').trim(), - chan_id: '', - peer: String((ch as any)?.peer_id || '').trim().toLowerCase(), - state, - active, - private: Boolean((ch as any)?.private), - capacity_sats: toSafe(capMsat / 1000n), - local_sats: toSafe(localMsat / 1000n), - remote_sats: toSafe(remoteMsat / 1000n), - funding_txid: fundingTxid, - channel_point: idFromFunding, - }); - } - } - - let walletSats: number | null = null; - let walletConfirmedSats: number | null = null; - let walletUnconfirmedSats: number | null = null; - let walletLockedSats: number | null = null; - let walletReservedAnchorSats: number | null = null; - if (impl === 'lnd') { - const w = (listfunds as any).wallet; - const confirmed = w && typeof w === 'object' ? Number.parseInt(String((w as any).confirmed_balance || '0'), 10) : 0; - const unconfirmed = w && typeof w === 'object' ? Number.parseInt(String((w as any).unconfirmed_balance || '0'), 10) : 0; - const locked = w && typeof w === 'object' ? Number.parseInt(String((w as any).locked_balance || '0'), 10) : 0; - const reservedAnchor = - w && typeof w === 'object' ? Number.parseInt(String((w as any).reserved_balance_anchor_chan || '0'), 10) : 0; - const conf = Number.isFinite(confirmed) ? Math.max(0, Math.trunc(confirmed)) : 0; - const unconf = Number.isFinite(unconfirmed) ? Math.max(0, Math.trunc(unconfirmed)) : 0; - const lock = Number.isFinite(locked) ? Math.max(0, Math.trunc(locked)) : 0; - const reserve = Number.isFinite(reservedAnchor) ? Math.max(0, Math.trunc(reservedAnchor)) : 0; - const spendable = Math.max(0, conf - lock - reserve); - walletSats = spendable; - walletConfirmedSats = conf; - walletUnconfirmedSats = unconf; - walletLockedSats = lock; - walletReservedAnchorSats = reserve; - } else { - const outputs = Array.isArray((listfunds as any).outputs) ? (listfunds as any).outputs : []; - let walletMsat = 0n; - for (const o of outputs) { - const msat = parseMsat((o as any)?.amount_msat); - if (msat !== null) walletMsat += msat; - } - walletSats = toSafe(walletMsat / 1000n); - walletConfirmedSats = walletSats; - walletUnconfirmedSats = null; - walletLockedSats = 0; - walletReservedAnchorSats = 0; - } - - let totalOutbound = 0n; - let maxOutbound = 0n; - let totalInbound = 0n; - let maxInbound = 0n; - let activeCount = 0; - for (const r of rows) { - if (!r.active) continue; - activeCount += 1; - const out = typeof r.local_sats === 'number' ? BigInt(Math.max(0, Math.trunc(r.local_sats))) : 0n; - const inn = typeof r.remote_sats === 'number' ? BigInt(Math.max(0, Math.trunc(r.remote_sats))) : 0n; - totalOutbound += out; - totalInbound += inn; - if (out > maxOutbound) maxOutbound = out; - if (inn > maxInbound) maxInbound = inn; - } - - return { - ok: true, - channels: rows.length, - channels_active: activeCount, - wallet_sats: walletSats, - wallet_confirmed_sats: walletConfirmedSats, - wallet_unconfirmed_sats: walletUnconfirmedSats, - wallet_locked_sats: walletLockedSats, - wallet_reserved_anchor_sats: walletReservedAnchorSats, - channel_rows: rows, - max_outbound_sats: toSafe(maxOutbound), - total_outbound_sats: toSafe(totalOutbound), - max_inbound_sats: toSafe(maxInbound), - total_inbound_sats: toSafe(totalInbound), - }; - } catch (_e) { - return { ok: false, channels: 0, channels_active: 0, channel_rows: [] }; - } - } - - function summarizePrice(snapshot: any) { - try { - if (!snapshot || typeof snapshot !== 'object') { - return { ok: false, ts: null, btc_usd: null, btc_usdt: null, usdt_usd: null, error: 'no_snapshot' }; - } - if (String((snapshot as any).type || '') === 'error') { - return { - ok: false, - ts: typeof (snapshot as any).ts === 'number' ? (snapshot as any).ts : null, - btc_usd: null, - btc_usdt: null, - usdt_usd: null, - error: String((snapshot as any).error || 'price oracle error'), - }; - } - if (String((snapshot as any).type || '') !== 'price_snapshot') { - return { ok: false, ts: null, btc_usd: null, btc_usdt: null, usdt_usd: null, error: 'unexpected_snapshot_type' }; - } - const pairs = (snapshot as any).pairs && typeof (snapshot as any).pairs === 'object' ? (snapshot as any).pairs : {}; - const btc = pairs?.BTC_USDT && typeof pairs.BTC_USDT === 'object' ? pairs.BTC_USDT : null; - const usdt = pairs?.USDT_USD && typeof pairs.USDT_USD === 'object' ? pairs.USDT_USD : null; - const btcUsdt = typeof btc?.median === 'number' && Number.isFinite(btc.median) ? btc.median : null; - const usdtUsd = typeof usdt?.median === 'number' && Number.isFinite(usdt.median) ? usdt.median : 1; - const btcUsd = btcUsdt !== null && usdtUsd !== null ? btcUsdt * usdtUsd : null; - return { - ok: Boolean((snapshot as any).ok), - ts: typeof (snapshot as any).ts === 'number' ? (snapshot as any).ts : null, - btc_usdt: btcUsdt, - usdt_usd: usdtUsd, - btc_usd: typeof btcUsd === 'number' && Number.isFinite(btcUsd) ? btcUsd : null, - btc_ok: Boolean(btc?.ok), - usdt_ok: usdt ? Boolean(usdt?.ok) : true, - providers: Array.isArray((snapshot as any).providers) ? (snapshot as any).providers.slice(0, 20) : [], - }; - } catch (_e) { - return { ok: false, ts: null, btc_usd: null, btc_usdt: null, usdt_usd: null, error: 'price_summary_failed' }; - } - } - - async function refreshPreflight(opts: { includeTradeAuto?: boolean } = {}) { - // Single-flight guard: prevents overlapping checklist polls from piling up under load. - if (preflightBusyRef.current) return; - preflightBusyRef.current = true; - const runSeq = preflightRunSeqRef.current + 1; - preflightRunSeqRef.current = runSeq; - setPreflightBusy(true); - const out: any = { ts: Date.now() }; - const includeTradeAuto = opts.includeTradeAuto ?? true; - try { - out.env = await runDirectToolOnce('intercomswap_env_get', {}, { auto_approve: false }); - setEnvInfo(out.env); - setEnvErr(null); - } catch (e: any) { - out.env_error = e?.message || String(e); - setEnvErr(out.env_error); - } - try { - out.peer_status = await runDirectToolOnce('intercomswap_peer_status', {}, { auto_approve: false }); - } catch (e: any) { - out.peer_status_error = e?.message || String(e); - } - try { - out.sc_info = await runDirectToolOnce('intercomswap_sc_info', {}, { auto_approve: false }); - } catch (e: any) { - out.sc_info_error = e?.message || String(e); - } - try { - out.sc_stats = await runDirectToolOnce('intercomswap_sc_stats', {}, { auto_approve: false }); - } catch (e: any) { - out.sc_stats_error = e?.message || String(e); - } - try { - const snap = await runDirectToolOnce('intercomswap_sc_price_get', {}, { auto_approve: false }); - out.price = summarizePrice(snap); - } catch (e: any) { - out.price_error = e?.message || String(e); - } - try { - out.autopost = await runDirectToolOnce('intercomswap_autopost_status', {}, { auto_approve: false }); - } catch (e: any) { - out.autopost_error = e?.message || String(e); - } - out.tradeauto = null; - out.tradeauto_error = null; - if (includeTradeAuto) { - try { - out.tradeauto = await runDirectToolOnce('intercomswap_tradeauto_status', {}, { auto_approve: false }); - } catch (e: any) { - out.tradeauto_error = e?.message || String(e); - } - } - try { - out.ln_info = await runDirectToolOnce('intercomswap_ln_info', {}, { auto_approve: false }); - } catch (e: any) { - out.ln_info_error = e?.message || String(e); - } - try { - out.ln_listpeers = await runDirectToolOnce('intercomswap_ln_listpeers', {}, { auto_approve: false }); - } catch (e: any) { - out.ln_listpeers_error = e?.message || String(e); - } - try { - out.ln_listfunds = await runDirectToolOnce('intercomswap_ln_listfunds', {}, { auto_approve: false }); - out.ln_channels = await runDirectToolOnce('intercomswap_ln_listchannels', {}, { auto_approve: false }); - out.ln_summary = summarizeLn(out.ln_listfunds, out.ln_channels, String(out?.env?.ln?.impl || out?.ln_info?.implementation || '')); - } catch (e: any) { - out.ln_listfunds_error = e?.message || String(e); - } - // If LN backend is docker, show compose service status in the checklist so operators can see - // whether the containers are actually running (without needing to run tools manually). - if (String(out?.env?.ln?.backend || '') === 'docker') { - try { - out.ln_docker_ps = await runDirectToolOnce('intercomswap_ln_docker_ps', {}, { auto_approve: false }); - } catch (e: any) { - out.ln_docker_ps_error = e?.message || String(e); - } - } - - // If Solana is configured for localhost, show (and allow starting) a local test validator. - const solKind = String(out?.env?.solana?.classify?.kind || ''); - if (solKind === 'local') { - try { - out.sol_local_status = await runDirectToolOnce('intercomswap_sol_local_status', {}, { auto_approve: false }); - } catch (e: any) { - out.sol_local_status_error = e?.message || String(e); - } - } - try { - out.sol_signer = await runDirectToolOnce('intercomswap_sol_signer_pubkey', {}, { auto_approve: false }); - } catch (e: any) { - out.sol_signer_error = e?.message || String(e); - } - try { - const signerPk = String(out?.sol_signer?.pubkey || '').trim(); - if (signerPk) { - out.sol_balance = await runDirectToolOnce('intercomswap_sol_balance', { pubkey: signerPk }, { auto_approve: false }); - const mint = String(walletUsdtMint || '').trim(); - if (mint) { - out.sol_usdt = await runDirectToolOnce( - 'intercomswap_sol_token_balance', - { owner: signerPk, mint }, - { auto_approve: false } - ); - } - } - } catch (e: any) { - out.sol_balance_error = e?.message || String(e); - } - try { - const solLocalUp = solKind !== 'local' || Boolean(out?.sol_local_status?.rpc_listening); - if (!solLocalUp) { - const rpc = String(Array.isArray(out?.env?.solana?.rpc_urls) ? out.env.solana.rpc_urls[0] : 'http://127.0.0.1:8899'); - out.sol_config_error = `Solana RPC is down (${rpc}). Start local validator first.`; - } else { - out.sol_config = await runDirectToolOnce('intercomswap_sol_config_get', {}, { auto_approve: false }); - } - } catch (e: any) { - out.sol_config_error = e?.message || String(e); - } - try { - out.app = await runDirectToolOnce('intercomswap_app_info', {}, { auto_approve: false }); - } catch (e: any) { - out.app_error = e?.message || String(e); - } - try { - // Ensure receipts DB is configured + writable early, so swaps always have a recovery trail. - out.receipts = await runDirectToolOnce('intercomswap_receipts_list', { limit: 1, offset: 0 }, { auto_approve: false }); - } catch (e: any) { - out.receipts_error = e?.message || String(e); - } - - if (preflightRunSeqRef.current === runSeq) setPreflight(out); - if (preflightRunSeqRef.current === runSeq) setPreflightBusy(false); - if (preflightRunSeqRef.current === runSeq) preflightBusyRef.current = false; - } - - async function refreshEnv() { - setEnvBusy(true); - try { - const out = await runDirectToolOnce('intercomswap_env_get', {}, { auto_approve: false }); - setEnvInfo(out); - setEnvErr(null); - } catch (e: any) { - setEnvInfo(null); - setEnvErr(e?.message || String(e)); - } finally { - setEnvBusy(false); - } - } - - async function loadTradesPage({ reset = false } = {}) { - if (tradesLoading) return; - setTradesLoading(true); - try { - const offset = reset ? 0 : tradesOffset; - const page = await runDirectToolOnce('intercomswap_receipts_list', { ...receiptsDbArg, limit: tradesLimit, offset }, { auto_approve: false }); - const arr = Array.isArray(page) ? page : []; - setTrades((prev) => { - const base = reset ? [] : prev; - const byId = new Map(); - const upsert = (t: any) => { - const id = String(t?.trade_id || '').trim(); - if (!id) return; - const existing = byId.get(id); - if (!existing) { - byId.set(id, t); - return; - } - const nextTs = tradeUpdatedAtMs(t); - const existingTs = tradeUpdatedAtMs(existing); - const nextTerminal = isTerminalTradeState(t?.state); - const existingTerminal = isTerminalTradeState(existing?.state); - if (nextTs > existingTs || (nextTs === existingTs && nextTerminal && !existingTerminal)) { - byId.set(id, t); - } - }; - for (const t of base) upsert(t); - for (const t of arr) upsert(t); - const out = Array.from(byId.values()).sort((a, b) => tradeUpdatedAtMs(b) - tradeUpdatedAtMs(a)); - return out.length <= 2000 ? out : out.slice(0, 2000); - }); - setTradesOffset(offset + arr.length); - setTradesHasMore(arr.length === tradesLimit); - } catch (e: any) { - setTradesHasMore(false); - void appendPromptEvent({ type: 'error', ts: Date.now(), error: `trades load failed: ${e?.message || String(e)}` }, { persist: false }); - } finally { - setTradesLoading(false); - } - } - - async function loadOpenRefundsPage({ reset = false } = {}) { - if (openRefundsLoading) return; - setOpenRefundsLoading(true); - try { - const offset = reset ? 0 : openRefundsOffset; - const page = await runDirectToolOnce( - 'intercomswap_receipts_list_open_refunds', - { ...receiptsDbArg, limit: openRefundsLimit, offset }, - { auto_approve: false } - ); - const arr = Array.isArray(page) ? page : []; - setOpenRefunds((prev) => { - const base = (reset ? [] : prev).filter((t) => { - const id = String(t?.trade_id || '').trim(); - if (!id) return false; - return !terminalTradeIdsSet.has(id); - }); - const byId = new Map(); - const upsert = (t: any) => { - const id = String(t?.trade_id || '').trim(); - if (!id) return; - if (terminalTradeIdsSet.has(id)) return; - const existing = byId.get(id); - if (!existing) { - byId.set(id, t); - return; - } - const nextTs = tradeUpdatedAtMs(t); - const existingTs = tradeUpdatedAtMs(existing); - if (nextTs >= existingTs) byId.set(id, t); - }; - for (const t of base) upsert(t); - for (const t of arr) upsert(t); - const out = Array.from(byId.values()).sort((a, b) => tradeUpdatedAtMs(b) - tradeUpdatedAtMs(a)); - return out.length <= 2000 ? out : out.slice(0, 2000); - }); - setOpenRefundsOffset(offset + arr.length); - setOpenRefundsHasMore(arr.length === openRefundsLimit); - } catch (e: any) { - setOpenRefundsHasMore(false); - void appendPromptEvent( - { type: 'error', ts: Date.now(), error: `open refunds load failed: ${e?.message || String(e)}` }, - { persist: false } - ); - } finally { - setOpenRefundsLoading(false); - } - } - - async function loadOpenClaimsPage({ reset = false } = {}) { - if (openClaimsLoading) return; - setOpenClaimsLoading(true); - try { - const offset = reset ? 0 : openClaimsOffset; - const page = await runDirectToolOnce( - 'intercomswap_receipts_list_open_claims', - { ...receiptsDbArg, limit: openClaimsLimit, offset }, - { auto_approve: false } - ); - const arr = Array.isArray(page) ? page : []; - setOpenClaims((prev) => { - const base = (reset ? [] : prev).filter((t) => { - const id = String(t?.trade_id || '').trim(); - if (!id) return false; - return !terminalTradeIdsSet.has(id); - }); - const byId = new Map(); - const upsert = (t: any) => { - const id = String(t?.trade_id || '').trim(); - if (!id) return; - if (terminalTradeIdsSet.has(id)) return; - const existing = byId.get(id); - if (!existing) { - byId.set(id, t); - return; - } - const nextTs = tradeUpdatedAtMs(t); - const existingTs = tradeUpdatedAtMs(existing); - if (nextTs >= existingTs) byId.set(id, t); - }; - for (const t of base) upsert(t); - for (const t of arr) upsert(t); - const out = Array.from(byId.values()).sort((a, b) => tradeUpdatedAtMs(b) - tradeUpdatedAtMs(a)); - return out.length <= 2000 ? out : out.slice(0, 2000); - }); - setOpenClaimsOffset(offset + arr.length); - setOpenClaimsHasMore(arr.length === openClaimsLimit); - } catch (e: any) { - setOpenClaimsHasMore(false); - void appendPromptEvent( - { type: 'error', ts: Date.now(), error: `open claims load failed: ${e?.message || String(e)}` }, - { persist: false } - ); - } finally { - setOpenClaimsLoading(false); - } - } - - async function appendPromptEvent(evt: any, { persist = true } = {}) { - const e = evt && typeof evt === 'object' ? evt : { type: 'event', evt }; - const ts = typeof e.ts === 'number' ? e.ts : typeof e.started_at === 'number' ? e.started_at : Date.now(); - const normalized = { ...e, ts }; - const sid = String(e.session_id || sessionId || ''); - const type = String(e.type || 'event'); - let dbId: number | null = null; - if (persist) { - try { - dbId = await promptAdd({ ts, session_id: sid, type, evt: normalized }); - } catch (_e) {} - } - - const el = promptListRef.current; - const prevHeight = el ? el.scrollHeight : 0; - const prevTop = el ? el.scrollTop : 0; - const keepViewport = Boolean(el && prevTop > 80); - - setPromptEvents((prev) => { - const cutoff = Date.now() - COLLINS_ACTIVITY_RETENTION_MS; - const next = [{ ...normalized, db_id: dbId }] - .concat(prev) - .filter((row) => { - const tsRow = eventTsMs(row); - return tsRow <= 0 || tsRow >= cutoff; - }); - if (next.length <= promptEventsMax) return next; - return next.slice(0, promptEventsMax); - }); - if (keepViewport) { - requestAnimationFrame(() => { - const el2 = promptListRef.current; - if (!el2) return; - const delta = el2.scrollHeight - prevHeight; - if (delta > 0) el2.scrollTop = prevTop + delta; - }); - } - } - - function scrollChatToBottom() { - const el = promptChatListRef.current; - if (!el) return; - el.scrollTop = el.scrollHeight; - } - - async function appendChatMessage( - role: 'user' | 'assistant', - text: string, - { forceFollowTail = false }: { forceFollowTail?: boolean } = {} - ) { - const ts = Date.now(); - const clean = String(text || '').slice(0, 200_000); - const follow = forceFollowTail || Boolean(promptChatFollowTailRef.current); - if (forceFollowTail) { - promptChatFollowTailRef.current = true; - setPromptChatFollowTail(true); - setPromptChatUnseen(0); - } - - let id: number | null = null; - try { - id = await chatAdd({ ts, role, text: clean }); - } catch (_e) { - id = null; - } - - const msg = { - id: id !== null ? id : Math.floor(Date.now() + Math.random() * 1000), - role, - ts, - text: clean, - }; - - if (!follow && role === 'assistant') { - setPromptChatUnseen((n) => Math.min(999, n + 1)); - return; - } - - setPromptChat((prev) => { - const next = prev.concat([msg]); - if (next.length <= promptChatMax) return next; - // Keep newest window in memory. - return next.slice(next.length - promptChatMax); - }); - if (follow) requestAnimationFrame(scrollChatToBottom); - } - - function maybePruneScEventDedupMap(now: number) { - const map = scEventDedupRef.current; - const sizeLimit = Math.max(scEventDedupMax, Math.trunc(scEventDedupMax * scEventDedupOverflowFactor)); - const dueByTime = now >= Number(scEventDedupNextPruneAtRef.current || 0); - const dueBySize = map.size > sizeLimit; - if (!dueByTime && !dueBySize) return; - - for (const [k, seenAt] of map.entries()) { - if (!k || !Number.isFinite(seenAt) || now - Number(seenAt) > scEventDedupTtlMs) map.delete(k); - } - if (map.size > scEventDedupMax) { - const ordered = Array.from(map.entries()).sort((a, b) => Number(a[1] || 0) - Number(b[1] || 0)); - const drop = map.size - scEventDedupMax; - for (let i = 0; i < drop; i += 1) { - const key = String(ordered[i]?.[0] || ''); - if (key) map.delete(key); - } - } - scEventDedupNextPruneAtRef.current = now + scEventDedupPruneEveryMs; - } - - function flushPendingScUiEvents() { - scUiFlushTimerRef.current = null; - const pending = scPendingUiEventsRef.current; - if (!Array.isArray(pending) || pending.length < 1) return; - const takeCount = Math.min(pending.length, scEventUiFlushBatch); - const batch = pending.splice(0, takeCount); - if (batch.length < 1) return; - - const newestFirst = [...batch].reverse(); - const el = scListRef.current; - const prevHeight = el ? el.scrollHeight : 0; - const prevTop = el ? el.scrollTop : 0; - const keepViewport = Boolean(el && prevTop > 80); - - setScEvents((prev) => { - const cutoff = Date.now() - COLLINS_SC_FEED_RETENTION_MS; - const next = newestFirst.concat(prev).filter((row) => { - const tsRow = eventTsMs(row); - return tsRow <= 0 || tsRow >= cutoff; - }); - if (next.length <= scEventsMax) return next; - return next.slice(0, scEventsMax); - }); - if (keepViewport) { - requestAnimationFrame(() => { - const el2 = scListRef.current; - if (!el2) return; - const delta = el2.scrollHeight - prevHeight; - if (delta > 0) el2.scrollTop = prevTop + delta; - }); - } - - if (scPendingUiEventsRef.current.length > 0 && scUiFlushTimerRef.current === null) { - scUiFlushTimerRef.current = window.setTimeout(flushPendingScUiEvents, scEventUiFlushMs); - } - } - - function scheduleScUiFlush() { - if (scUiFlushTimerRef.current !== null) return; - scUiFlushTimerRef.current = window.setTimeout(flushPendingScUiEvents, scEventUiFlushMs); - } - - function appendScEvent(evt: any, { persist = true } = {}) { - const e = evt && typeof evt === 'object' ? evt : { type: 'event', evt }; - if (!shouldKeepScEventInUi(e)) return; - const msgTs = e?.message && typeof e.message.ts === 'number' ? e.message.ts : null; - const ts = typeof e.ts === 'number' ? e.ts : msgTs !== null ? msgTs : Date.now(); - const feedCutoff = Date.now() - COLLINS_SC_FEED_RETENTION_MS; - if (Number.isFinite(ts) && ts > 0 && ts < feedCutoff) return; - const normalized = { ...e, ts }; - const dedupKey = deriveScEventDedupKey(normalized); - if (dedupKey) { - const now = Date.now(); - const map = scEventDedupRef.current; - const prevSeenAt = Number(map.get(dedupKey) || 0); - if (prevSeenAt > 0 && now - prevSeenAt <= scEventDedupTtlMs) return; - map.set(dedupKey, now); - maybePruneScEventDedupMap(now); - } - - scPendingUiEventsRef.current.push(normalized); - if (scPendingUiEventsRef.current.length > scEventsMax * 2) { - scPendingUiEventsRef.current.splice(0, scPendingUiEventsRef.current.length - scEventsMax * 2); - } - scheduleScUiFlush(); - - if (persist && normalized.type === 'sc_event') { - const channel = String(e.channel || ''); - const kind = String(e.kind || ''); - const trade_id = String(e.trade_id || ''); - const seq = typeof e.seq === 'number' ? e.seq : null; - void scAdd({ ts, channel, kind, trade_id, seq, evt: normalized }).catch(() => {}); - } - } - - async function copyToClipboard(label: string, value: any) { - const s = String(value ?? '').trim(); - if (!s) return; - try { - await navigator.clipboard.writeText(s); - pushToast('success', `Copied ${label}`); - void appendPromptEvent({ type: 'ui', ts: Date.now(), message: `copied ${label}` }, { persist: false }); - } catch (_e) {} - } - - function deriveKindTrade(msg: any) { - if (!msg || typeof msg !== 'object') return { kind: '', trade_id: '' }; - const kind = typeof msg.kind === 'string' ? msg.kind : ''; - const trade_id = typeof msg.trade_id === 'string' ? msg.trade_id : ''; - return { kind, trade_id }; - } - - async function startScStream() { - // Mark the stream as wanted. This is the default; STOP stack will disable it. - scStreamWantedRef.current = true; - - // Bump generation so stale async finally/catch blocks cannot clobber the latest stream state. - scStreamGenRef.current += 1; - const gen = scStreamGenRef.current; - - if (scAbortRef.current) scAbortRef.current.abort(); - const ac = new AbortController(); - scAbortRef.current = ac; - - const channels = Array.from(watchedChannelsSet).slice(0, 50); - const url = new URL('/v1/sc/stream', window.location.origin); - if (channels.length > 0) url.searchParams.set('channels', channels.join(',')); - url.searchParams.set('backlog', '250'); - - setScConnecting(true); - setScConnected(false); - setScStreamErr(null); - - const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - - const isAbortLike = (err: any, msg: string) => { - if (ac.signal.aborted) return true; - if (err && typeof err === 'object') { - if (String((err as any).name || '') === 'AbortError') return true; - } - if (/client_closed/i.test(msg)) return true; - return false; - }; - const isTransientNetErr = (msg: string) => { - const s = String(msg || ''); - return ( - /BodyStreamBuffer was aborted/i.test(s) || - /Received network error or non-101 status code/i.test(s) || - /Failed to fetch/i.test(s) || - /NetworkError/i.test(s) || - /Load failed/i.test(s) || - /socket hang up/i.test(s) || - /ECONNRESET/i.test(s) - ); - }; - - // Auto-reconnect loop: the feed is required for a safe human UX. - let backoffMs = 450; - while (!ac.signal.aborted && scStreamWantedRef.current && scStreamGenRef.current === gen) { - try { - const res = await fetch(url.toString(), { method: 'GET', signal: ac.signal }); - if (!res.ok || !res.body) throw new Error(`sc/stream failed: ${res.status}`); - - const reader = res.body.getReader(); - const td = new TextDecoder(); - let buf = ''; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += td.decode(value, { stream: true }); - while (true) { - const idx = buf.indexOf('\n'); - if (idx < 0) break; - const line = buf.slice(0, idx).trim(); - buf = buf.slice(idx + 1); - if (!line) continue; - let obj: any = null; - try { - obj = JSON.parse(line); - } catch (_e) { - appendScEvent({ type: 'parse_error', ts: Date.now(), line }, { persist: false }); - continue; - } - - if (obj.type === 'sc_stream_open') { - backoffMs = 450; - if (scStreamGenRef.current === gen) { - setScConnected(true); - setScConnecting(false); - setScStreamErr(null); - } - continue; - } - - // Heartbeats are transport-level keepalives; don’t pollute the operator log. - if (obj.type === 'heartbeat') continue; - - if (obj.type === 'sc_event') { - const msg = obj.message; - const d = deriveKindTrade(msg); - appendScEvent({ ...obj, ...d }, { persist: true }); - continue; - } - - if (obj.type === 'error') { - // Server-side stream error. Treat as reconnect-worthy. - throw new Error(String(obj?.error || 'sc/stream error')); - } - - appendScEvent(obj, { persist: false }); - } - } - } catch (err: any) { - const msg = err?.message || String(err); - if (isAbortLike(err, msg)) break; - const transient = isTransientNetErr(msg); - - // Only update state if this is still the active stream. - if (scStreamGenRef.current === gen) { - setScConnected(false); - setScConnecting(true); - setScStreamErr(transient ? null : msg); - } - if (!transient) { - appendScEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - } - } - - // Stream ended (disconnect) without abort. Reconnect with backoff. - if (ac.signal.aborted || !scStreamWantedRef.current || scStreamGenRef.current !== gen) break; - - if (scStreamGenRef.current === gen) { - setScConnected(false); - setScConnecting(true); - // Disconnects can happen on flaky networks; reconnect silently. - setScStreamErr(null); - } - // Keep operator logs clean: connection churn is reflected in status pills, not the feed. - - await sleep(backoffMs); - backoffMs = Math.min(8000, Math.trunc(backoffMs * 1.6)); - } - - if (scStreamGenRef.current === gen) { - setScConnecting(false); - setScConnected(false); - } - } - - function stopScStream() { - scStreamWantedRef.current = false; - if (scAbortRef.current) scAbortRef.current.abort(); - scAbortRef.current = null; - if (scUiFlushTimerRef.current !== null) { - clearTimeout(scUiFlushTimerRef.current); - scUiFlushTimerRef.current = null; - } - scPendingUiEventsRef.current = []; - setScConnecting(false); - setScConnected(false); - setScStreamErr(null); - } - - async function runPromptStream(payload: any) { - // Hard gate: never allow trade/protocol actions unless the full stack is up. - // This prevents operators from broadcasting RFQs/offers or starting bots when settlement isn’t possible. - try { - const promptStr = String(payload?.prompt || '').trim(); - let toolName: string | null = null; - if (promptStr.startsWith('{')) { - try { - const obj: any = JSON.parse(promptStr); - if (obj && typeof obj === 'object' && String(obj.type || '') === 'tool' && typeof obj.name === 'string') { - toolName = String(obj.name).trim() || null; - } - } catch (_e) {} - } - - const block = - (toolName && toolNeedsFullStack(toolName) && !stackGate.ok) || - (!toolName && runMode === 'llm' && !stackGate.ok); - - if (block) { - const missing = stackGate.reasons.length > 0 ? stackGate.reasons.map((r) => `- ${r}`).join('\n') : '- unknown'; - const msg = `${toolName || 'prompt'}: blocked (stack not ready)\n\nMissing:\n${missing}\n\nGo to Overview -> Getting Started and complete the checklist.`; - setRunErr(msg); - setConsoleEvents([{ type: 'error', ts: Date.now(), error: msg }]); - void appendPromptEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - return { type: 'blocked', error: msg }; - } - } catch (_e) {} - - if (promptAbortRef.current) promptAbortRef.current.abort(); - const ac = new AbortController(); - promptAbortRef.current = ac; - - setRunBusy(true); - setRunErr(null); - setConsoleEvents([]); - - await appendPromptEvent({ type: 'ui', ts: Date.now(), message: 'run starting...' }, { persist: false }); - - let finalObj: any = null; - try { - const res = await fetch('/v1/run/stream', { - method: 'POST', - signal: ac.signal, - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(payload), - }); - if (!res.ok || !res.body) throw new Error(`run failed: ${res.status}`); - - const reader = res.body.getReader(); - const td = new TextDecoder(); - let buf = ''; - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buf += td.decode(value, { stream: true }); - while (true) { - const idx = buf.indexOf('\n'); - if (idx < 0) break; - const line = buf.slice(0, idx).trim(); - buf = buf.slice(idx + 1); - if (!line) continue; - let obj: any = null; - try { - obj = JSON.parse(line); - } catch (_e) { - await appendPromptEvent({ type: 'parse_error', ts: Date.now(), line }, { persist: false }); - continue; - } - if (obj.type === 'final') finalObj = obj; - if (obj.type === 'run_start' && obj.session_id) setSessionId(String(obj.session_id)); - if (obj.type === 'error') setRunErr(String(obj.error || 'error')); - if (obj.type === 'tool' && obj.result && typeof obj.result === 'object' && obj.result.type === 'error') { - const msg = String(obj?.result?.error || `${obj?.name || 'tool'} failed`); - setRunErr(msg); - } - if (obj.type === 'done') setRunBusy(false); - setConsoleEvents((prev) => { - const next = [obj].concat(prev); - if (next.length <= consoleEventsMax) return next; - return next.slice(0, consoleEventsMax); - }); - await appendPromptEvent(obj, { persist: true }); - } - } - return finalObj; - } catch (err: any) { - const msg = err?.message || String(err); - setRunErr(msg); - setConsoleEvents((prev) => { - const next = [{ type: 'error', ts: Date.now(), error: msg }].concat(prev); - if (next.length <= consoleEventsMax) return next; - return next.slice(0, consoleEventsMax); - }); - await appendPromptEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - return { type: 'error', error: msg }; - } finally { - setRunBusy(false); - } - } - - async function onRun() { - if (runMode === 'tool') { - const name = toolName.trim(); - if (!name) return; - if (!activeTool || activeTool?.name !== name) { - const msg = 'Tools not loaded yet. Click "Reload tools".'; - setRunErr(msg); - void appendPromptEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - return; - } - let args: any = {}; - if (toolInputMode === 'form') { - args = toolArgsObj && typeof toolArgsObj === 'object' ? toolArgsObj : {}; - } else { - try { - args = toolArgsText.trim() ? JSON.parse(toolArgsText) : {}; - setToolArgsParseErr(null); - if (args && typeof args === 'object') setToolArgsObj(args); - } catch (e: any) { - const msg = `Invalid JSON args: ${e?.message || String(e)}`; - setToolArgsParseErr(msg); - setRunErr(msg); - void appendPromptEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - return; - } - } - - const argErrs = validateToolArgs(activeTool, args); - if (argErrs.length > 0) { - const msg = `Invalid args:\n- ${argErrs.join('\n- ')}`; - setRunErr(msg); - void appendPromptEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - return; - } - - if (toolRequiresApproval(name) && !autoApprove) { - const ok = window.confirm(`${name} requires approval (it changes state or can move funds).\n\nApprove once and run now?`); - if (!ok) { - const msg = `${name}: blocked (not approved)`; - setRunErr(msg); - void appendPromptEvent({ type: 'error', ts: Date.now(), error: msg }, { persist: false }); - return; - } - } - const directToolPrompt = { - type: 'tool', - name, - arguments: args && typeof args === 'object' ? args : {}, - }; - await runPromptStream({ - prompt: JSON.stringify(directToolPrompt), - session_id: sessionId, - auto_approve: toolRequiresApproval(name) ? true : autoApprove, - dry_run: false, - }); - return; - } - - const p = promptInput.trim(); - if (!p) return; - await runPromptStream({ - prompt: p, - session_id: sessionId, - auto_approve: autoApprove, - dry_run: false, - }); - } - - useEffect(() => { - refreshHealth(); - refreshTools(); - void refreshEnv(); - void refreshPreflight(); - const t = setInterval(refreshHealth, 5000); - return () => clearInterval(t); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Keep uiNowMs ticking so expiry-based UI stays correct even if the network is quiet. - useEffect(() => { - const t = setInterval(() => setUiNowMs(Date.now()), 15_000); - return () => clearInterval(t); - }, []); - - // Periodic local DB hygiene to bound disk growth. - useEffect(() => { - const kindRaw = String(envInfo?.env_kind || '').trim().toLowerCase(); - if (!kindRaw) return; - const run = () => - void dbPruneRetention({ - scFeedRetentionMs: COLLINS_SC_FEED_RETENTION_MS, - activityRetentionMs: COLLINS_ACTIVITY_RETENTION_MS, - }).catch(() => {}); - run(); - const t = setInterval(run, 30 * 60 * 1000); - return () => clearInterval(t); - }, [envInfo?.env_kind]); - - // Stack observer: - // - Periodically refresh the checklist while the stack is up (so the UI detects crashes/disconnects). - // - Emit a toast if the stack transitions from READY -> not ready. - useEffect(() => { - const okPromptd = Boolean(health?.ok); - const running = Boolean(stackAnyRunning || stackGate.ok); - if (!okPromptd || !running) return; - const intervalMs = 15_000; - const t = setInterval(() => { - if (preflightBusyRef.current) return; - void refreshPreflight(); - }, intervalMs); - return () => clearInterval(t); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [health?.ok, stackAnyRunning, stackGate.ok, tradeAutoTraceEnabled, tradeAutoWorkerEnabled]); - - useEffect(() => { - const ok = Boolean(stackGate.ok); - const prev = stackOkRef.current; - if (prev === null) { - stackOkRef.current = ok; - if (ok) setStackLastOkTs(Date.now()); - return; - } - if (ok) setStackLastOkTs(Date.now()); - if (prev && !ok) { - const reasons = stackGate.reasons.length > 0 ? stackGate.reasons.map((r) => `- ${r}`).join('\n') : '- unknown'; - pushToast('error', `Stack issue detected (something crashed/disconnected)\n\n${reasons}`, { ttlMs: 12_000 }); - } - stackOkRef.current = ok; - }, [stackGate.ok, stackGate.reasons]); - - // Close detail modal via Escape. - useEffect(() => { - const isModal = selected && selected.type !== 'console_event' && selected.type !== 'prompt_event'; - if (!isModal) return; - const onKey = (e: KeyboardEvent) => { - if (e.key === 'Escape') setSelected(null); - }; - window.addEventListener('keydown', onKey); - return () => window.removeEventListener('keydown', onKey); - }, [selected]); - - // Auto-connect the sidechannel feed once a peer is up. The UI relies on this for RFQ/Offer inboxes. - useEffect(() => { - const scPort = (() => { - try { - const u = new URL(String(envInfo?.sc_bridge?.url || '').trim() || 'ws://127.0.0.1:49222'); - const p = u.port ? Number.parseInt(u.port, 10) : 0; - return Number.isFinite(p) && p > 0 ? p : 49222; - } catch (_e) { - return 49222; - } - })(); - const okPeer = Boolean( - preflight?.peer_status?.peers?.some?.((p: any) => Boolean(p?.alive) && Number(p?.sc_bridge?.port) === scPort) - ); - if (!health?.ok || !okPeer) return; - if (!scStreamWantedRef.current) return; - if (scConnected || scConnecting) return; - void startScStream(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [health?.ok, preflight?.peer_status, scChannels, scSwapWatchChannels, envInfo?.sc_bridge?.url]); - - // Lazy load tab-specific data. - useEffect(() => { - if (activeTab === 'trade_actions' && trades.length === 0) void loadTradesPage({ reset: true }); - if (activeTab === 'refunds' && (openRefunds.length === 0 || openClaims.length === 0)) { - if (openRefunds.length === 0) void loadOpenRefundsPage({ reset: true }); - if (openClaims.length === 0) void loadOpenClaimsPage({ reset: true }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab]); - - // Keep receipt-based tabs fresh so terminal state transitions are reflected without manual refresh. - useEffect(() => { - if (!health?.ok) return; - if (activeTab !== 'trade_actions' && activeTab !== 'refunds') return; - const run = () => { - if (activeTab === 'trade_actions') void loadTradesPage({ reset: true }); - if (activeTab === 'refunds') { - void loadOpenRefundsPage({ reset: true }); - void loadOpenClaimsPage({ reset: true }); - } - }; - run(); - const t = setInterval(run, 15_000); - return () => clearInterval(t); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTab, health?.ok, selectedReceiptsSource?.key]); - - // When switching receipts DB sources, reset pagination so operators don't mix multiple stores. - useEffect(() => { - if (!selectedReceiptsSource) return; - setTrades([]); - setTradesOffset(0); - setTradesHasMore(true); - setOpenRefunds([]); - setOpenRefundsOffset(0); - setOpenRefundsHasMore(true); - setOpenClaims([]); - setOpenClaimsOffset(0); - setOpenClaimsHasMore(true); - if (activeTab === 'trade_actions') void loadTradesPage({ reset: true }); - if (activeTab === 'refunds') { - void loadOpenRefundsPage({ reset: true }); - void loadOpenClaimsPage({ reset: true }); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedReceiptsSource?.key]); - - // Load recent history from local IndexedDB. - // IMPORTANT: keep test vs mainnet separate by using a namespaced DB per env_kind. - useEffect(() => { - const kindRaw = String(envInfo?.env_kind || '').trim().toLowerCase(); - if (!kindRaw) return; - const kind = kindRaw === 'test' || kindRaw === 'mainnet' || kindRaw === 'mixed' ? kindRaw : 'default'; - setDbNamespace(kind); - - // Reset in-memory logs when switching env kinds so operators don't get "mixed" UI. - setScEvents([]); - scEventDedupRef.current.clear(); - scEventDedupNextPruneAtRef.current = 0; - scPendingUiEventsRef.current = []; - if (scUiFlushTimerRef.current !== null) { - clearTimeout(scUiFlushTimerRef.current); - scUiFlushTimerRef.current = null; - } - setScSwapWatchChannelsState([]); - scSwapWatchFirstSeenAtRef.current.clear(); - setPromptEvents([]); - setPromptChat([]); - - (async () => { - try { - await dbPruneRetention({ - scFeedRetentionMs: COLLINS_SC_FEED_RETENTION_MS, - activityRetentionMs: COLLINS_ACTIVITY_RETENTION_MS, - }); - } catch (_e) {} - const now = Date.now(); - const scCutoff = now - COLLINS_SC_FEED_RETENTION_MS; - const activityCutoff = now - COLLINS_ACTIVITY_RETENTION_MS; - try { - const sc = await scListLatest({ limit: 400 }); - setScEvents( - sc - .map((r) => ({ ...(r.evt || {}), db_id: r.id })) - .filter((e) => { - const ts = eventTsMs(e); - return ts <= 0 || ts >= scCutoff; - }) - ); - } catch (_e) {} - try { - const pe = await promptListLatest({ limit: 300 }); - setPromptEvents( - pe - .map((r) => ({ ...(r.evt || {}), db_id: r.id })) - .filter((e) => { - const ts = eventTsMs(e); - return ts <= 0 || ts >= activityCutoff; - }) - ); - } catch (_e) {} - try { - const ch = await chatListLatest({ limit: 300 }); - // DB returns newest-first; chat UI wants oldest-first. - setPromptChat( - ch - .map((r: any) => ({ - id: Number(r.id), - role: normalizeChatRole(r.role), - ts: Number(r.ts), - text: String(r.text || ''), - })) - .filter((m: any) => Number.isFinite(m.ts) && m.ts >= activityCutoff) - .reverse() - ); - requestAnimationFrame(scrollChatToBottom); - } catch (_e) {} - })(); - }, [envInfo?.env_kind]); - - useEffect(() => { - return () => { - if (scUiFlushTimerRef.current !== null) { - clearTimeout(scUiFlushTimerRef.current); - scUiFlushTimerRef.current = null; - } - scPendingUiEventsRef.current = []; - }; - }, []); - - // No “follow tail” UI: logs render newest-first. - - const onScScroll = () => { - const cur = scListRef.current; - if (!cur) return; - const nearBottom = cur.scrollHeight - cur.scrollTop - cur.clientHeight < 180; - if (nearBottom) void loadOlderScEvents({ limit: 250 }); - }; - - const onPromptScroll = () => { - const cur = promptListRef.current; - if (!cur) return; - const nearBottom = cur.scrollHeight - cur.scrollTop - cur.clientHeight < 180; - if (nearBottom) void loadOlderPromptEvents({ limit: 250 }); - }; - - const onPromptChatScroll = () => { - const cur = promptChatListRef.current; - if (!cur) return; - const distBottom = cur.scrollHeight - cur.scrollTop - cur.clientHeight; - const nearBottom = distBottom < 160; - const nearTop = cur.scrollTop < 160; - - const following = Boolean(promptChatFollowTailRef.current); - if (following && !nearBottom) { - promptChatFollowTailRef.current = false; - setPromptChatFollowTail(false); - } - if (!following && nearBottom) { - promptChatFollowTailRef.current = true; - setPromptChatFollowTail(true); - setPromptChatUnseen(0); - } - - if (nearTop) void loadOlderChatMessages({ limit: 250 }); - }; - - const onTradesScroll = () => { - const cur = tradesListRef.current; - if (!cur) return; - const nearBottom = cur.scrollHeight - cur.scrollTop - cur.clientHeight < 180; - if (nearBottom && tradesHasMore && !tradesLoading) void loadTradesPage({ reset: false }); - }; - - const onOpenRefundsScroll = () => { - const cur = openRefundsListRef.current; - if (!cur) return; - const nearBottom = cur.scrollHeight - cur.scrollTop - cur.clientHeight < 180; - if (nearBottom && openRefundsHasMore && !openRefundsLoading) void loadOpenRefundsPage({ reset: false }); - }; - - const onOpenClaimsScroll = () => { - const cur = openClaimsListRef.current; - if (!cur) return; - const nearBottom = cur.scrollHeight - cur.scrollTop - cur.clientHeight < 180; - if (nearBottom && openClaimsHasMore && !openClaimsLoading) void loadOpenClaimsPage({ reset: false }); - }; - - const lnInfoObj = preflight?.ln_info && typeof preflight.ln_info === 'object' ? preflight.ln_info : null; - const lnAlias = lnInfoObj ? String((lnInfoObj as any).alias || '').trim() : ''; - const lnNodeId = lnInfoObj ? String((lnInfoObj as any).id || (lnInfoObj as any).identity_pubkey || '').trim() : ''; - const lnNodeIdShort = lnNodeId ? `${lnNodeId.slice(0, 16)}…` : ''; - const solSignerPubkey = String(preflight?.sol_signer?.pubkey || '').trim(); - const lnChannelCount = Number(preflight?.ln_summary?.channels || 0); - const lnActiveChannelCount = Number(preflight?.ln_summary?.channels_active || 0); - const lnChannelRows = Array.isArray(preflight?.ln_summary?.channel_rows) ? preflight.ln_summary.channel_rows : []; - const lnNumericChanIdOptions = useMemo(() => { - const out: string[] = []; - const seen = new Set(); - for (const ch of lnChannelRows) { - const v = String((ch as any)?.chan_id || '').trim(); - if (!/^[0-9]+$/.test(v)) continue; - if (seen.has(v)) continue; - seen.add(v); - out.push(v); - } - out.sort((a, b) => (a.length !== b.length ? a.length - b.length : a.localeCompare(b))); - return out; - }, [lnChannelRows]); - const lnVisibleChannelRows = useMemo(() => { - if (lnShowInactiveChannels) return lnChannelRows; - return lnChannelRows.filter((ch: any) => Boolean(ch?.active) || isPendingLnChannelState((ch as any)?.state)); - }, [lnChannelRows, lnShowInactiveChannels]); - const lnMaxOutboundSats = typeof preflight?.ln_summary?.max_outbound_sats === 'number' ? preflight.ln_summary.max_outbound_sats : null; - const lnTotalOutboundSats = typeof preflight?.ln_summary?.total_outbound_sats === 'number' ? preflight.ln_summary.total_outbound_sats : null; - const lnMaxInboundSats = typeof preflight?.ln_summary?.max_inbound_sats === 'number' ? preflight.ln_summary.max_inbound_sats : null; - const lnTotalInboundSats = typeof preflight?.ln_summary?.total_inbound_sats === 'number' ? preflight.ln_summary.total_inbound_sats : null; - const lnWalletSats = typeof (preflight as any)?.ln_summary?.wallet_sats === 'number' ? (preflight as any).ln_summary.wallet_sats : null; - const lnWalletConfirmedSats = - typeof (preflight as any)?.ln_summary?.wallet_confirmed_sats === 'number' - ? (preflight as any).ln_summary.wallet_confirmed_sats - : null; - const lnWalletReservedAnchorSats = - typeof (preflight as any)?.ln_summary?.wallet_reserved_anchor_sats === 'number' - ? (preflight as any).ln_summary.wallet_reserved_anchor_sats - : null; - const solLamportsAvailable = - typeof (preflight as any)?.sol_balance === 'number' - ? Number((preflight as any).sol_balance) - : typeof (preflight as any)?.sol_balance === 'string' && /^[0-9]+$/.test(String((preflight as any).sol_balance).trim()) - ? Number.parseInt(String((preflight as any).sol_balance).trim(), 10) - : typeof (solBalance as any)?.lamports === 'number' - ? Number((solBalance as any).lamports) - : null; - const usdtBalanceAtomic = - String((preflight as any)?.sol_usdt?.amount || '').trim() || - String(walletUsdtAtomic || '').trim() || - ''; - const lnImpl = String((preflight as any)?.env?.ln?.impl || (preflight as any)?.ln_info?.implementation || envInfo?.ln?.impl || '').trim().toLowerCase(); - const lnBackend = String(envInfo?.ln?.backend || ''); - const lnRebalanceSupported = lnImpl === 'lnd'; - const lnRebalanceMinChannelsOk = lnActiveChannelCount >= 2; - const lnSpliceBackendSupported = lnImpl === 'cln'; - const lnUnlockHelperSupported = lnImpl === 'lnd' && lnBackend === 'docker'; - const lnNetwork = String(envInfo?.ln?.network || ''); - const isLnRegtestDocker = lnBackend === 'docker' && lnNetwork === 'regtest'; - const solKind = String(preflight?.env?.solana?.classify?.kind || envInfo?.solana?.classify?.kind || ''); - const solLocalUp = solKind !== 'local' || Boolean(preflight?.sol_local_status?.rpc_listening); - const solConfigOk = !preflight?.sol_config_error; - const needSolLocalStart = solKind === 'local' && !solLocalUp; - const needLnBootstrap = isLnRegtestDocker && !lnWalletLocked && (lnChannelCount < 1 || Boolean(preflight?.ln_listfunds_error)); - const autopostJobs = Array.isArray((preflight as any)?.autopost?.jobs) ? (preflight as any).autopost.jobs : []; - const offerAutopostJobs = autopostJobs.filter((j: any) => String(j?.tool || '') === 'intercomswap_offer_post'); - const rfqAutopostJobs = autopostJobs.filter((j: any) => String(j?.tool || '') === 'intercomswap_rfq_post'); - const tradeAutoStatus = (preflight as any)?.tradeauto && typeof (preflight as any).tradeauto === 'object' - ? ((preflight as any).tradeauto as any) - : null; - - // Sync UI toggles to backend truth (so reloads / remote restarts do not desync the buttons). - useEffect(() => { - if (!tradeAutoStatus || typeof tradeAutoStatus !== 'object') return; - if (typeof tradeAutoStatus.running === 'boolean') setTradeAutoWorkerEnabled(Boolean(tradeAutoStatus.running)); - if (typeof tradeAutoStatus.trace_enabled === 'boolean') setTradeAutoTraceEnabled(Boolean(tradeAutoStatus.trace_enabled)); - }, [tradeAutoStatus?.running, tradeAutoStatus?.trace_enabled]); - const tradeAutoRecent = useMemo(() => { - const rows = Array.isArray(tradeAutoStatus?.recent_events) ? tradeAutoStatus.recent_events : []; - return rows - .slice() - .reverse() - .map((evt: any, idx: number) => ({ - _idx: idx, - ts: typeof evt?.ts === 'number' ? evt.ts : null, - type: String(evt?.type || 'trace'), - stage: String(evt?.stage || '').trim() || null, - trade_id: String(evt?.trade_id || '').trim() || null, - channel: String(evt?.channel || '').trim() || null, - sig: String(evt?.sig || '').trim() || null, - error: String(evt?.error || '').trim() || null, - cooldown_ms: typeof evt?.cooldown_ms === 'number' ? evt.cooldown_ms : null, - })); - }, [tradeAutoStatus?.recent_events]); - const oracle: OracleSummary = useMemo(() => { - const p = preflight?.price && typeof preflight.price === 'object' ? (preflight.price as any) : null; - return { - ok: Boolean(p?.ok), - ts: typeof p?.ts === 'number' ? p.ts : null, - btc_usd: typeof p?.btc_usd === 'number' && Number.isFinite(p.btc_usd) ? p.btc_usd : null, - btc_usdt: typeof p?.btc_usdt === 'number' && Number.isFinite(p.btc_usdt) ? p.btc_usdt : null, - usdt_usd: typeof p?.usdt_usd === 'number' && Number.isFinite(p.usdt_usd) ? p.usdt_usd : 1, - }; - }, [preflight?.price]); - const lnPeerSuggestions = useMemo( - () => collectLnPeerSuggestions((preflight as any)?.ln_listpeers), - [preflight?.ln_listpeers] - ); - const lnConnectedPeerSuggestions = useMemo(() => lnPeerSuggestions.filter((p) => p.connected), [lnPeerSuggestions]); - const lnPeerFailoverKeyRef = useRef(''); - const lnPeerFailoverSeenRef = useRef<{ nodeId: string; wasConnected: boolean } | null>(null); - const lnDefaultPeerAutoConnectLastAttemptAtRef = useRef(0); - const lnDefaultPeerWasConnectedRef = useRef(false); - - // Mainnet UX: automatically (re)connect to the default peer (ACINQ) so operators don't end up - // with an isolated topology that causes NO_ROUTE. This is best-effort and throttled to avoid - // spamming lncli under unstable networks. - useEffect(() => { - const kind = String(envInfo?.env_kind || '').trim().toLowerCase(); - if (kind !== 'mainnet') return; - if (!stackAnyRunning) return; - if (lnWalletLocked) return; - const peer = lnPeerInput.trim(); - if (!peer || peer !== ACINQ_PEER_URI) return; - const connected = lnConnectedPeerSuggestions.some((p) => p.id === ACINQ_NODE_ID); - if (connected) { - lnDefaultPeerWasConnectedRef.current = true; - return; - } - if (runBusy) return; - - const now = Date.now(); - const lastAttemptAt = Number(lnDefaultPeerAutoConnectLastAttemptAtRef.current || 0); - const wasConnected = Boolean(lnDefaultPeerWasConnectedRef.current); - const cooldownMs = wasConnected ? 5_000 : 30_000; - if (lastAttemptAt > 0 && now - lastAttemptAt < cooldownMs) return; - lnDefaultPeerAutoConnectLastAttemptAtRef.current = now; - - (async () => { - try { - await runToolFinal('intercomswap_ln_connect', { peer: ACINQ_PEER_URI }, { auto_approve: true }); - void refreshPreflight(); - } catch (_e) { - // Keep this quiet; if ACINQ can't be reached the operator can switch peers in Advanced. - } - })(); - }, [envInfo?.env_kind, stackAnyRunning, lnWalletLocked, lnPeerInput, lnConnectedPeerSuggestions, runBusy]); - - useEffect(() => { - const peers = lnConnectedPeerSuggestions; - if (peers.length < 1) return; - const currentRaw = lnPeerInput.trim(); - if (!currentRaw) { - const next = peers[0]; - if (next?.uri) setLnPeerInput(next.uri); - return; - } - if (!lnAutoPeerFailover) return; - const currentNodeId = parseNodeIdFromPeerUri(currentRaw); - if (!currentNodeId) { - const next = peers[0]; - if (next?.uri && next.uri !== currentRaw) { - setLnPeerInput(next.uri); - pushToast('info', `LN peer auto-selected: ${next.id.slice(0, 12)}…`); - } - return; - } - if (!lnPeerFailoverSeenRef.current || lnPeerFailoverSeenRef.current.nodeId !== currentNodeId) { - lnPeerFailoverSeenRef.current = { nodeId: currentNodeId, wasConnected: false }; - } - const currentOk = peers.some((p) => p.id === currentNodeId); - if (currentOk) { - lnPeerFailoverSeenRef.current.wasConnected = true; - lnPeerFailoverKeyRef.current = ''; - return; - } - // Do not auto-failover a peer that was never observed as connected (prevents overriding the mainnet default). - if (!lnPeerFailoverSeenRef.current.wasConnected) return; - const next = peers.find((p) => p.id !== currentNodeId) || peers[0]; - if (!next?.uri || next.uri === currentRaw) return; - const key = `${currentNodeId}->${next.id}`; - if (lnPeerFailoverKeyRef.current === key) return; - lnPeerFailoverKeyRef.current = key; - setLnPeerInput(next.uri); - pushToast('info', `LN peer auto-failover: ${currentNodeId.slice(0, 12)}… -> ${next.id.slice(0, 12)}…`, { ttlMs: 8000 }); - }, [lnConnectedPeerSuggestions, lnPeerInput, lnAutoPeerFailover]); - - return ( -
-
-
- -
- -
-
-
- -
-
- - {navOpen ? ( - - ) : null} - -
- {activeTab === 'overview' ? ( -
- - {!stackGate.ok ? ( -
- {!stackAnyRunning ? ( - <> - Setup required. Click START in the header to bootstrap peer + sidechannels + Lightning + Solana + receipts. - - ) : ( - <> - Stack is running. Complete the remaining checklist items below to enable trading. - - )} -
- ) : null} - {stackGate.invitePolicyWarning ? ( -
- Invite Policy: {stackGate.invitePolicyWarning} -
- ) : null} - -
- - -
- - {!stackGate.ok ? ( -
- Trading setup incomplete. Complete these items to enable trading: -
- {stackGate.reasons.length > 0 ? stackGate.reasons.map((r) => `- ${r}`).join('\n') : '- unknown'} -
-
- ) : ( -
- READY You can post Offers (Buy BTC) and RFQs (Sell BTC). -
- )} - - {lnWalletLocked ? ( -
- Lightning wallet is locked. Unlock the wallet in your LN backend, then press Refresh BTC in Wallets. - {lnWalletLockedMessage ?
{lnWalletLockedMessage}
: null} - {lnUnlockHelperSupported ? ( -
- -
- ) : null} -
- ) : null} - - {!stackGate.ok && stackAnyRunning ? ( -
- {!scConnected ? ( - - ) : null} - {needLnBootstrap ? ( - - ) : null} - {needSolLocalStart ? ( - - ) : null} - {!solConfigOk && solKind === 'local' && solLocalUp ? ( - - ) : null} -
- ) : null} - -
-
- Rendezvous Channels -
- setRendezvousChannelsFromInput(e.target.value)} - placeholder="0000intercomswapbtcusdt" - /> -
- -
-
- Funding -
-
- BTC - - - -
- {lnFundingAddrErr ?
{lnFundingAddrErr}
: null} - {lnWalletSats !== null ? ( -
- wallet: {satsToBtcDisplay(lnWalletSats)} BTC ({lnWalletSats} sats) - {oracle.btc_usd ? {fmtUsd((lnWalletSats / 1e8) * oracle.btc_usd)} : null} -
- ) : null} - -
- SOL - - - - {solBalance !== null && solBalance !== undefined ? ( - - {lamportsToSolDisplay(solBalance)} SOL ({String(solBalance)} lamports) - - ) : null} -
- {solBalanceErr ?
{solBalanceErr}
: null} -
- -
-
- Lightning Channel -
-
- {lnChannelCount > 0 ? {lnChannelCount} channel(s) : no channels} - {needLnBootstrap ? ( - - ) : ( - - )} -
-
-
- - -
- setRendezvousChannelsFromInput(e.target.value)} - placeholder="channels (csv)" - /> - - {scConnected ? 'connected' : scConnecting ? 'connecting' : 'stopped'} - - -
- {scStreamErr ?
feed: {scStreamErr}
: null} -
- setScFilter((p) => ({ ...p, channel: e.target.value }))} - placeholder="filter channel (contains)" - /> - setScFilter((p) => ({ ...p, kind: e.target.value }))} - placeholder="filter kind (contains)" - /> -
-
Filters are substring matches. Example: kind=swap.rfq.
- String(e.db_id || e.seq || e.id || e.ts || '')} - estimatePx={78} - onScroll={onScScroll} - render={(e) => ( - setSelected({ type: 'sc_event', evt: e })} - selected={selected?.type === 'sc_event' && selected?.evt?.seq === e.seq} - /> - )} - /> -
- - -
- - - - {tradeAutoStatus?.trace_enabled ? 'trace on' : 'trace off'} - - - {tradeAutoStatus?.running ? 'worker on' : 'worker off'} - - {tradeAutoStatus?.stats?.actions !== undefined ? ( - actions: {Number(tradeAutoStatus.stats.actions || 0)} - ) : null} - {tradeAutoStatus?.stats?.ticks !== undefined ? ( - ticks: {Number(tradeAutoStatus.stats.ticks || 0)} - ) : null} - {tradeAutoStatus?.stats?.last_tick_at ? ( - last tick: {msToUtcIso(Number(tradeAutoStatus.stats.last_tick_at))} - ) : null} -
- {!tradeAutoStatus?.trace_enabled ? ( -
- Trace is disabled by default. The worker can still trade automatically while trace is off. -
- ) : null} - {preflight?.tradeauto_error ? ( -
- tradeauto_status: {String(preflight.tradeauto_error)} -
- ) : null} - {tradeAutoStatus?.stats?.last_error ? ( -
- last error: {String(tradeAutoStatus.stats.last_error)} -
- ) : null} - {tradeAutoStatus?.trace_enabled ? ( - <> -
- backend trace from intercomswap_tradeauto_status (newest first) -
- `${e.ts || 0}:${e.type}:${e.stage || ''}:${e.trade_id || ''}:${e._idx}`} - estimatePx={84} - render={(e: any) => ( -
-
- {String(e.type || 'trace')} - {typeof e.ts === 'number' ? msToUtcIso(e.ts) : '—'} -
-
- {e.trade_id ? ( -
- trade: {String(e.trade_id)} -
- ) : null} - {e.stage ? ( -
- stage: {String(e.stage)} -
- ) : null} - {e.channel ? ( -
- channel: {String(e.channel)} -
- ) : null} - {e.sig ? ( -
- sig: {String(e.sig)} -
- ) : null} - {typeof e.cooldown_ms === 'number' ? ( -
- retry: {Math.trunc(e.cooldown_ms)} ms -
- ) : null} - {e.error ? ( -
- {String(e.error)} -
- ) : null} -
-
- )} - /> - - ) : null} -
-
- ) : null} - - {activeTab === 'prompt' ? ( -
- -