From 2848bb448349d23a26a33e433e47eb53db2d0e62 Mon Sep 17 00:00:00 2001 From: Sentinel-Autonomybuilder Date: Sat, 2 May 2026 13:34:32 -0700 Subject: [PATCH] chore(defaults): consensus-based audit, drop stale fallbacks (2026-05-02) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-05-02 follow-up to PR #28 surfaced two issues with the audit: 1. The previous script hardcoded EXPECTED_UDVPN as a constant. The audited wallet had a small outflow between PR authoring and merge, so re-running the script declared 0/22 healthy when in fact 12 endpoints all agreed on the new (correct) balance and only rpc.sentinel.co was actually broken. 2. defaults.js still listed rpc.sentinel.co / lcd.sentinel.co as a "stale fallback" entry. Today's audit confirms both are still ~22k blocks behind tip and return 0 for funded addresses while reporting catching_up=false. Keeping them in the array meant a tryWithFallback() call could still hit them after every other endpoint failed and silently return wrong data. Changes: - tools/audit-rpc-endpoints.mjs — replace hardcoded EXPECTED_UDVPN with a consensus check (modal balance across responding candidates, within 50 blocks of tip). Survives any future outflow from the audit address. Also audits LCD endpoints in the same run; the previous script only covered RPC. - defaults.js — drop rpc.sentinel.co and lcd.sentinel.co. Re-sort RPC list by today's measured latency. Expand LCD list 6 -> 9 entries (Roomit, ChainTools, ChainVibes, Validatus all newly verified). Audit results (2026-05-02, consensus mode): - RPC: 12/22 healthy, 12/13 responding agree on balance - LCD: 9/12 healthy, 9/10 responding agree on balance Run: node tools/audit-rpc-endpoints.mjs Test plan - node -e "import('./defaults.js').then(d => console.log(d.DEFAULT_RPC, d.DEFAULT_LCD))" -> Busurnode for both - Audit script exits 0 with both RPC and LCD tier-1 lists populated --- defaults.js | 55 +++++----- tools/audit-rpc-endpoints.mjs | 187 ++++++++++++++++++++++++---------- 2 files changed, 166 insertions(+), 76 deletions(-) diff --git a/defaults.js b/defaults.js index c3916ee..41d8126 100644 --- a/defaults.js +++ b/defaults.js @@ -46,43 +46,48 @@ export const CHAIN_VERSION = 'v12.0.0'; // sentinelhub version export const COSMOS_SDK_VERSION = '0.47.17'; // ─── RPC Endpoints (TX broadcast) ──────────────────────────────────────────── -// Ordered by measured latency from a 22-candidate audit on 2026-04-30 against -// a known funded address (verified each endpoint returned the correct on-chain -// balance, not just /status). Run scripts/audit-rpc-endpoints.mjs to refresh. +// Latency-sorted list of endpoints that passed consensus health check on +// 2026-05-02. "Healthy" means: connects, /status reports catching_up=false, +// and the bank balance for a known address matches the modal answer across +// all responding candidates within 50 blocks of tip. Run +// `node tools/audit-rpc-endpoints.mjs` to refresh. // -// rpc.sentinel.co is intentionally LAST: on 2026-04-30 it was ~22k blocks -// behind tip and serving stale ABCI state (returned 0 for funded addresses). -// Kept as last-resort fallback because some integrators hardcode it. +// rpc.sentinel.co / lcd.sentinel.co are intentionally excluded: on 2026-05-02 +// they were ~22k blocks behind tip and returning 0 for funded addresses while +// reporting catching_up=false on /status. Consumers that need them can add +// them at runtime via addRpcEndpoint() / addLcdEndpoint(). export const RPC_ENDPOINTS = [ - { url: 'https://rpc-sentinel.busurnode.com', name: 'Busurnode', verified: '2026-05-02' }, + { url: 'https://rpc-sentinel.busurnode.com', name: 'Busurnode', verified: '2026-05-02' }, + { url: 'https://rpc.trinitystake.io', name: 'Trinity Stake', verified: '2026-05-02' }, { url: 'https://sentinel-rpc.publicnode.com', name: 'PublicNode (Allnodes)', verified: '2026-05-02' }, - { url: 'https://rpc.trinitystake.io', name: 'Trinity Stake', verified: '2026-05-02' }, - { url: 'https://rpc.sentinel.validatus.com', name: 'Validatus', verified: '2026-05-02' }, - { url: 'https://sentinel-rpc.polkachu.com', name: 'Polkachu', verified: '2026-05-02' }, - { url: 'https://rpc.dvpn.roomit.xyz', name: 'Roomit', verified: '2026-05-02' }, - { url: 'https://rpc.sentinel.quokkastake.io', name: 'QuokkaStake', verified: '2026-05-02' }, - { url: 'https://rpc.sentinel.suchnode.net', name: 'SuchNode', verified: '2026-05-02' }, - { url: 'https://rpc-sentinel.chainvibes.com', name: 'ChainVibes', verified: '2026-05-02' }, - { url: 'https://rpc.sentineldao.com', name: 'Sentinel Growth DAO', verified: '2026-05-02' }, - { url: 'https://rpc.mathnodes.com', name: 'MathNodes', verified: '2026-05-02' }, - { url: 'https://rpc.sentinel.chaintools.tech', name: 'ChainTools', verified: '2026-05-02' }, - { url: 'https://rpc.sentinel.co:443', name: 'Sentinel Official (stale fallback)', verified: '2026-05-02' }, + { url: 'https://sentinel-rpc.polkachu.com', name: 'Polkachu', verified: '2026-05-02' }, + { url: 'https://rpc.mathnodes.com', name: 'MathNodes', verified: '2026-05-02' }, + { url: 'https://rpc.dvpn.roomit.xyz', name: 'Roomit', verified: '2026-05-02' }, + { url: 'https://rpc.sentinel.suchnode.net', name: 'SuchNode', verified: '2026-05-02' }, + { url: 'https://rpc.sentinel.chaintools.tech', name: 'ChainTools', verified: '2026-05-02' }, + { url: 'https://rpc.sentinel.validatus.com', name: 'Validatus', verified: '2026-05-02' }, + { url: 'https://rpc.sentinel.quokkastake.io', name: 'QuokkaStake', verified: '2026-05-02' }, + { url: 'https://rpc.sentineldao.com', name: 'Sentinel Growth DAO', verified: '2026-05-02' }, + { url: 'https://rpc-sentinel.chainvibes.com', name: 'ChainVibes', verified: '2026-05-02' }, ]; export const DEFAULT_RPC = RPC_ENDPOINTS[0].url; // ─── LCD Endpoints (REST queries) ──────────────────────────────────────────── -// Same audit methodology as RPC. lcd.sentinel.co also returned stale data on -// 2026-04-30 (0 balance for a funded address) and sits last for parity. +// Same consensus methodology as RPC. lcd.sentinel.co excluded for the same +// stale-state reason. export const LCD_ENDPOINTS = [ - { url: 'https://api-sentinel.busurnode.com', name: 'Busurnode', verified: '2026-05-02' }, - { url: 'https://api.sentinel.suchnode.net', name: 'SuchNode', verified: '2026-05-02' }, - { url: 'https://api.sentinel.quokkastake.io', name: 'QuokkaStake', verified: '2026-05-02' }, - { url: 'https://sentinel-api.polkachu.com', name: 'Polkachu', verified: '2026-05-02' }, + { url: 'https://api-sentinel.busurnode.com', name: 'Busurnode', verified: '2026-05-02' }, { url: 'https://sentinel-rest.publicnode.com', name: 'PublicNode (Allnodes)', verified: '2026-05-02' }, - { url: 'https://lcd.sentinel.co', name: 'Sentinel Official (stale fallback)', verified: '2026-05-02' }, + { url: 'https://api.sentinel.suchnode.net', name: 'SuchNode', verified: '2026-05-02' }, + { url: 'https://sentinel-api.polkachu.com', name: 'Polkachu', verified: '2026-05-02' }, + { url: 'https://api.dvpn.roomit.xyz', name: 'Roomit', verified: '2026-05-02' }, + { url: 'https://api.sentinel.quokkastake.io', name: 'QuokkaStake', verified: '2026-05-02' }, + { url: 'https://api.sentinel.chaintools.tech', name: 'ChainTools', verified: '2026-05-02' }, + { url: 'https://api-sentinel.chainvibes.com', name: 'ChainVibes', verified: '2026-05-02' }, + { url: 'https://api.sentinel.validatus.com', name: 'Validatus', verified: '2026-05-02' }, ]; export const DEFAULT_LCD = LCD_ENDPOINTS[0].url; diff --git a/tools/audit-rpc-endpoints.mjs b/tools/audit-rpc-endpoints.mjs index 0874660..61550d2 100644 --- a/tools/audit-rpc-endpoints.mjs +++ b/tools/audit-rpc-endpoints.mjs @@ -1,37 +1,38 @@ #!/usr/bin/env node /** - * Audit Sentinel RPC + LCD endpoints. + * Audit Sentinel RPC + LCD endpoints (consensus mode). * - * For each candidate, this script verifies: - * 1. Tendermint connect succeeds within 8s - * 2. /status reports `catching_up: false` - * 3. ABCI bank balance query for a known funded address returns the - * expected amount (this is stronger than /status alone -- a node can - * report in-sync while serving stale ABCI state, which is exactly the - * failure mode that bricked rpc.sentinel.co for several weeks) + * Health = endpoint responds AND its (height, balance) matches the modal + * answer across all responding candidates, within 50 blocks of tip. * - * Output is sorted by latency, ready to paste into `defaults.js`. + * Why consensus instead of a hardcoded expected balance: the audited address + * may transact (its balance changes), making any constant in this file go + * stale. Consensus survives that — the truth is whatever the majority of + * responding nodes agree on. A node that disagrees with the majority is + * either stale (rpc.sentinel.co was 22k blocks behind tip and returning 0) + * or operating on a forked / corrupted state. * * Usage: * node tools/audit-rpc-endpoints.mjs - * node tools/audit-rpc-endpoints.mjs + * node tools/audit-rpc-endpoints.mjs * - * The default funded address is a public wallet we control; override if it - * gets drained or moved. + * The default address is a public wallet we control; it doesn't matter what + * the balance IS — only that all healthy nodes agree on the same number. */ +import axios from 'axios'; import { Tendermint37Client } from '@cosmjs/tendermint-rpc'; import { QueryClient, setupBankExtension } from '@cosmjs/stargate'; -import { RPC_ENDPOINTS } from '../defaults.js'; +import { RPC_ENDPOINTS, LCD_ENDPOINTS } from '../defaults.js'; const FUNDED_ADDR = process.argv[2] || 'sent1uav3z70yynp4jnt39c6pg3d6ujw78m52v2h7gs'; -const EXPECTED_UDVPN = process.argv[3] || '10000000000'; +const HEIGHT_TOLERANCE = 50; -// Audit candidates = SDK list + every public Sentinel RPC we've ever seen. -// Add new endpoints here when you discover them. The script will tell you -// which ones to keep in defaults.js. -const EXTRA_CANDIDATES = [ +// Audit candidates = SDK list + every public Sentinel endpoint we've ever seen. +// Add new entries here when you discover them; the script tells you which +// ones to keep in defaults.js. +const RPC_EXTRAS = [ ['https://rpc-sentinel.busurnode.com', 'Busurnode'], ['https://rpc-sentinel-ia.cosmosia.notional.ventures', 'Notional'], ['https://rpc.sentinel.chaintools.tech', 'ChainTools'], @@ -51,17 +52,33 @@ const EXTRA_CANDIDATES = [ ['https://rpc.sentinel.suchnode.net', 'SuchNode'], ]; -// Dedupe by URL: SDK list takes priority for the name field. -const seen = new Set(); -const candidates = []; -for (const ep of RPC_ENDPOINTS) { - if (!seen.has(ep.url)) { seen.add(ep.url); candidates.push([ep.url, ep.name]); } -} -for (const [url, name] of EXTRA_CANDIDATES) { - if (!seen.has(url)) { seen.add(url); candidates.push([url, name]); } +const LCD_EXTRAS = [ + ['https://api.sentinel.chaintools.tech', 'ChainTools'], + ['https://lcd.sentineldao.com', 'Sentinel Growth DAO'], + ['https://api-sentinel.busurnode.com', 'Busurnode'], + ['https://api.sentinel.suchnode.net', 'SuchNode'], + ['https://api.sentinel.quokkastake.io', 'QuokkaStake'], + ['https://sentinel-api.polkachu.com', 'Polkachu'], + ['https://sentinel-rest.publicnode.com', 'PublicNode (Allnodes)'], + ['https://api.dvpn.roomit.xyz', 'Roomit'], + ['https://api.sentinel.validatus.com', 'Validatus'], + ['https://api.mathnodes.com', 'MathNodes'], + ['https://api-sentinel.chainvibes.com', 'ChainVibes'], +]; + +function dedupe(sdkList, extras) { + const seen = new Set(); + const out = []; + for (const ep of sdkList) { + if (!seen.has(ep.url)) { seen.add(ep.url); out.push([ep.url, ep.name]); } + } + for (const [url, name] of extras) { + if (!seen.has(url)) { seen.add(url); out.push([url, name]); } + } + return out; } -async function audit(url, name) { +async function probeRpc(url, name) { const t0 = Date.now(); let tm = null; try { @@ -80,44 +97,112 @@ async function audit(url, name) { q.bank.balance(FUNDED_ADDR, 'udvpn'), new Promise((_, rej) => setTimeout(() => rej(new Error('balance timeout 10s')), 10000)), ]); - const ms = Date.now() - t0; - const balanceOk = bal.amount === EXPECTED_UDVPN; - return { url, name, ok: !catchingUp && balanceOk, height, catchingUp, balance: bal.amount, balanceOk, ms }; + return { url, name, ok: true, height, catchingUp, balance: bal.amount, ms: Date.now() - t0 }; } catch (e) { - const ms = Date.now() - t0; - return { url, name, ok: false, error: e.message, ms }; + return { url, name, ok: false, error: e.message, ms: Date.now() - t0 }; } finally { try { tm && tm.disconnect(); } catch {} } } -console.log(`Auditing ${candidates.length} RPC endpoints against ${FUNDED_ADDR} (expected ${EXPECTED_UDVPN} udvpn)\n`); +async function probeLcd(url, name) { + const t0 = Date.now(); + try { + const [statusResp, balResp] = await Promise.all([ + axios.get(`${url}/cosmos/base/tendermint/v1beta1/blocks/latest`, { timeout: 10000 }), + axios.get(`${url}/cosmos/bank/v1beta1/balances/${FUNDED_ADDR}/by_denom?denom=udvpn`, { timeout: 10000 }), + ]); + const height = Number(statusResp.data?.block?.header?.height || 0); + const balance = balResp.data?.balance?.amount || '0'; + return { url, name, ok: true, height, balance, ms: Date.now() - t0 }; + } catch (e) { + const reason = e.response ? `${e.response.status} ${e.response.statusText}` : e.message; + return { url, name, ok: false, error: reason, ms: Date.now() - t0 }; + } +} -const results = []; -for (const [url, name] of candidates) { - process.stdout.write(` ${name.padEnd(28)} ${url.padEnd(58)} `); - const r = await audit(url, name); - results.push(r); - if (r.ok) console.log(`OK h=${r.height} bal=${r.balance} ${r.ms}ms`); - else if (r.error) console.log(`FAIL ${r.error} (${r.ms}ms)`); - else console.log(`STALE catching=${r.catchingUp} balOk=${r.balanceOk} h=${r.height} bal=${r.balance}`); +function consensus(results, { rejectCatchingUp = false } = {}) { + const responding = results.filter(r => r.ok); + const balCounts = new Map(); + for (const r of responding) balCounts.set(r.balance, (balCounts.get(r.balance) || 0) + 1); + let consensusBal = null, max = 0; + for (const [b, c] of balCounts) if (c > max) { max = c; consensusBal = b; } + const tipHeight = Math.max(0, ...responding.map(r => r.height)); + const healthy = responding + .filter(r => r.balance === consensusBal) + .filter(r => (tipHeight - r.height) <= HEIGHT_TOLERANCE) + .filter(r => !(rejectCatchingUp && r.catchingUp)) + .sort((a, b) => a.ms - b.ms); + return { consensusBal, agree: max, totalResponding: responding.length, tipHeight, healthy }; } -const tier1 = results.filter(r => r.ok).sort((a, b) => a.ms - b.ms); -const tier2 = results.filter(r => !r.ok); +function reasonFor(r, consensusBal, tipHeight) { + if (r.error) return r.error; + if (r.catchingUp) return 'catching_up=true'; + if (r.balance !== consensusBal) return `balance=${r.balance} (consensus=${consensusBal})`; + if ((tipHeight - r.height) > HEIGHT_TOLERANCE) return `behind tip by ${tipHeight - r.height} blocks`; + return 'unknown'; +} -console.log('\n=== TIER 1 — paste this into defaults.js RPC_ENDPOINTS ==='); const today = new Date().toISOString().slice(0, 10); -for (const r of tier1) { + +// ─── RPC ──────────────────────────────────────────────────────────────────── +const rpcCandidates = dedupe(RPC_ENDPOINTS, RPC_EXTRAS); +console.log(`Auditing ${rpcCandidates.length} RPC endpoints against ${FUNDED_ADDR}\n`); + +const rpcResults = []; +for (const [url, name] of rpcCandidates) { + process.stdout.write(` ${name.padEnd(28)} ${url.padEnd(58)} `); + const r = await probeRpc(url, name); + rpcResults.push(r); + if (r.ok) console.log(`resp h=${r.height} bal=${r.balance} catching=${r.catchingUp} ${r.ms}ms`); + else console.log(`FAIL ${r.error} (${r.ms}ms)`); +} + +const rpcVerdict = consensus(rpcResults, { rejectCatchingUp: true }); +const rpcHealthy = new Set(rpcVerdict.healthy); +const rpcUnhealthy = rpcResults.filter(r => !rpcHealthy.has(r)); + +console.log(`\nRPC consensus: balance=${rpcVerdict.consensusBal} (${rpcVerdict.agree}/${rpcVerdict.totalResponding} agree), tip=${rpcVerdict.tipHeight}\n`); +console.log('=== TIER 1 RPC — paste into defaults.js RPC_ENDPOINTS ==='); +for (const r of rpcVerdict.healthy) { const lat = String(r.ms).padStart(5); console.log(` { url: '${r.url}', name: '${r.name}', verified: '${today}' }, // ${lat}ms`); } +console.log('\n=== TIER 2 RPC (excluded) ==='); +for (const r of rpcUnhealthy) { + console.log(` ${r.url.padEnd(58)} ${reasonFor(r, rpcVerdict.consensusBal, rpcVerdict.tipHeight)}`); +} +console.log(`\n${rpcVerdict.healthy.length}/${rpcResults.length} RPC healthy`); + +// ─── LCD ──────────────────────────────────────────────────────────────────── +const lcdCandidates = dedupe(LCD_ENDPOINTS, LCD_EXTRAS); +console.log(`\nAuditing ${lcdCandidates.length} LCD endpoints against ${FUNDED_ADDR}\n`); -console.log('\n=== TIER 2 (failed/stale/wrong-balance) ==='); -for (const r of tier2) { - const reason = r.error || (r.catchingUp ? 'catching_up=true' : !r.balanceOk ? `wrong balance ${r.balance}` : 'unknown'); - console.log(` ${r.url.padEnd(58)} ${reason}`); +const lcdResults = []; +for (const [url, name] of lcdCandidates) { + process.stdout.write(` ${name.padEnd(28)} ${url.padEnd(50)} `); + const r = await probeLcd(url, name); + lcdResults.push(r); + if (r.ok) console.log(`resp h=${r.height} bal=${r.balance} ${r.ms}ms`); + else console.log(`FAIL ${r.error} (${r.ms}ms)`); +} + +const lcdVerdict = consensus(lcdResults); +const lcdHealthy = new Set(lcdVerdict.healthy); +const lcdUnhealthy = lcdResults.filter(r => !lcdHealthy.has(r)); + +console.log(`\nLCD consensus: balance=${lcdVerdict.consensusBal} (${lcdVerdict.agree}/${lcdVerdict.totalResponding} agree), tip=${lcdVerdict.tipHeight}\n`); +console.log('=== TIER 1 LCD — paste into defaults.js LCD_ENDPOINTS ==='); +for (const r of lcdVerdict.healthy) { + const lat = String(r.ms).padStart(5); + console.log(` { url: '${r.url}', name: '${r.name}', verified: '${today}' }, // ${lat}ms`); +} +console.log('\n=== TIER 2 LCD (excluded) ==='); +for (const r of lcdUnhealthy) { + console.log(` ${r.url.padEnd(50)} ${reasonFor(r, lcdVerdict.consensusBal, lcdVerdict.tipHeight)}`); } +console.log(`\n${lcdVerdict.healthy.length}/${lcdResults.length} LCD healthy`); -console.log(`\n${tier1.length}/${results.length} healthy`); -process.exit(tier1.length === 0 ? 1 : 0); +const totalHealthy = rpcVerdict.healthy.length + lcdVerdict.healthy.length; +process.exit(totalHealthy === 0 ? 1 : 0);