diff --git a/apps/scan/lib/api.ts b/apps/scan/lib/api.ts index 607b3c2..f767f21 100644 --- a/apps/scan/lib/api.ts +++ b/apps/scan/lib/api.ts @@ -1,4 +1,4 @@ -import { getApiUrl, type NetworkId } from "./chain"; +import { getApiUrl, getRpcUrl, type NetworkId } from "./chain"; // DECISION: Backend amounts are in "sentri" (1 SRX = 1e8 sentri). The UI displays SRX. // All fetchers do the conversion at the edge so downstream code can treat numbers as SRX. @@ -649,17 +649,54 @@ const TOKEN_FACTORY_V1_1_0: Record | null; +}; +const FACTORY_CACHE: Record = { + mainnet: { data: [], fetchedAt: 0, inFlight: null }, + testnet: { data: [], fetchedAt: 0, inFlight: null }, +}; +const FACTORY_TTL_MS = 5 * 60 * 1000; + async function fetchEvmTokensFromFactory(network: NetworkId): Promise { const cfg = TOKEN_FACTORY_V1_1_0[network]; if (!cfg) return []; + const entry = FACTORY_CACHE[network]; + const fresh = Date.now() - entry.fetchedAt < FACTORY_TTL_MS; + if (fresh && entry.data.length > 0) return entry.data; + if (entry.inFlight) return entry.inFlight; + entry.inFlight = doFetchEvmTokensFromFactory(network, cfg).then((data) => { + entry.data = data; + entry.fetchedAt = Date.now(); + entry.inFlight = null; + return data; + }).catch((err) => { + entry.inFlight = null; + throw err; + }); + return entry.inFlight; +} + +async function doFetchEvmTokensFromFactory( + network: NetworkId, + cfg: { addr: `0x${string}`; fromBlock: number }, +): Promise { + // Get the chain tip in hex so we know when to stop. - const base = network === "testnet" - ? (process.env.NEXT_PUBLIC_TESTNET_API || "https://testnet-api.sentrixchain.com") - : (process.env.NEXT_PUBLIC_MAINNET_API || "https://api.sentrixchain.com"); + const rpcUrl = getRpcUrl(network); let tip = 0; try { - const r = await fetch(`${base}/rpc`, { + const r = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", method: "eth_blockNumber", params: [], id: 1 }), @@ -789,14 +826,12 @@ export async function fetchToken(network: NetworkId, address: string): Promise { const addr = normalizeAddress(address); - const base = network === "testnet" - ? (process.env.NEXT_PUBLIC_TESTNET_API || "https://testnet-api.sentrixchain.com") - : (process.env.NEXT_PUBLIC_MAINNET_API || "https://api.sentrixchain.com"); + const rpcUrl = getRpcUrl(network); const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); async function call(sig: string): Promise { try { - const res = await fetch(`${base}/rpc`, { + const res = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -1212,9 +1247,7 @@ export async function fetchEventLogs( fromBlock: number | "earliest" = "earliest", toBlock: number | "latest" = "latest", ): Promise { - const base = (network === "testnet" - ? (process.env.NEXT_PUBLIC_TESTNET_API || "https://testnet-api.sentrixchain.com") - : (process.env.NEXT_PUBLIC_MAINNET_API || "https://api.sentrixchain.com")); + const rpcUrl = getRpcUrl(network); const fromHex = typeof fromBlock === "number" ? `0x${fromBlock.toString(16)}` : fromBlock; const toHex = typeof toBlock === "number" ? `0x${toBlock.toString(16)}` : toBlock; // 2026-04-30 audit: this function previously had no timeout. A slow RPC @@ -1222,7 +1255,7 @@ export async function fetchEventLogs( const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 8000); try { - const res = await fetch(`${base}/rpc`, { + const res = await fetch(rpcUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ diff --git a/apps/scan/lib/chain.ts b/apps/scan/lib/chain.ts index cc3c69d..3790c0d 100644 --- a/apps/scan/lib/chain.ts +++ b/apps/scan/lib/chain.ts @@ -49,6 +49,16 @@ export function getApiUrl(network: NetworkId) { : (process.env.NEXT_PUBLIC_MAINNET_API || "https://api.sentrixchain.com"); } +// Canonical JSON-RPC endpoint. Use this — never compose `${apiUrl}/rpc`, +// which lands on a CORS-stripped Caddy passthrough that's both wrong +// architecturally and silently breaks browser fetches on testnet (the +// audit on 2026-05-11 caught 100+ requests/page going to the wrong host). +export function getRpcUrl(network: NetworkId) { + return network === "testnet" + ? (process.env.NEXT_PUBLIC_TESTNET_RPC || "https://testnet-rpc.sentrixchain.com") + : (process.env.NEXT_PUBLIC_MAINNET_RPC || "https://rpc.sentrixchain.com"); +} + export function getWsUrl(network: NetworkId) { return network === "testnet" ? (process.env.NEXT_PUBLIC_TESTNET_WS || "wss://testnet-rpc.sentrixchain.com/ws") diff --git a/apps/scan/lib/search-validate.ts b/apps/scan/lib/search-validate.ts index ab8c621..f3c5dc1 100644 --- a/apps/scan/lib/search-validate.ts +++ b/apps/scan/lib/search-validate.ts @@ -54,6 +54,23 @@ async function probeTx(network: NetworkId, hash: `0x${string}`): Promise (the only block route is +// height-keyed). +async function probeBlockByHash( + network: NetworkId, + hash: `0x${string}`, +): Promise { + try { + const client = createClient(network); + const block = await client.getBlock({ blockHash: hash }); + return block?.number ?? null; + } catch { + return null; + } +} + export async function validateAndResolveSearch( network: NetworkId, query: string, @@ -77,25 +94,37 @@ export async function validateAndResolveSearch( return { kind: "not_found", reason: `Block #${q} not found on either network` }; } - // Transaction hash — accept both 0x-prefixed (wallet shape) and bare - // 64-hex (Sentrix internal shape). viem getTransaction needs the 0x - // form, so we prepend if missing before probing. The href routes - // through with whatever the user typed — fetchTransaction strips 0x - // again for the REST call internally. + // 64-hex — ambiguous between tx hash and block hash. Accept both + // 0x-prefixed (wallet shape) and bare (Sentrix internal shape). + // viem needs the 0x form for both getTransaction + getBlock, so we + // prepend if missing before probing. Run all four probes (tx + block + // on current + other network) in parallel — at chain-RPC latencies + // (~50ms each) the cost is dominated by the slowest, not the sum. if (/^(0x)?[a-fA-F0-9]{64}$/.test(q)) { const probeHash = (q.startsWith("0x") ? q : `0x${q}`) as `0x${string}`; - const [hereOk, otherOk] = await Promise.all([ + const other = OTHER[network]; + const [txHere, txOther, blockHere, blockOther] = await Promise.all([ probeTx(network, probeHash), - probeTx(OTHER[network], probeHash), + probeTx(other, probeHash), + probeBlockByHash(network, probeHash), + probeBlockByHash(other, probeHash), ]); - if (hereOk) return { kind: "tx", href: `/tx/${q}`, onNetwork: network }; - if (otherOk) + if (txHere) return { kind: "tx", href: `/tx/${q}`, onNetwork: network }; + if (blockHere !== null) + return { kind: "block", href: `/blocks/${blockHere}`, onNetwork: network }; + if (txOther) return { kind: "tx", - href: withNetwork(`/tx/${q}`, network, OTHER[network]), - onNetwork: OTHER[network], + href: withNetwork(`/tx/${q}`, network, other), + onNetwork: other, + }; + if (blockOther !== null) + return { + kind: "block", + href: withNetwork(`/blocks/${blockOther}`, network, other), + onNetwork: other, }; - return { kind: "not_found", reason: "Transaction not found on either network" }; + return { kind: "not_found", reason: "No transaction or block with that hash on either network" }; } // Address — viem's `isAddress` accepts 0x-prefixed only. Sentrix's diff --git a/scripts/audit-live.mjs b/scripts/audit-live.mjs index 3498258..ee381a5 100755 --- a/scripts/audit-live.mjs +++ b/scripts/audit-live.mjs @@ -24,11 +24,17 @@ const JSON_OUT = process.argv.includes("--json"); // fires without actually owning a real testnet tx). Those console 404s // are filtered out of the issue count. const SURFACE = [ - { app: "scan", url: "https://scan.sentrixchain.com" }, - { app: "scan-tx-mainnet", url: "https://scan.sentrixchain.com/tx/0xdeadbeef", expectNotFound: true }, - { app: "scan-tx-testnet", url: "https://scan.sentrixchain.com/tx/0xdeadbeef?network=testnet", expectNotFound: true }, - { app: "scan-block-mainnet", url: "https://scan.sentrixchain.com/blocks/1" }, - { app: "scan-leaderboard", url: "https://scan.sentrixchain.com/leaderboard" }, + { app: "scan-v1", url: "https://scan.sentrixchain.com" }, + { app: "scan-v1-tx-mainnet", url: "https://scan.sentrixchain.com/tx/0xdeadbeef", expectNotFound: true }, + { app: "scan-v1-tx-testnet", url: "https://scan.sentrixchain.com/tx/0xdeadbeef?network=testnet", expectNotFound: true }, + { app: "scan-v1-block", url: "https://scan.sentrixchain.com/blocks/1" }, + { app: "scan-v1-leaderboard", url: "https://scan.sentrixchain.com/leaderboard" }, + // V2 (Leptos) — separate sentriscloud.com hosts. Coexists with V1 per + // user direction; both monitored permanently. + { app: "scan-v2", url: "https://scan.sentriscloud.com" }, + { app: "scan-v2-block", url: "https://scan.sentriscloud.com/block/1" }, + { app: "scan-v2-testnet", url: "https://scan-testnet.sentriscloud.com" }, + { app: "scan-v2-testnet-blk", url: "https://scan-testnet.sentriscloud.com/block/1" }, { app: "solux", url: "https://solux.sentriscloud.com" }, { app: "sentriscloud", url: "https://sentriscloud.com" }, { app: "sentrixchain", url: "https://sentrixchain.com" }, diff --git a/scripts/audit-static.sh b/scripts/audit-static.sh index c3bce15..83d4ac6 100755 --- a/scripts/audit-static.sh +++ b/scripts/audit-static.sh @@ -183,6 +183,23 @@ else done fi +# ── Rule 10: composing JSON-RPC URL from API base ────────────────────── +# 2026-05-11 audit found scan v1 hitting `${apiBase}/rpc` 100+ times per +# page render. The /rpc path on api.sentrixchain.com is a Caddy +# passthrough that lacks CORS headers and is the wrong host +# architecturally. Always use `getRpcUrl(network)` from chain.ts. +section "BAD: composing JSON-RPC URL from API base (use getRpcUrl(network))" +hits=$(grep -rnE $EXCLUDE 'fetch\([^)]*\$\{[^}]*[Bb]ase\}/rpc|fetch\([^)]*\$\{[^}]*[Aa]piUrl\}/rpc|fetch\([^)]*api\.sentrixchain\.com/rpc' $APPS_GLOB 2>/dev/null || true) +if [ -z "$hits" ]; then + green " clean" +else + echo "$hits" | while IFS= read -r line; do + red " ✗ $line" + done + count=$(echo "$hits" | wc -l) + ISSUES=$((ISSUES + count)) +fi + printf '\n────────────────────────────────────────\n' if [ "$ISSUES" -eq 0 ]; then green "Static audit: no hard errors found." diff --git a/scripts/scan-deep-audit.mjs b/scripts/scan-deep-audit.mjs new file mode 100644 index 0000000..327f6ba --- /dev/null +++ b/scripts/scan-deep-audit.mjs @@ -0,0 +1,80 @@ +// Deep audit: scan v1 — every public route, both networks, capture +// console errors + key visual data points + check loading-vs-data state. + +import { chromium } from "playwright"; + +const BASE = "https://scan.sentrixchain.com"; +const ROUTES = [ + // mainnet + { path: "/", label: "home-mainnet", net: "mainnet" }, + { path: "/blocks", label: "blocks-list-mainnet", net: "mainnet" }, + { path: "/blocks/1681000", label: "block-detail-mainnet", net: "mainnet" }, + { path: "/blocks/1", label: "block-genesis-mainnet",net: "mainnet" }, + { path: "/validators", label: "validators-mainnet", net: "mainnet" }, + { path: "/leaderboard", label: "leaderboard-mainnet", net: "mainnet" }, + { path: "/tokens", label: "tokens-mainnet", net: "mainnet" }, + { path: "/address/0x87c9976d4b2e360b9fbb87e4bd5442edce2a7511", label: "address-validator", net: "mainnet" }, + { path: "/api-docs", label: "api-docs", net: "mainnet" }, + // testnet variants + { path: "/?network=testnet", label: "home-testnet", net: "testnet" }, + { path: "/blocks?network=testnet", label: "blocks-list-testnet", net: "testnet" }, + { path: "/validators?network=testnet", label: "validators-testnet", net: "testnet" }, +]; + +async function audit() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + + for (const { path, label, net } of ROUTES) { + const page = await ctx.newPage(); + const errors = []; + const warnings = []; + page.on("console", (m) => { + if (m.type() === "error") errors.push(m.text()); + if (m.type() === "warning") warnings.push(m.text()); + }); + page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`)); + + let status = "ok"; + let height = "—"; + let firstSkeleton = "—"; + let mainText = ""; + try { + // domcontentloaded is more reliable than networkidle when the page + // has long-poll WS / 5s polling — networkidle never resolves and + // we time out before extracting anything useful. + const res = await page.goto(BASE + path, { waitUntil: "domcontentloaded", timeout: 25000 }); + status = res?.status() ?? "no-response"; + await page.waitForTimeout(4000); + + const heightCandidates = await page.locator('h1, h2, h3, [class*="height"], [class*="number"]').allInnerTexts().catch(() => []); + const heightMatch = (heightCandidates || []).find((t) => typeof t === "string" && /^[0-9,]{4,}$/.test(t.trim())); + if (heightMatch) height = heightMatch.trim(); + + firstSkeleton = await page.locator('[class*="skeleton"], [class*="Skeleton"], [class*="animate-pulse"]').count(); + mainText = await page.locator('main').first().innerText().catch(() => ""); + if (!mainText) mainText = await page.locator('body').first().innerText().catch(() => ""); + } catch (e) { + status = `nav-fail: ${e.message.slice(0, 80)}`; + } + + const empty = mainText.length < 200; + const errCount = errors.length; + const warnCount = warnings.length; + const stuck = firstSkeleton > 5; + + const flag = (errCount > 0 || empty || stuck || (typeof status === "number" && status >= 400)) + ? "✗" + : (warnCount > 0 || (typeof firstSkeleton === "number" && firstSkeleton > 0)) + ? "?" + : "✓"; + + console.log(`${flag} ${label.padEnd(26)} HTTP=${status} errs=${errCount} warns=${warnCount} skeletons=${firstSkeleton} height=${height} bodyLen=${mainText.length}`); + if (errCount > 0) { + for (const e of errors.slice(0, 3)) console.log(` ↳ ERR: ${e.slice(0, 200)}`); + } + await page.close(); + } + await browser.close(); +} +audit().catch(console.error); diff --git a/scripts/scan-network-trace.mjs b/scripts/scan-network-trace.mjs new file mode 100644 index 0000000..54c72bb --- /dev/null +++ b/scripts/scan-network-trace.mjs @@ -0,0 +1,97 @@ +// Trace every network request a scan v1 page makes — URL, status, timing. +// Use this to figure out WHY skeletons are stuck (which fetches are +// pending / 4xx / 5xx). + +import { chromium } from "playwright"; + +const TARGET = process.argv[2] || "https://scan.sentrixchain.com/"; +const WAIT = Number(process.argv[3] || 8000); + +async function trace() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const page = await ctx.newPage(); + + const reqs = new Map(); + page.on("request", (req) => { + let postBody = null; + if (req.method() === "POST") { + try { postBody = req.postData(); } catch {} + } + reqs.set(req, { url: req.url(), method: req.method(), postBody, start: Date.now(), status: null, body: null }); + }); + page.on("response", async (res) => { + const req = res.request(); + const entry = reqs.get(req); + if (!entry) return; + entry.status = res.status(); + entry.elapsed = Date.now() - entry.start; + if (res.status() >= 400) { + try { entry.body = (await res.text()).slice(0, 300); } catch {} + } + }); + page.on("requestfailed", (req) => { + const entry = reqs.get(req); + if (entry) { + entry.status = "FAIL"; + entry.error = req.failure()?.errorText || "unknown"; + entry.elapsed = Date.now() - entry.start; + } + }); + + await page.goto(TARGET, { waitUntil: "domcontentloaded", timeout: 25000 }).catch((e) => console.log("nav-err:", e.message)); + await page.waitForTimeout(WAIT); + + const all = Array.from(reqs.values()).filter((r) => r.url.includes("sentrixchain") || r.url.includes("sentriscloud")); + const grouped = {}; + for (const r of all) { + const key = r.status >= 400 || r.status === "FAIL" ? "BAD" : r.status === null ? "PENDING" : "OK"; + grouped[key] = grouped[key] || []; + grouped[key].push(r); + } + + console.log(`URL: ${TARGET}`); + console.log(`Total: ${all.length} (BAD=${(grouped.BAD||[]).length} PENDING=${(grouped.PENDING||[]).length} OK=${(grouped.OK||[]).length})`); + console.log(); + + for (const cat of ["BAD", "PENDING", "OK"]) { + if (!grouped[cat]) continue; + console.log(`── ${cat} ──`); + // group by URL pattern (strip params + dynamic ids); for /rpc POSTs + // also break out by JSON-RPC method so we can see exactly what is + // being spammed. + const byPath = {}; + for (const r of grouped[cat]) { + const u = new URL(r.url); + let path = `${u.host}${u.pathname.replace(/\/0x[a-f0-9]+/g, "/").replace(/\/\d+/g, "/")}`; + if (path.endsWith("/rpc") && r.postBody) { + try { + const j = JSON.parse(r.postBody); + const method = Array.isArray(j) ? `BATCH(${j.length}: ${j[0]?.method})` : (j.method || "?"); + path = `${path} [${method}]`; + } catch {} + } + byPath[path] = byPath[path] || { count: 0, statuses: new Set(), elapsed: [] }; + byPath[path].count++; + byPath[path].statuses.add(r.status); + if (r.elapsed) byPath[path].elapsed.push(r.elapsed); + } + const rows = Object.entries(byPath).sort((a,b) => b[1].count - a[1].count); + for (const [path, info] of rows) { + const avgMs = info.elapsed.length ? Math.round(info.elapsed.reduce((a,b)=>a+b,0) / info.elapsed.length) : "—"; + console.log(` ${String(info.count).padStart(3)}x [${[...info.statuses].join(",")}] ${path} (avg ${avgMs}ms)`); + } + console.log(); + } + // Show first BAD body for diagnosis + if (grouped.BAD) { + console.log("── first BAD response bodies (preview) ──"); + for (const r of grouped.BAD.slice(0, 3)) { + console.log(` [${r.status}] ${r.url}`); + if (r.body) console.log(` body: ${r.body.slice(0, 200)}`); + if (r.error) console.log(` err: ${r.error}`); + } + } + await browser.close(); +} +trace().catch(console.error);