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
55 changes: 30 additions & 25 deletions defaults.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
187 changes: 136 additions & 51 deletions tools/audit-rpc-endpoints.mjs
Original file line number Diff line number Diff line change
@@ -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 <funded-address> <expected-udvpn>
* node tools/audit-rpc-endpoints.mjs <funded-address>
*
* 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'],
Expand All @@ -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 {
Expand All @@ -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);
Loading