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>
227 lines
11 KiB
JavaScript
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 ')}`)); }
|
|
});
|
|
}
|
|
});
|
|
}
|