deviant/scripts/lib/chipnet-multisource.mjs
Local Dev 6130c0cab1 feat(asm): Potidaea swap-relay + tally refresh to 52,998
Prove the Potidaea platform-fee revenue model live on chipnet with two
end-to-end swap-relay tests, then sweep the cumulative chipnet-swap tally
from "17,000+" to the exact figure 52,998 across every referencing doc.

Snapshot 0239 (four revisions) captures the full arc — GMX Potidaea
(9,296 swaps · 5.47 M platform bits) → OpenMint Potidaea (26,599 swaps ·
15.02 M platform bits · 30.03 M founder bits · 44 swaps/sec peak · 2:1
founder/platform ratio matching ADR-0053 spec exactly). Client-added
platform-fee output at tx.outputs[3] — no covenant change required.

Ships the multi-source connectivity layer used throughout:
scripts/lib/chipnet-multisource.mjs (race local Fulcrum + BCHN RPC +
public electrums, chipnet-only endpoint list, wrong-network tip-guard,
belt-and-braces broadcast) and scripts/lib/chipnet-keystore.mjs (single
Keys/chipnet-keystore.json for all wallets, legacy per-file fallback).
Keys/ added to .gitignore.

Also: GMX/Game.X zero-bet-fee policy — chipnet-gmx-game-loop-test.mjs
now returns 0n from escrowFee() by default (feeAmount=0 on the escrow
covenant; already covered by the "if feeAmount > 0" gate at
GamePoolEscrowFlat.cash:54, no covenant change needed). Old formula
kept behind ARBITER_FEE_LEGACY=1 env flag.

Canonical tally table lives in
Parameters/ASM_Native/ASM-single-asset-throughput.md §4a; update it
first when the next throughput runs land, then grep-replace the exact
number across dependent files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-08-14 01:39:41 +02:00

227 lines
11 KiB
JavaScript

/**
* chipnet-multisource.mjs — multi-source connectivity for chipnet scripts.
*
* Rule (see memory `chipnet-multi-source-connectivity`): never depend on a single
* data source. Query local BCHN RPC + local Fulcrum + public electrum servers in
* parallel with fastest-wins/failover. Broadcast belt-and-braces to local + one
* public. If any source is down, workflow proceeds on the others.
*
* Exports:
* - listUnspent(cashaddr) → UTXOs incl. mempool, from whichever source answers first
* - broadcast(rawHex) → sends to local BCHN + one public electrum; first success wins
* - getTip() → chain tip height from the fastest source
* - rpc(method, params) → straight BCHN RPC call (creds from bitcoin.conf)
* - scantxoutset(addr) → LAST-RESORT UTXO scan; only used when all electrums fail
*
* Chipnet endpoints (verified working):
* - ws://127.0.0.1:50003 (local Fulcrum, mempool-aware, own machine)
* - wss://chipnet.bch.ninja:50004
* - wss://chipnet.imaginary.cash:50004
* - wss://tbch4.loping.net:62002
* - http://127.0.0.1:48332 (local BCHN JSON-RPC, creds in bitcoin.conf)
*/
import http from 'node:http';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import WebSocket from 'ws';
import { decodeCashAddress } from '@bitauth/libauth';
// ── Electrum endpoints (chipnet). Local first; publics as failover peers. ──
// CHIPNET electrum endpoints. Public chipnet coverage is thin — only two
// providers consistently expose WSS. Note: `tbch.loping.net:60004` and
// `tbch4.loping.net:62002` serve **mainnet** data (verified Aug 2026 — the
// connectivity check caught a chipnet tip of 318k vs their 1.7M), so they
// belong in a mainnet endpoint list, not this one. If/when a mainnet script
// is added, build a separate MAINNET_ENDPOINTS array and include loping there.
export const ELECTRUM_ENDPOINTS = [
{ url: 'ws://127.0.0.1:50003', label: 'local-fulcrum', own: true },
{ url: 'wss://chipnet.bch.ninja:50004', label: 'bch.ninja' },
{ url: 'wss://chipnet.imaginary.cash:50004', label: 'imaginary.cash' },
];
// Sanity-guard height. Any endpoint claiming a tip above this must be on a
// different network (mainnet is ~1.7M at time of writing; chipnet is ~318k).
// electrumFastest tip-verifies each endpoint against this before use — a
// wrong-network endpoint here can never poison a wallet read.
export const CHIPNET_MAX_EXPECTED_HEIGHT = 500_000;
// ── BCHN RPC (creds from bitcoin.conf; never hardcode) ──
export function rpcCreds() {
if (process.env.RPC_USER && process.env.RPC_PASS) return { user: process.env.RPC_USER, pass: process.env.RPC_PASS };
for (const p of ['D:\\Program Files\\ChipnetData\\bitcoin.conf', 'D:\\ChipnetData\\bitcoin.conf']) {
try {
const t = readFileSync(p, 'utf8');
const u = t.match(/^\s*rpcuser=(.+)$/m)?.[1]?.trim();
const pw = t.match(/^\s*rpcpassword=(.+)$/m)?.[1]?.trim();
if (u && pw) return { user: u, pass: pw };
} catch { /* next path */ }
}
throw new Error('BCHN RPC creds not found — set RPC_USER/RPC_PASS or ensure bitcoin.conf has rpcuser/rpcpassword');
}
let _auth = null;
function rpcAuth() {
if (_auth) return _auth;
const { user, pass } = rpcCreds();
_auth = 'Basic ' + Buffer.from(`${user}:${pass}`).toString('base64');
return _auth;
}
export function rpc(method, params = [], { timeoutMs = 10_000 } = {}) {
return new Promise((resolve, reject) => {
const body = JSON.stringify({ jsonrpc: '1.0', id: 't', method, params });
const req = http.request(
{ hostname: '127.0.0.1', port: 48332, path: '/', method: 'POST',
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), authorization: rpcAuth() } },
(res) => { const c = []; res.on('data', (d) => c.push(d)); res.on('end', () => {
try { const j = JSON.parse(Buffer.concat(c).toString()); j.error ? reject(new Error(j.error.message)) : resolve(j.result); }
catch (e) { reject(e); }
}); });
req.setTimeout(timeoutMs, () => { req.destroy(new Error(`BCHN RPC timeout after ${timeoutMs}ms`)); });
req.on('error', reject);
req.end(body);
});
}
// ── Electrum WSS single-request helper (returns [ok, source-label, result-or-err]) ──
function electrumRequest(endpoint, method, params, timeoutMs = 8000) {
return new Promise((resolve) => {
const t0 = Date.now();
let done = false;
const finish = (ok, val) => { if (done) return; done = true; try { ws.close(); } catch {} resolve({ ok, label: endpoint.label, url: endpoint.url, val, ms: Date.now() - t0 }); };
let ws;
try {
ws = new WebSocket(endpoint.url, { handshakeTimeout: timeoutMs });
} catch (e) { return finish(false, e); }
const timer = setTimeout(() => finish(false, new Error(`timeout ${timeoutMs}ms`)), timeoutMs);
ws.on('open', () => ws.send(JSON.stringify({ id: 1, method, params }) + '\n'));
ws.on('message', (buf) => {
clearTimeout(timer);
try {
const j = JSON.parse(buf.toString().trim());
if (j.error) return finish(false, new Error(j.error.message ?? JSON.stringify(j.error)));
return finish(true, j.result);
} catch (e) { return finish(false, e); }
});
ws.on('error', (e) => { clearTimeout(timer); finish(false, e); });
});
}
// ── Fastest-wins query across ALL electrum endpoints for a read-only method ──
// If `verifyChipnet: true` (default), first probe every endpoint's tip in
// parallel and blacklist any whose height falls outside the chipnet window —
// prevents a mis-configured mainnet endpoint from corrupting a wallet read.
const wrongNetworkEndpoints = new Set(); // remembers bad endpoints for this process lifetime
async function verifyEndpointOnChipnet(ep, timeoutMs) {
if (wrongNetworkEndpoints.has(ep.url)) return false;
const r = await electrumRequest(ep, 'blockchain.headers.subscribe', [], timeoutMs);
if (!r.ok) return false; // let the actual call raise the failure downstream
const h = r.val?.height;
if (typeof h !== 'number') return false;
if (h > CHIPNET_MAX_EXPECTED_HEIGHT) {
wrongNetworkEndpoints.add(ep.url);
console.error(`[chipnet-multisource] blacklisting ${ep.label} (${ep.url}) — tip h=${h} is out of chipnet range (< ${CHIPNET_MAX_EXPECTED_HEIGHT})`);
return false;
}
return true;
}
export async function electrumFastest(method, params, { endpoints = ELECTRUM_ENDPOINTS, timeoutMs = 8000, verifyChipnet = true } = {}) {
let active = endpoints;
if (verifyChipnet) {
const checks = await Promise.all(endpoints.map((ep) => verifyEndpointOnChipnet(ep, timeoutMs).then((ok) => ({ ep, ok }))));
active = checks.filter((c) => c.ok).map((c) => c.ep);
if (active.length === 0) throw new Error('no chipnet electrum endpoints available (all failed tip-verify or wrong network)');
}
const inflight = active.map((ep) => electrumRequest(ep, method, params, timeoutMs));
return new Promise((resolve, reject) => {
let failures = 0;
const errs = [];
for (const p of inflight) {
p.then((r) => {
if (r.ok) resolve(r);
else { failures++; errs.push(`${r.label}: ${r.val?.message ?? r.val}`); if (failures === inflight.length) reject(new Error(`all electrum sources failed:\n ${errs.join('\n ')}`)); }
});
}
});
}
// ── cashaddr → electrum scripthash — handles P2PKH (20B payload), P2SH32 (32B), P2SH (20B legacy) ──
export function addressToElectrumScriptHash(address) {
const decoded = decodeCashAddress(address);
if (typeof decoded === 'string') throw new Error(`decodeCashAddress: ${decoded}`);
const payload = decoded.payload;
let lock;
if (payload.length === 20) {
lock = new Uint8Array(25);
lock[0] = 0x76; lock[1] = 0xa9; lock[2] = 0x14; lock.set(payload, 3); lock[23] = 0x88; lock[24] = 0xac;
} else if (payload.length === 32) {
// P2SH32 (contract token addresses): OP_HASH256 <32 push> <hash> OP_EQUAL
lock = new Uint8Array(35);
lock[0] = 0xaa; lock[1] = 0x20; lock.set(payload, 2); lock[34] = 0x87;
} else {
throw new Error(`unsupported cashaddr payload length ${payload.length} for ${address}`);
}
const hash = createHash('sha256').update(lock).digest();
return Buffer.from(hash.reverse()).toString('hex');
}
// ── UTXO listing — mempool-aware. Race all electrum endpoints; only fall back
// to scantxoutset if EVERY electrum failed (electrum is O(1); scan is O(N)).
export async function listUnspent(cashaddr, { includeTokens = true, endpoints, timeoutMs } = {}) {
const sh = addressToElectrumScriptHash(cashaddr);
const method = 'blockchain.scripthash.listunspent';
const params = includeTokens ? [sh, 'include_tokens'] : [sh];
try {
const winner = await electrumFastest(method, params, { endpoints, timeoutMs });
return { source: winner.label, utxos: (winner.val ?? []).map((u) => ({
txid: u.tx_hash, vout: u.tx_pos, satoshis: BigInt(u.value), height: u.height,
token: u.token_data ? { ...u.token_data, amount: BigInt(u.token_data.amount) } : undefined,
})) };
} catch (electrumErr) {
// fallback: BCHN scantxoutset (confirmed-only; misses mempool coins)
const scan = await rpc('scantxoutset', ['start', [{ desc: `addr(${cashaddr})` }]], { timeoutMs: 60_000 });
return { source: 'bchn-rpc-fallback', utxos: (scan.unspents ?? []).map((u) => ({
txid: u.txid, vout: u.vout, satoshis: BigInt(Math.round(u.amount * 1e8)), height: u.height,
token: u.tokenData ? { ...u.tokenData, amount: BigInt(u.tokenData.amount) } : undefined,
})), warning: `electrum failed: ${electrumErr.message}` };
}
}
// ── Chain tip — fastest source wins ──
export async function getTip() {
try {
const winner = await electrumFastest('blockchain.headers.subscribe', []);
return { source: winner.label, height: winner.val.height };
} catch {
const h = await rpc('getblockcount');
return { source: 'bchn-rpc-fallback', height: h };
}
}
// ── Broadcast belt-and-braces — send to local BCHN AND one public electrum in parallel; first success wins.
// Other legs still race to propagate through their own peers; a failure on one leg is not fatal.
export async function broadcast(rawHex, { alsoElectrum = true } = {}) {
const attempts = [
rpc('sendrawtransaction', [rawHex]).then((v) => ({ ok: true, source: 'bchn-rpc', val: v }), (e) => ({ ok: false, source: 'bchn-rpc', val: e })),
];
if (alsoElectrum) {
const publics = ELECTRUM_ENDPOINTS.filter((e) => !e.own);
for (const ep of publics.slice(0, 2)) {
attempts.push(electrumRequest(ep, 'blockchain.transaction.broadcast', [rawHex], 10_000).then((r) => ({ ok: r.ok, source: r.label, val: r.val })));
}
}
return new Promise((resolve, reject) => {
let failed = 0;
const errs = [];
const results = [];
for (const p of attempts) {
p.then((r) => {
results.push(r);
if (r.ok) resolve({ ...r, results });
else { failed++; errs.push(`${r.source}: ${r.val?.message ?? r.val}`); if (failed === attempts.length) reject(new Error(`all broadcast paths failed:\n ${errs.join('\n ')}`)); }
});
}
});
}