Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 46 additions & 13 deletions apps/scan/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -649,17 +649,54 @@ const TOKEN_FACTORY_V1_1_0: Record<NetworkId, { addr: `0x${string}`; fromBlock:

const LOGS_CHUNK_SIZE = 5_000;

// Module-level cache for the chain-walk result. Without this every page
// mount re-walks the factory event range — ~110 eth_getLogs / page on
// mainnet (we caught it at 2026-05-11 during the deep audit). The token
// list is append-only and changes maybe a few times per hour at most;
// a 5-minute TTL is the right balance between freshness and not
// hammering the RPC. In-flight dedup so two simultaneous callers share
// the same walk instead of doubling it.
type FactoryCacheEntry = {
data: TokenData[];
fetchedAt: number;
inFlight: Promise<TokenData[]> | null;
};
const FACTORY_CACHE: Record<NetworkId, FactoryCacheEntry> = {
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<TokenData[]> {
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<TokenData[]> {

// 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 }),
Expand Down Expand Up @@ -789,14 +826,12 @@ export async function fetchToken(network: NetworkId, address: string): Promise<T

async function fetchEvmTokenFromChain(network: NetworkId, address: string): Promise<TokenData | null> {
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<string | null> {
try {
const res = await fetch(`${base}/rpc`, {
const res = await fetch(rpcUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
Expand Down Expand Up @@ -1212,17 +1247,15 @@ export async function fetchEventLogs(
fromBlock: number | "earliest" = "earliest",
toBlock: number | "latest" = "latest",
): Promise<EventLog[]> {
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
// would hang the page render forever. 8s matches the apiFetch default.
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({
Expand Down
10 changes: 10 additions & 0 deletions apps/scan/lib/chain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
53 changes: 41 additions & 12 deletions apps/scan/lib/search-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ async function probeTx(network: NetworkId, hash: `0x${string}`): Promise<boolean
}
}

// 64-hex strings are ambiguous between tx hashes and block hashes. We
// probe both in parallel; if a block matches we resolve the height so
// the caller can route to /blocks/<height> (the only block route is
// height-keyed).
async function probeBlockByHash(
network: NetworkId,
hash: `0x${string}`,
): Promise<bigint | null> {
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,
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions scripts/audit-live.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
17 changes: 17 additions & 0 deletions scripts/audit-static.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
80 changes: 80 additions & 0 deletions scripts/scan-deep-audit.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading
Loading