632 lines
61 KiB
JavaScript
632 lines
61 KiB
JavaScript
// adaptoare.mjs (quantum exposure report, C12): the four ADAPTERS that bring observations about an address from a data source the
|
|
// client names (mode 2: the client's own node or index; mode 3: a third-party provider, only with written consent). Each adapter opens
|
|
// connections ONLY to the declared target, returns an observation object for clasifica.mjs, and turns every failure (no answer, an
|
|
// error, a malformed or truncated answer) into a NAMED reason (`error` / `partial`), never into "nothing was found".
|
|
//
|
|
// electrum the Electrum server protocol (JSON-RPC lines over TCP or TLS): server.version, server.features (its genesis_hash names
|
|
// the chain the server serves), blockchain.headers.subscribe (the tip), blockchain.block.header at a checkpoint height
|
|
// (mainnet: bitcoin.mjs punctDeControl), blockchain.scripthash.get_history, blockchain.transaction.get (raw hex). Every
|
|
// transaction is parsed from its raw bytes and must hash to its id, and must pay or spend the script whose history it is in.
|
|
// esplora the HTTP API of an Esplora-style index: /block-height/0 (the genesis block: the chain it serves), the tip (below),
|
|
// /block-height/:h at a checkpoint height, /address/:a, /address/:a/txs (+ /txs/chain/:last for the rest),
|
|
// /scripthash/:h/txs for scripts without an address. Evidence is computed from the hex fields, not from the labels; every
|
|
// transaction is re-serialized from the fields served and must hash to the id the index gives, and must pay or spend the
|
|
// script. The index's counts (chain_stats, mempool_stats) are read only as counts, never as Number(x) of whatever was
|
|
// served. Each path is a separate HTTP request: behind a load balancer the tip and the history may come from different
|
|
// machines, which no answer shows (README, NOT measured).
|
|
// bitcoind Bitcoin Core JSON-RPC: the chain the node serves, read once per report (lantulCore: getblockhash 0, getblockcount,
|
|
// getblockhash at a checkpoint height), scantxoutset (unspent outputs of the script) and, for the transaction ids the client gives
|
|
// (--txids, read with txindex=1), getrawtransaction verbose, read ONCE for the whole report (tranzactiiCore) and shared
|
|
// by every address; each must hash to its id (from `hex`, or re-serialized from the decoded form when a node serves no
|
|
// `hex`), and an id the node does not serve refuses the report before any address is measured, naming the id.
|
|
// Bitcoin Core keeps no address index: without ids, or for an address that none of the given CONFIRMED transactions
|
|
// touches (a touch only in the node's mempool is not one), spends are not searched and the report says so, per address;
|
|
// with ids, an output of the address that the scan no longer finds unspent and that no given transaction spends was
|
|
// spent by a transaction NOT given, and the report says that too (spentNotRead). And since no answer of the node shows
|
|
// the list to be the address's whole history, an address read through this adapter is never "no on-chain evidence of
|
|
// exposure": it is "exposed" or "unmeasured" (historyFromRequesterList, clasifica.mjs).
|
|
// eth-rpc Ethereum JSON-RPC: eth_chainId, eth_blockNumber, eth_syncing and the HEAD block (eth_getBlockByNumber at that number,
|
|
// without transactions: its time and its state root), then the ACCOUNT AT THAT HEAD: eth_getProof at the head number, every
|
|
// node of the proof checked by its hash from that state root (its nonce and its code hash), and eth_getCode at the same
|
|
// number, which must hash to the code hash proven (stareLaCap); nothing is read at 'latest'. Then eth_getBlockByNumber with
|
|
// transactions over a window of blocks to FIND a transaction sent by the address; the sender's key is re-derived from its
|
|
// signature here. The head is judged against the report date (see clasifica.mjs); the head block itself is the endpoint's
|
|
// answer, not checked against the consensus of the chain (README, NOT measured).
|
|
// Electrum and Esplora read the CHAIN the target serves (its genesis block, and on mainnet its block at a checkpoint height) and its TIP
|
|
// before the history (its height and its 80-byte header; clasifica.mjs judges the chain against the address's network and the checkpoint,
|
|
// the header against that chain's proof-of-work limit, and the header time against the report date), and every Bitcoin adapter reads the
|
|
// SIBLING scripts of a P2PKH / P2WPKH / P2PK input (the same HASH160 as P2PKH, P2WPKH and P2SH-P2WPKH; for a P2PK input also its other
|
|
// encodings and its BIP-86 P2TR output: bitcoin.mjs scripturiSurori), Electrum and Esplora from the target by script, Bitcoin Core in the
|
|
// given transactions. Bitcoin Core is asked which chain it serves (its genesis block and its block at a checkpoint height; no tip header,
|
|
// so the proof-of-work limit is not judged there); its scan reads the node's unspent set as of the run, and the block time of each given
|
|
// transaction is kept, so that clasifica.mjs can judge both against the report date.
|
|
// The whole message of a TLS error is the target's text (it names the target's certificate) and is quoted as served.
|
|
// In mode 3 the target's name is resolved first, every resolved address must be public (adrese-private.mjs), and the connection is
|
|
// made to the verified address (lookupFixat), with no redirect followed.
|
|
import net from 'node:net';
|
|
import tls from 'node:tls';
|
|
import http from 'node:http';
|
|
import https from 'node:https';
|
|
import dns from 'node:dns';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
// adrese-private.mjs, the private-address guard shared with the readiness scanner: cloud-gateway/ in the AERE development tree,
|
|
// readiness/ in the public copy (AERE_EXPUNERE_ADRESE_PRIVATE names another file); without it the tool does not start
|
|
const ADRESE_PRIVATE = [process.env.AERE_EXPUNERE_ADRESE_PRIVATE, '../../../cloud-gateway/adrese-private.mjs', '../readiness/adrese-private.mjs'].filter(Boolean).map((p) => path.resolve(path.dirname(fileURLToPath(import.meta.url)), p)).find((p) => fs.existsSync(p));
|
|
if (!ADRESE_PRIVATE) throw new Error('adaptoare.mjs: adrese-private.mjs (the private-address guard) is not beside this tool: cloud-gateway/ in the development tree, readiness/ in the public copy, or AERE_EXPUNERE_ADRESE_PRIVATE');
|
|
const { ipPrivat, lookupFixat } = await import(pathToFileURL(ADRESE_PRIVATE).href);
|
|
import * as B from './bitcoin.mjs';
|
|
import * as E from './ethereum.mjs';
|
|
|
|
/** a refusal of the input, named. `citate`: hex strings that are the requester's OWN input or public chain data computed from it (a
|
|
* transaction id given with --txids), which the command line may print in full; every other long hex run in a message is masked */
|
|
export class Refuz extends Error { constructor(motiv, detaliu, citate = []) { super(detaliu ? motiv + ': ' + detaliu : motiv); this.motiv = motiv; this.citate = citate.map((x) => String(x).toLowerCase()); } }
|
|
export const CAP_TRANZACTII = 2000; // a history larger than this is reported as not measured, not truncated in silence
|
|
const timeoutMs = () => Math.max(1000, Number(process.env.AERE_EXPUNERE_TIMEOUT_MS || 20000));
|
|
/** a message cut to 200 characters, every long hex run masked; a cut that falls inside a [served: ...] quote closes the quote, so that what
|
|
* the target served never reaches the report unquoted (the language gate would then judge it as the tool's own text) */
|
|
const taie = (s) => { let x = String(s ?? '').replace(/[0-9a-fA-F]{40,}/g, '<hex>'); if (x.length > 200) { x = x.slice(0, 200); const i = x.lastIndexOf('[served: '); if (i >= 0 && x.indexOf(']', i) < 0) x += ']'; } return x; };
|
|
/** text the TARGET served (an error body, an error message, a version string, an identifier that is not one), quoted as such:
|
|
* `[served: ...]`. The language gate of raport.mjs judges the tool's text and leaves these quotes out, the way it leaves out what the
|
|
* requester gave verbatim */
|
|
export const servit = (s) => '[served: ' + taie(s).replace(/[[\]]/g, '') + ']';
|
|
/** an identifier the target serves (a transaction id, a block hash) is used in the report only in its own form, 64 hex digits (an
|
|
* Ethereum hash with its 0x); anything else is the target's text and is quoted */
|
|
const ID_BTC = /^[0-9a-f]{64}$/;
|
|
const HASH_ETH = /^0x[0-9a-fA-F]{64}$/;
|
|
const idSauCitat = (x, re) => (typeof x === 'string' && re.test(x) ? x : servit(x));
|
|
/** a JSON-RPC quantity (0x + hex digits) as a BigInt, or null: never a conversion error whose message would carry the served text */
|
|
const cantitate = (x) => (typeof x === 'string' && /^0x[0-9a-fA-F]{1,64}$/.test(x) ? BigInt(x) : null);
|
|
/** 2^53 - 1, the largest integer a JavaScript Number holds exactly: a block number above it is not used as a number */
|
|
const NUMAR_EXACT = 9007199254740991n;
|
|
/** an error of the TLS layer: Node.js ERR_TLS_* / ERR_SSL_*, an OpenSSL error (it carries `library`), or a certificate verification code.
|
|
* Its message is built from what the TARGET presented (ERR_TLS_CERT_ALTNAME_INVALID names every name of its certificate), so it is the
|
|
* target's text and is quoted WHOLE as served; the code, Node's own, stays outside the quote */
|
|
const CODURI_X509 = new Set(['UNABLE_TO_GET_ISSUER_CERT', 'UNABLE_TO_GET_CRL', 'UNABLE_TO_DECRYPT_CERT_SIGNATURE', 'UNABLE_TO_DECRYPT_CRL_SIGNATURE', 'UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY', 'CERT_SIGNATURE_FAILURE', 'CRL_SIGNATURE_FAILURE', 'CERT_NOT_YET_VALID', 'CERT_HAS_EXPIRED', 'CRL_NOT_YET_VALID', 'CRL_HAS_EXPIRED', 'ERROR_IN_CERT_NOT_BEFORE_FIELD', 'ERROR_IN_CERT_NOT_AFTER_FIELD', 'ERROR_IN_CRL_LAST_UPDATE_FIELD', 'ERROR_IN_CRL_NEXT_UPDATE_FIELD', 'OUT_OF_MEM', 'DEPTH_ZERO_SELF_SIGNED_CERT', 'SELF_SIGNED_CERT_IN_CHAIN', 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', 'CERT_CHAIN_TOO_LONG', 'CERT_REVOKED', 'INVALID_CA', 'PATH_LENGTH_EXCEEDED', 'INVALID_PURPOSE', 'CERT_UNTRUSTED', 'CERT_REJECTED', 'HOSTNAME_MISMATCH']);
|
|
const eroareTls = (e) => !!e && typeof e.code === 'string' && (/^ERR_(TLS|SSL)_/.test(e.code) || CODURI_X509.has(e.code) || e.library !== undefined);
|
|
const mesajConexiune = (e) => (eroareTls(e) ? e.code + ' ' + servit(e.message) : e.code ? e.code + ' ' + e.message : e.message);
|
|
/** the 80-byte header of a target's tip -> { height, time, hash, bits }, or { partial } naming what is wrong (bitcoin.mjs antetBloc); the
|
|
* bits are judged in clasifica.mjs against the proof-of-work limit of the chain the target serves */
|
|
function varfDinAntet(height, hexAntet, ceServeste) {
|
|
const a = B.antetBloc(hexAntet);
|
|
if (a.eroare) return { partial: `the header ${ceServeste} for its tip (height ${height}) is not a block header: ${a.eroare}` };
|
|
return { height, time: a.time, hash: a.hash, bits: a.bits };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- targets
|
|
/** "host:port", "tcp://host:port", "tls://host:port" (also ssl://) -> { scheme, host, port } */
|
|
export function tintaElectrum(s) {
|
|
const m = /^(?:(tcp|tls|ssl):\/\/)?\[?([^\]\/:]+|\[[^\]]+\])\]?:(\d{1,5})$/.exec(String(s || '').trim());
|
|
if (!m) throw new Refuz('target', 'the Electrum target must be host:port, tcp://host:port or tls://host:port');
|
|
return { scheme: m[1] === 'ssl' ? 'tls' : (m[1] || 'tcp'), host: m[2].replace(/^\[|\]$/g, ''), port: Number(m[3]) };
|
|
}
|
|
export function tintaHttp(s) {
|
|
let u; try { u = new URL(String(s || '').trim()); } catch { throw new Refuz('target', 'the target must be an http:// or https:// URL'); }
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Refuz('target', 'the target must be an http:// or https:// URL');
|
|
// an IPv6 literal comes back from URL.hostname with its brackets; net.isIP and the resolver want it bare
|
|
return { url: u, host: u.hostname.replace(/^\[|\]$/g, ''), port: Number(u.port) || (u.protocol === 'https:' ? 443 : 80), base: u.href.replace(/\/+$/, '') };
|
|
}
|
|
/** mode 3: resolve and REFUSE any target that is not public; returns the lookup to pin the connection to the verified address */
|
|
export async function rezolvaPublic(host) {
|
|
const ip4 = net.isIP(host);
|
|
let adrese;
|
|
if (ip4) adrese = [{ address: host, family: ip4 }];
|
|
else { try { adrese = await dns.promises.lookup(host, { all: true }); } catch (e) { throw new Refuz('target-unresolved', `the third-party target ${host} does not resolve (${taie(e.code || e.message)})`); } }
|
|
if (!adrese.length) throw new Refuz('target-unresolved', `the third-party target ${host} resolves to nothing`);
|
|
for (const a of adrese) if (ipPrivat(a.address)) throw new Refuz('target-private', `the third-party target ${host} resolves to a non-public address; a third-party provider must be a public service (mode 3 refuses private, loopback, link-local and reserved addresses)`);
|
|
return { adresa: adrese[0], lookup: lookupFixat(adrese[0]) };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- HTTP (JSON), no redirects, one declared target
|
|
export function httpCerere({ url, method = 'GET', body = null, headers = {}, lookup = undefined, auth = null }) {
|
|
return new Promise((resolve, reject) => {
|
|
const u = new URL(url); const mod = u.protocol === 'https:' ? https : http;
|
|
const h = { 'user-agent': 'aere-quantum-exposure-report/1', accept: 'application/json', ...headers };
|
|
if (body !== null) h['content-type'] = 'application/json';
|
|
if (auth) h.authorization = 'Basic ' + Buffer.from(auth, 'utf8').toString('base64');
|
|
const req = mod.request(u, { method, headers: h, ...(lookup ? { lookup } : {}), timeout: timeoutMs() }, (res) => {
|
|
const parts = []; res.on('data', (d) => parts.push(d)); res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(parts).toString('utf8') }));
|
|
});
|
|
req.on('timeout', () => { req.destroy(new Error('timeout after ' + timeoutMs() + ' ms')); });
|
|
req.on('error', (e) => reject(new Error(taie(mesajConexiune(e)))));
|
|
if (body !== null) req.write(typeof body === 'string' ? body : JSON.stringify(body));
|
|
req.end();
|
|
});
|
|
}
|
|
async function jsonRpc(url, method, params, o) {
|
|
const r = await httpCerere({ url, method: 'POST', body: { jsonrpc: '2.0', id: 1, method, params }, lookup: o.lookup, auth: o.auth });
|
|
if (!r.text.trim()) throw new Error(`${method}: empty body (HTTP ${r.status})`);
|
|
let j; try { j = JSON.parse(r.text); } catch { throw new Error(`${method}: the body is not JSON (HTTP ${r.status})`); }
|
|
if (j && j.error) throw new Error(`${method}: endpoint error ${servit(String(j.error.code ?? '') + ' ' + String(j.error.message ?? ''))}`);
|
|
if (!j || !Object.hasOwn(j, 'result')) throw new Error(`${method}: no result in the answer (HTTP ${r.status})`);
|
|
return j.result;
|
|
}
|
|
|
|
/** a count an index serves: a JSON integer, 0 or more, or null (never Number(x), which reads null, '' and [] as 0) */
|
|
const numarator = (x) => (Number.isSafeInteger(x) && x >= 0 ? x : null);
|
|
/**
|
|
* the ids of the transactions of a history served for script `t` that neither pay it nor spend it: an index answers the history of a
|
|
* script with the transactions that touch it, so one that does not is not part of that history. A spend is recognised by its funding
|
|
* (an output of the same history), by the prevout an index serves with it, or by a push or a script in it that hashes to the address
|
|
* (bitcoin.mjs dovezileIntrarii), so a spend served without its funding still counts as touching
|
|
*/
|
|
export function neatinse(t, txs) {
|
|
const iesiri = new Set();
|
|
for (const tx of txs) tx.vout.forEach((o, i) => { if (B.platesteTinta(t, o.scriptPubKey)) iesiri.add(tx.txid + ':' + i); });
|
|
return txs.filter((tx) => !tx.vout.some((o) => B.platesteTinta(t, o.scriptPubKey)) && !tx.vin.some((v) => iesiri.has(v.txid + ':' + v.vout) || v.prevoutPaysTarget === true || B.dovezileIntrarii(t, v, false).length > 0)).map((tx) => tx.txid);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- evidence from a list of parsed transactions
|
|
/** txs: [{ txid, height, unconfirmed, vin: [{txid, vout, scriptSig, witness}], vout: [{scriptPubKey}] }] -> the observation for clasifica.mjs.
|
|
* Only CONFIRMED transactions become on-chain evidence; the ones the target holds unconfirmed (its mempool) are counted, and a revealing
|
|
* spend among them is flagged (`unconfirmedRevealing`) so that the classification can say "public, but not on chain yet". */
|
|
export function observatieDinTranzactii(t, txs, extra = {}) {
|
|
const conf = txs.filter((x) => !x.unconfirmed), neconf = txs.filter((x) => x.unconfirmed);
|
|
const outputs = [], outSet = new Set();
|
|
for (const tx of conf) tx.vout.forEach((o, i) => { if (B.platesteTinta(t, o.scriptPubKey)) { outputs.push({ txid: tx.txid, vout: i, height: tx.height, block: tx.block || null }); outSet.add(tx.txid + ':' + i); } });
|
|
const judeca = (tx, lista) => tx.vin.forEach((vin, i) => {
|
|
const spendsTarget = outSet.has(vin.txid + ':' + vin.vout) || vin.prevoutPaysTarget === true;
|
|
const findings = B.dovezileIntrarii(t, vin, spendsTarget);
|
|
if (findings.length || spendsTarget) lista.push({ txid: tx.txid, index: i, height: tx.height, block: tx.block || null, findings, spendsTarget, spends: vin.txid + ':' + vin.vout });
|
|
});
|
|
const inputs = []; for (const tx of conf) judeca(tx, inputs);
|
|
const neconfInputs = []; for (const tx of neconf) judeca(tx, neconfInputs);
|
|
// an output to this script that the target holds only in its mempool: for a key-in-output type (P2TR, P2PK, bare multisig) the key is
|
|
// public there, but not on chain yet
|
|
let neconfOutputs = 0; for (const tx of neconf) for (const o of tx.vout) if (B.platesteTinta(t, o.scriptPubKey)) neconfOutputs++;
|
|
return { outputs, inputs, spentOutputs: inputs.filter((i) => i.spendsTarget).length, txCount: conf.length, unconfirmedCount: neconf.length,
|
|
unconfirmedRevealing: neconfInputs.some((i) => i.findings.some((f) => B.DOVEZI_REVELATOARE.has(f.what))), unconfirmedOutputs: neconfOutputs, unconfirmedInputs: neconfInputs.length,
|
|
// the unconfirmed inputs that SPEND an output of this script (its funding read, or the prevout an index serves pays it)
|
|
unconfirmedSpends: neconfInputs.filter((i) => i.spendsTarget).length, ...extra };
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Electrum
|
|
function electrumClient(tinta, o) {
|
|
return new Promise((resolve, reject) => {
|
|
const opts = { host: tinta.host, port: tinta.port, ...(o.lookup ? { lookup: o.lookup } : {}) };
|
|
const sock = tinta.scheme === 'tls' ? tls.connect({ ...opts, servername: net.isIP(tinta.host) ? undefined : tinta.host }) : net.connect(opts);
|
|
const pending = new Map(); let id = 0; let buf = ''; let done = false;
|
|
const fail = (e) => { if (done) return; done = true; for (const p of pending.values()) p.reject(e); pending.clear(); reject(e); };
|
|
sock.setTimeout(timeoutMs(), () => { sock.destroy(new Error('timeout after ' + timeoutMs() + ' ms')); });
|
|
sock.on('error', (e) => fail(new Error(taie(mesajConexiune(e)))));
|
|
sock.on('close', () => fail(new Error('the target closed the connection')));
|
|
sock.on('data', (d) => {
|
|
buf += d.toString('utf8'); let i;
|
|
while ((i = buf.indexOf('\n')) >= 0) {
|
|
const line = buf.slice(0, i); buf = buf.slice(i + 1); if (!line.trim()) continue;
|
|
let j; try { j = JSON.parse(line); } catch { fail(new Error('a line from the target is not JSON')); return; }
|
|
const p = pending.get(j.id); if (!p) continue; pending.delete(j.id);
|
|
if (j.error) p.reject(new Error(`${p.method}: server error ${servit(String(j.error.code ?? '') + ' ' + String(j.error.message ?? ''))}`)); else if (!Object.hasOwn(j, 'result')) p.reject(new Error(`${p.method}: no result`)); else p.resolve(j.result);
|
|
}
|
|
});
|
|
const call = (method, params) => new Promise((res, rej) => { if (done) return rej(new Error('connection closed')); const k = ++id; pending.set(k, { resolve: res, reject: rej, method }); sock.write(JSON.stringify({ jsonrpc: '2.0', id: k, method, params }) + '\n'); });
|
|
const onConnect = () => { done = false; resolve({ call, close: () => { done = true; sock.destroy(); } }); };
|
|
sock.once(tinta.scheme === 'tls' ? 'secureConnect' : 'connect', onConnect);
|
|
});
|
|
}
|
|
/** the history of one script through an open Electrum connection -> { txs, gol } or { partial } / { error } naming what is wrong */
|
|
async function istoricElectrum(cl, t, cerere) {
|
|
const sh = B.scripthash(t.scriptPubKey);
|
|
const hist = await cl.call('blockchain.scripthash.get_history', [sh]); cerere();
|
|
if (!Array.isArray(hist)) return { error: 'get_history did not return a list' };
|
|
if (hist.length > CAP_TRANZACTII) return { partial: `a history of ${hist.length} transactions exceeds the cap of ${CAP_TRANZACTII}` };
|
|
const txs = [];
|
|
for (const h of hist) {
|
|
if (!h || typeof h.tx_hash !== 'string') return { partial: 'a history entry has no tx_hash' };
|
|
// the id is used in the report (and in the next request) only as an id: 64 hex digits; anything else is the target's text, quoted
|
|
if (!ID_BTC.test(h.tx_hash)) return { partial: `a history entry has a tx_hash that is not a transaction id (64 lowercase hex digits): ${servit(h.tx_hash)}` };
|
|
if (!Number.isInteger(h.height)) return { partial: `the history entry of ${h.tx_hash} has a height that is not an integer: ${servit(JSON.stringify(h.height))}` };
|
|
const raw = await cl.call('blockchain.transaction.get', [h.tx_hash, false]); cerere();
|
|
if (typeof raw !== 'string' || !raw) return { partial: `transaction ${h.tx_hash} was served empty` };
|
|
let tx; try { tx = B.parseTx(Buffer.from(raw, 'hex')); } catch (e) { return { partial: `transaction ${h.tx_hash} does not parse (${e.message})` }; }
|
|
if (tx.txid !== h.tx_hash) return { partial: `the bytes served for ${h.tx_hash} hash to ${tx.txid}` };
|
|
// Electrum: height 0 = in the mempool, -1 = in the mempool with an unconfirmed parent; neither is on chain
|
|
txs.push({ ...tx, height: h.height > 0 ? h.height : null, unconfirmed: !(h.height > 0) });
|
|
}
|
|
const straine = neatinse(t, txs);
|
|
if (straine.length) return { partial: `the history served for this script holds ${straine.length} transaction(s) that neither pay it nor spend it (the first: ${straine[0]}); an index serves, for a script, the transactions that touch it` };
|
|
return { txs, gol: hist.length === 0 };
|
|
}
|
|
export async function electrum(t, o) {
|
|
const tinta = tintaElectrum(o.target); let cl; let cereri = 0;
|
|
const cerere = () => { cereri++; };
|
|
try {
|
|
cl = await electrumClient(tinta, o);
|
|
const ver = await cl.call('server.version', ['aere-quantum-exposure-report 1', '1.4']); cereri++;
|
|
// the CHAIN the target serves: server.features names its genesis block (protocol 1.4, genesis_hash); clasifica.mjs compares it with
|
|
// the address's network before any label
|
|
const fe = await cl.call('server.features', []); cereri++;
|
|
const geneza = fe && typeof fe === 'object' ? fe.genesis_hash : undefined;
|
|
if (typeof geneza !== 'string' || !ID_BTC.test(geneza)) return { partial: `server.features served no genesis block hash (64 lowercase hex digits): ${servit(JSON.stringify(fe && typeof fe === 'object' ? (geneza ?? null) : fe))}`, requests: cereri };
|
|
const chain = { geneza, retea: B.reteaDinGeneza(geneza) };
|
|
// the TIP of the target, read BEFORE the history (blockchain.headers.subscribe, protocol 1.4: { height, hex }): every transaction
|
|
// confirmed at or below that height is in the history served after it, so the verdict can say up to where the history goes
|
|
const v = await cl.call('blockchain.headers.subscribe', []); cereri++;
|
|
if (!v || typeof v !== 'object' || !Number.isInteger(v.height) || v.height < 0) return { partial: `blockchain.headers.subscribe served no tip height: ${servit(JSON.stringify(v))}`, requests: cereri };
|
|
const tip = varfDinAntet(v.height, v.hex, 'the target serves');
|
|
if (tip.partial) return { partial: tip.partial, requests: cereri };
|
|
// the CHECKPOINT (bitcoin.mjs punctDeControl): on a network that holds one, the header the target serves at the highest checkpoint
|
|
// height at or below its tip (blockchain.block.header, protocol 1.4), whose hash clasifica.mjs compares with the checkpoint: a chain
|
|
// that shares the genesis block and split later serves another block there
|
|
const pc = B.punctDeControl(chain.retea, v.height);
|
|
if (pc && pc.sub !== undefined) chain.control = { sub: pc.sub, inaltime: v.height };
|
|
else if (pc) {
|
|
const hx = await cl.call('blockchain.block.header', [pc.height]); cereri++;
|
|
const a = B.antetBloc(typeof hx === 'string' ? hx : '');
|
|
if (a.eroare) return { partial: `the header the target serves for height ${pc.height} (a checkpoint height) is not a block header: ${a.eroare}`, requests: cereri };
|
|
chain.control = { height: pc.height, asteptat: pc.hash, servit: a.hash };
|
|
}
|
|
const h = await istoricElectrum(cl, t, cerere);
|
|
if (!h.txs) return { ...h, requests: cereri };
|
|
const obs = observatieDinTranzactii(t, h.txs, { tip, server: servit((Array.isArray(ver) ? String(ver[0]) : String(ver)).slice(0, 60)), chain, note: h.gol ? 'the target served an empty history for this script: either it was never used on chain or the target does not index it' : null });
|
|
// the SIBLING scripts of the same HASH160 (bitcoin.mjs scripturiSurori), through the same connection: a key revealed by a spend there
|
|
// is the same key. A sibling that cannot be read is named as such (the classification does not give "no on-chain evidence" past it)
|
|
obs.siblings = [];
|
|
for (const s of B.scripturiSurori(t)) {
|
|
const st = B.tintaSora(s, t.network);
|
|
let so; try { const hs = await istoricElectrum(cl, st, cerere); so = hs.txs ? observatieDinTranzactii(st, hs.txs) : hs; } catch (e) { so = { error: 'Electrum target ' + tinta.host + ':' + tinta.port + ': ' + taie(e.message) }; }
|
|
obs.siblings.push({ type: s.type, relatie: s.relatie, codificare: s.codificare, input: s.input, t: st, obs: so });
|
|
}
|
|
obs.requests = cereri;
|
|
return obs;
|
|
} catch (e) { return { error: 'Electrum target ' + tinta.host + ':' + tinta.port + ': ' + taie(e.message), requests: cereri }; }
|
|
finally { if (cl) cl.close(); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Esplora
|
|
/** the id of a transaction as an Esplora index serves it (/address/:a/txs): version, vin (txid, vout, scriptsig, sequence; a coinbase
|
|
* input carries the null outpoint), vout (value in satoshi, scriptpubkey), locktime, serialized without the witness (the id does not cover
|
|
* it) -> { txid } or { eroare } when a field is missing or not of its type */
|
|
export function idDinCampuriEsplora(x) {
|
|
try {
|
|
const u32 = (v, nume) => { if (!Number.isInteger(v) || v < 0 || v > 0xffffffff) throw new Error(nume + ' is not a 32-bit unsigned integer'); return v; };
|
|
if (!x || !Number.isInteger(x.version)) throw new Error('no version');
|
|
if (!Array.isArray(x.vin) || !x.vin.length || !Array.isArray(x.vout)) throw new Error('no vin / vout');
|
|
const vin = x.vin.map((v) => { if (!/^[0-9a-f]{64}$/.test(String(v.txid))) throw new Error('an input has no previous transaction id'); return { txid: v.txid, vout: u32(v.vout, 'vout'), scriptSig: B.dinHex(v.scriptsig || ''), sequence: u32(v.sequence, 'sequence'), witness: [] }; });
|
|
const vout = x.vout.map((v) => { if (!Number.isSafeInteger(v.value) || v.value < 0) throw new Error('an output value is not an amount in satoshi'); return { value: BigInt(v.value), scriptPubKey: B.dinHex(v.scriptpubkey || '') }; });
|
|
return { txid: B.parseTx(B.serializeTx({ version: x.version, locktime: u32(x.locktime, 'locktime'), vin, vout })).txid };
|
|
} catch (e) { return { eroare: taie(e.message).slice(0, 80) }; }
|
|
}
|
|
export async function esplora(t, o) {
|
|
const tinta = tintaHttp(o.target); let cereri = 0;
|
|
const get = async (p) => { cereri++; const r = await httpCerere({ url: tinta.base + p, lookup: o.lookup }); if (r.status !== 200) throw new Error(`${p}: HTTP ${r.status} ${servit(r.text.slice(0, 60))}`); if (!r.text.trim()) throw new Error(`${p}: empty body`); try { return JSON.parse(r.text); } catch { throw new Error(`${p}: not JSON`); } };
|
|
const getText = async (p) => { cereri++; const r = await httpCerere({ url: tinta.base + p, lookup: o.lookup, headers: { accept: 'text/plain' } }); if (r.status !== 200) throw new Error(`${p}: HTTP ${r.status} ${servit(r.text.slice(0, 60))}`); return r.text.trim(); };
|
|
try {
|
|
// the CHAIN the index serves: the hash of its block at height 0, its genesis block; clasifica.mjs compares it with the address's
|
|
// network before any label
|
|
const geneza = await getText('/block-height/0');
|
|
if (!ID_BTC.test(geneza)) return { partial: `/block-height/0 served no genesis block hash (64 lowercase hex digits): ${servit(geneza)}`, requests: cereri };
|
|
const chain = { geneza, retea: B.reteaDinGeneza(geneza) };
|
|
// the TIP of the index, read BEFORE the history: its height, the hash of the block at that height, and that block's 80-byte header,
|
|
// which must hash to that hash (every transaction confirmed at or below that height is in the history served after it)
|
|
const hTxt = await getText('/blocks/tip/height');
|
|
if (!/^\d{1,9}$/.test(hTxt)) return { partial: `/blocks/tip/height served no height: ${servit(hTxt)}`, requests: cereri };
|
|
const inaltime = Number(hTxt);
|
|
const hashVarf = await getText('/block-height/' + inaltime);
|
|
if (!ID_BTC.test(hashVarf)) return { partial: `/block-height/${inaltime} served no block hash (64 lowercase hex digits): ${servit(hashVarf)}`, requests: cereri };
|
|
const tip = varfDinAntet(inaltime, await getText('/block/' + hashVarf + '/header'), `served for the block ${hashVarf}`);
|
|
if (tip.partial) return { partial: tip.partial, requests: cereri };
|
|
if (tip.hash !== hashVarf) return { partial: `the header served for the tip block ${hashVarf} (height ${inaltime}) hashes to ${tip.hash}`, requests: cereri };
|
|
// the CHECKPOINT (bitcoin.mjs punctDeControl): the hash of the block the index serves at the highest checkpoint height at or below
|
|
// its tip, which clasifica.mjs compares with the checkpoint
|
|
const pc = B.punctDeControl(chain.retea, inaltime);
|
|
if (pc && pc.sub !== undefined) chain.control = { sub: pc.sub, inaltime };
|
|
else if (pc) {
|
|
const hc = await getText('/block-height/' + pc.height);
|
|
if (!ID_BTC.test(hc)) return { partial: `/block-height/${pc.height} (a checkpoint height) served no block hash (64 lowercase hex digits): ${servit(hc)}`, requests: cereri };
|
|
chain.control = { height: pc.height, asteptat: pc.hash, servit: hc };
|
|
}
|
|
const obs = await istoricEsplora(t, get);
|
|
if (obs.partial || obs.error) return { ...obs, requests: cereri };
|
|
obs.tip = tip; obs.chain = chain;
|
|
// the SIBLING scripts of the same key, read by their script (/scripthash/), never by the name the report gives them
|
|
obs.siblings = [];
|
|
for (const s of B.scripturiSurori(t)) {
|
|
const st = B.tintaSora(s, t.network);
|
|
let so; try { so = await istoricEsplora(st, get); } catch (e) { so = { error: 'Esplora target ' + tinta.host + ': ' + taie(e.message) }; }
|
|
obs.siblings.push({ type: s.type, relatie: s.relatie, codificare: s.codificare, input: s.input, t: st, obs: so });
|
|
}
|
|
obs.requests = cereri;
|
|
return obs;
|
|
} catch (e) { return { error: 'Esplora target ' + tinta.host + ': ' + taie(e.message), requests: cereri }; }
|
|
}
|
|
/** the history of one script through an Esplora index (`get`: one JSON request, errors thrown) -> an observation, or { partial } / { error } */
|
|
async function istoricEsplora(t, get) {
|
|
const hasAddr = t.form === 'base58check' || t.form === 'bech32' || t.form === 'bech32m';
|
|
// Esplora names a script without an address by the sha256 of the scriptPubKey as plain hex (NOT byte-reversed as in the
|
|
// Electrum protocol; measured 2026-10-11: the reversed form answers an empty list for a script with a history)
|
|
const baza = hasAddr ? '/address/' + t.input : '/scripthash/' + B.hex(B.sha256(t.scriptPubKey));
|
|
const stats = await get(baza);
|
|
const cs = stats && typeof stats === 'object' && stats.chain_stats && typeof stats.chain_stats === 'object' ? stats.chain_stats : null;
|
|
if (!cs) return { error: baza.split('/')[1] + ' did not return chain_stats' };
|
|
// the index's counts are read only as COUNTS (a JSON integer, 0 or more): null, a string, an empty list, a negative or a missing value is
|
|
// not a count of zero (Number(null) and Number('') are 0), and an answer that carries one is named and not used
|
|
const total = numarator(cs.tx_count), finantate = numarator(cs.funded_txo_count), cheltuite = numarator(cs.spent_txo_count);
|
|
if (total === null || finantate === null || cheltuite === null) return { partial: `the target served chain_stats counts that are not counts (tx_count, funded_txo_count and spent_txo_count must each be an integer, 0 or more): ${servit(JSON.stringify({ tx_count: cs.tx_count ?? null, funded_txo_count: cs.funded_txo_count ?? null, spent_txo_count: cs.spent_txo_count ?? null }))}` };
|
|
// the mempool counts are used only when all three are counts; otherwise the comparisons below cannot be made, and that is carried to
|
|
// the classification (mempoolNecitit), which then gives no "no on-chain evidence"
|
|
const memBrut = stats.mempool_stats;
|
|
const mem = memBrut && typeof memBrut === 'object' && [memBrut.tx_count, memBrut.funded_txo_count, memBrut.spent_txo_count].every((x) => numarator(x) !== null) ? memBrut : null;
|
|
if (total > CAP_TRANZACTII) return { partial: `a history of ${total} transactions exceeds the cap of ${CAP_TRANZACTII}` };
|
|
// the first page carries the target's mempool transactions (status.confirmed false) before the confirmed ones; they are kept apart:
|
|
// never on-chain evidence, but a revealing spend among them is flagged by observatieDinTranzactii (same treatment as Electrum height 0)
|
|
const neconf = [];
|
|
// an id that is not 64 hex digits is the target's text, quoted, and the page is not used
|
|
const confirmate = (l) => { if (!Array.isArray(l)) throw new Error('the transaction list is not a list'); const rau = l.find((x) => !x || !ID_BTC.test(String(x.txid))); if (rau !== undefined) throw new Error(`a transaction in the list has an id that is not a transaction id (64 lowercase hex digits): ${servit(rau && rau.txid)}`); neconf.push(...l.filter((x) => x.status && !x.status.confirmed)); return l.filter((x) => x.status && x.status.confirmed); };
|
|
const pagina = confirmate(await get(baza + '/txs'));
|
|
let ultim = pagina.length ? pagina[pagina.length - 1].txid : null;
|
|
while (pagina.length < total && ultim) {
|
|
const next = confirmate(await get(baza + '/txs/chain/' + ultim));
|
|
if (!next.length) break;
|
|
pagina.push(...next); ultim = next[next.length - 1].txid;
|
|
}
|
|
if (pagina.length !== total) return { partial: `the target served ${pagina.length} confirmed transactions of the ${total} it counts for this script` };
|
|
// an index serves each transaction as FIELDS, not bytes: they are re-serialized here (version, inputs, outputs, locktime: exactly what
|
|
// the id covers; the witness is not under the id) and must hash to the id the index gives, or nothing from that page is used
|
|
for (const x of [...pagina, ...neconf]) { const id = idDinCampuriEsplora(x); if (id.eroare) return { partial: `transaction ${x.txid} as served cannot be re-serialized from its fields (${id.eroare})` }; if (id.txid !== x.txid) return { partial: `transaction ${x.txid} as served does not hash to its id: its fields re-serialized hash to ${id.txid}` }; }
|
|
const tx = (x, unconfirmed) => ({
|
|
txid: x.txid, height: unconfirmed ? null : x.status.block_height, unconfirmed,
|
|
vin: (x.vin || []).map((v) => ({ txid: v.txid, vout: v.vout, scriptSig: B.dinHex(v.scriptsig || ''), witness: (v.witness || []).map((w) => B.dinHex(w)), prevoutPaysTarget: !!(v.prevout && v.prevout.scriptpubkey && B.platesteTinta(t, B.dinHex(v.prevout.scriptpubkey))) })),
|
|
vout: (x.vout || []).map((v) => ({ scriptPubKey: B.dinHex(v.scriptpubkey || '') })),
|
|
});
|
|
const txs = [...pagina.map((x) => tx(x, false)), ...neconf.map((x) => tx(x, true))];
|
|
const straine = neatinse(t, txs);
|
|
if (straine.length) return { partial: `the history served for this script holds ${straine.length} transaction(s) that neither pay it nor spend it (the first: ${straine[0]}); an index serves, for a script, the transactions that touch it` };
|
|
const obs = observatieDinTranzactii(t, txs, { note: pagina.length === 0 ? 'the target served no confirmed transaction for this script: either it was never used on chain or the target does not index it' : null });
|
|
// the index's own counts on chain: OUTPUTS to this script (compared in clasifica.mjs with the outputs read from the pages: an index that
|
|
// counts more holds an output it did not serve) and SPENT outputs (compared with the spends read from the pages)
|
|
obs.fundedOutputsServed = finantate;
|
|
obs.spentOutputsServed = cheltuite;
|
|
// its mempool counts. A spend counted there is a broadcast spend (it reveals the key even if it never confirms): the count is compared
|
|
// with the unconfirmed inputs the page carried that SPEND this script; when it counts more, a spend was not served (gone between the
|
|
// two requests, or left off), whatever other mempool transactions of the address the page carried. OUTPUTS to this script (a P2TR,
|
|
// P2PK or bare-multisig output carries its key, which is public there even unconfirmed) are compared with the unconfirmed outputs the
|
|
// page carried, and TRANSACTIONS of this script with the unconfirmed transactions the page carried (the first page carries up to 50 of
|
|
// them); a count above what was served means the index holds something it did not serve
|
|
if (mem) {
|
|
obs.mempoolSpendsCounted = mem.spent_txo_count; obs.mempoolSpendNotServed = obs.mempoolSpendsCounted > obs.unconfirmedSpends;
|
|
obs.mempoolOutputsCounted = mem.funded_txo_count; obs.mempoolOutputNotServed = obs.mempoolOutputsCounted > obs.unconfirmedOutputs;
|
|
obs.mempoolTxsCounted = mem.tx_count; obs.mempoolTxNotServed = obs.mempoolTxsCounted > obs.unconfirmedCount;
|
|
} else obs.mempoolNecitit = memBrut === undefined ? 'absent' : servit(JSON.stringify(memBrut));
|
|
return obs;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Bitcoin Core
|
|
function authBitcoind() {
|
|
if (process.env.AERE_BITCOIND_COOKIE) { try { return fs.readFileSync(process.env.AERE_BITCOIND_COOKIE, 'utf8').trim(); } catch { throw new Refuz('bitcoind-cookie', 'the cookie file named by AERE_BITCOIND_COOKIE cannot be read'); } }
|
|
return process.env.AERE_BITCOIND_AUTH ? String(process.env.AERE_BITCOIND_AUTH).trim() : null;
|
|
}
|
|
/** the decoded form of getrawtransaction verbose (version, vin with txid / vout or coinbase, scriptSig.hex, sequence, txinwitness; vout
|
|
* with value in BTC and scriptPubKey.hex; locktime) -> the parsed form that bitcoin.mjs serializeTx takes; a missing field throws */
|
|
function dinFormaDecodata(v) {
|
|
const intreg = (x, nume) => { if (!Number.isInteger(x) || x < 0 || x > 0xffffffff) throw new Error(nume + ' is not a 32-bit unsigned integer'); return x; };
|
|
const satoshi = (x) => { const s = typeof x === 'number' ? x.toFixed(8) : String(x); if (!/^\d+(\.\d{1,8})?$/.test(s)) throw new Error('an output value is not an amount'); const [i, f = ''] = s.split('.'); return BigInt(i) * 100000000n + BigInt((f + '00000000').slice(0, 8)); };
|
|
if (!Array.isArray(v.vin) || !v.vin.length || !Array.isArray(v.vout)) throw new Error('no vin / vout');
|
|
if (!Number.isInteger(v.version)) throw new Error('no version');
|
|
const martor = (i) => (i.txinwitness || []).map((w) => B.dinHex(w));
|
|
const intrare = (i) => {
|
|
if (typeof i.coinbase === 'string') return { txid: '00'.repeat(32), vout: 0xffffffff, scriptSig: B.dinHex(i.coinbase), sequence: intreg(i.sequence, 'sequence'), witness: martor(i) };
|
|
if (!/^[0-9a-f]{64}$/.test(String(i.txid))) throw new Error('an input has no previous transaction id');
|
|
return { txid: i.txid, vout: intreg(i.vout, 'vout'), scriptSig: B.dinHex(i.scriptSig ? i.scriptSig.hex : ''), sequence: intreg(i.sequence, 'sequence'), witness: martor(i) };
|
|
};
|
|
return { version: v.version, locktime: intreg(v.locktime, 'locktime'), vin: v.vin.map(intrare), vout: v.vout.map((o) => ({ value: satoshi(o.value), scriptPubKey: B.dinHex(o.scriptPubKey ? o.scriptPubKey.hex : '') })) };
|
|
}
|
|
/**
|
|
* read the --txids ONCE for the whole report (getrawtransaction verbose): -> Map txid -> { tx (parsed), block, unconfirmed }, plus the
|
|
* request count. An id the node does not serve is a REFUSAL of the input (named), before any address is measured: the list is the
|
|
* client's and a wrong id in it cannot be attributed to any address; a transport failure is thrown as a plain Error (every address is
|
|
* then unmeasured with that reason).
|
|
*/
|
|
export async function tranzactiiCore(target, txids, o = {}) {
|
|
const tinta = tintaHttp(target); const auth = authBitcoind(); let cereri = 0;
|
|
const cache = new Map();
|
|
for (const id of txids) {
|
|
if (cache.has(id)) continue;
|
|
cereri++;
|
|
let v;
|
|
try { v = await jsonRpc(tinta.base, 'getrawtransaction', [id, true], { lookup: o.lookup, auth }); }
|
|
catch (e) { if (/endpoint error/.test(String(e.message))) throw new Refuz('txids', `transaction ${id} given with --txids is not served by the node (${taie(e.message)}); nothing was measured`, [id]); throw new Error('Bitcoin Core target ' + tinta.host + ': ' + taie(e.message)); }
|
|
if (!v || typeof v !== 'object') throw new Refuz('txids', `getrawtransaction ${id} returned nothing; nothing was measured`, [id]);
|
|
let tx;
|
|
if (typeof v.hex === 'string' && v.hex) { try { tx = B.parseTx(Buffer.from(v.hex, 'hex')); } catch (e) { throw new Refuz('txids', `transaction ${id} does not parse (${taie(e.message)})`, [id]); } if (tx.txid !== id) throw new Refuz('txids', `the bytes served for ${id} hash to ${tx.txid}`, [id, tx.txid]); }
|
|
else {
|
|
// a node that serves the decoded form only: the transaction is re-serialized from it and must hash to the id given, exactly as
|
|
// the raw bytes must on every other path (a field changed by the node, or a form this rebuild does not read, is a refusal)
|
|
try { tx = B.parseTx(B.serializeTx(dinFormaDecodata(v))); } catch (e) { throw new Refuz('txids', `transaction ${id} was served without its hex and its decoded form cannot be re-serialized (${taie(e.message)}); nothing was measured`, [id]); }
|
|
if (tx.txid !== id) throw new Refuz('txids', `transaction ${id} was served without its hex, and its decoded form re-serialized hashes to ${tx.txid}; nothing was measured`, [id, tx.txid]);
|
|
}
|
|
// a verbose transaction without a blockhash is in the node's mempool, not on chain; the block hash (stable) names the position,
|
|
// `confirmations` changes at every block and is not used. A blockhash that is not 64 hex digits is not a position: the answer is refused
|
|
if (v.blockhash !== undefined && v.blockhash !== null && !ID_BTC.test(String(v.blockhash))) throw new Refuz('txids', `getrawtransaction ${id} served a blockhash that is not a block hash (64 lowercase hex digits): ${servit(v.blockhash)}; nothing was measured`, [id]);
|
|
// ON CHAIN = in a block of the node's ACTIVE chain: Bitcoin Core (rpc/rawtransaction.cpp, TxToJSON) writes `confirmations` >= 1 for a
|
|
// block of the active chain and 0 for a block off it (a stale block the txindex still names); a transaction with a blockhash and no
|
|
// confirmation is not on chain, exactly like one in the mempool (`offChain` names the difference). `confirmations` is read as this test
|
|
// only; the position written is the block hash
|
|
const peLant = typeof v.blockhash === 'string' && Number.isInteger(v.confirmations) && v.confirmations >= 1;
|
|
// the block time (Bitcoin Core `blocktime`, the header time of its block) of a transaction on the active chain: clasifica.mjs judges it
|
|
// against the report date; one on the active chain served without it cannot be placed there (named)
|
|
const blocktime = peLant && Number.isInteger(v.blocktime) && v.blocktime >= 0 && v.blocktime <= 0xffffffff ? v.blocktime : null;
|
|
cache.set(id, { tx, block: peLant ? v.blockhash : null, height: peLant && Number.isInteger(v.height) ? v.height : null, unconfirmed: !peLant, offChain: typeof v.blockhash === 'string' && !peLant, blocktime, faraTimp: peLant && blocktime === null });
|
|
}
|
|
return { cache, requests: cereri };
|
|
}
|
|
/**
|
|
* the CHAIN a Bitcoin Core node serves, read ONCE for the whole report: getblockhash 0 (its genesis block), getblockcount (the height of
|
|
* its active chain) and, on a network that holds checkpoints (bitcoin.mjs punctDeControl), getblockhash at the highest checkpoint height at
|
|
* or below it -> { chain, requests } or { partial, requests } naming what is wrong; a transport failure or an RPC error is thrown as a
|
|
* plain Error. clasifica.mjs judges it as it judges the chain of an Electrum or Esplora target (no tip header is read here, so the
|
|
* proof-of-work limit is not judged for Bitcoin Core)
|
|
*/
|
|
export async function lantulCore(target, o = {}) {
|
|
const tinta = tintaHttp(target); const auth = authBitcoind(); let cereri = 0;
|
|
const rpc = async (m, p) => { cereri++; return jsonRpc(tinta.base, m, p, { lookup: o.lookup, auth }); };
|
|
const geneza = await rpc('getblockhash', [0]);
|
|
if (typeof geneza !== 'string' || !ID_BTC.test(geneza)) return { partial: `getblockhash 0 served no genesis block hash (64 lowercase hex digits): ${servit(JSON.stringify(geneza))}`, requests: cereri };
|
|
const inaltime = await rpc('getblockcount', []);
|
|
if (!Number.isSafeInteger(inaltime) || inaltime < 0) return { partial: `getblockcount served no height: ${servit(JSON.stringify(inaltime))}`, requests: cereri };
|
|
const chain = { geneza, retea: B.reteaDinGeneza(geneza), height: inaltime };
|
|
const pc = B.punctDeControl(chain.retea, inaltime);
|
|
if (pc && pc.sub !== undefined) chain.control = { sub: pc.sub, inaltime };
|
|
else if (pc) {
|
|
const hc = await rpc('getblockhash', [pc.height]);
|
|
if (typeof hc !== 'string' || !ID_BTC.test(hc)) return { partial: `getblockhash ${pc.height} (a checkpoint height) served no block hash (64 lowercase hex digits): ${servit(JSON.stringify(hc))}`, requests: cereri };
|
|
chain.control = { height: pc.height, asteptat: pc.hash, servit: hc };
|
|
}
|
|
return { chain, requests: cereri };
|
|
}
|
|
export async function bitcoind(t, o) {
|
|
const tinta = tintaHttp(o.target); let cereri = 0; const auth = authBitcoind();
|
|
const rpc = async (m, p) => { cereri++; return jsonRpc(tinta.base, m, p, { lookup: o.lookup, auth }); };
|
|
// the chain the node serves (lantulCore, read once by raport.mjs): without it no address is measured through this node
|
|
if (!o.lantCore) return { error: 'Bitcoin Core target ' + tinta.host + ': the chain the node serves could not be read: ' + taie(o.lantCoreError || 'not read') };
|
|
if (o.txidsError) return { error: 'Bitcoin Core target ' + tinta.host + ': the transactions given with --txids could not be read: ' + taie(o.txidsError) };
|
|
try {
|
|
const hasAddr = t.form === 'base58check' || t.form === 'bech32' || t.form === 'bech32m';
|
|
const desc = hasAddr ? `addr(${t.input})` : `raw(${B.hex(t.scriptPubKey)})`;
|
|
const scan = await rpc('scantxoutset', ['start', [{ desc, range: 0 }]]);
|
|
if (!scan || scan.success !== true || !Array.isArray(scan.unspents)) return { error: 'scantxoutset did not succeed' };
|
|
const unspent = scan.unspents.filter((u) => { try { return B.platesteTinta(t, B.dinHex(u.scriptPubKey)); } catch { return false; } });
|
|
// an unspent output is named in the report by its txid:vout; a txid that is not 64 hex digits or a vout that is not an index is the
|
|
// node's text, quoted, and the scan is not used
|
|
const uRau = unspent.find((u) => !ID_BTC.test(String(u.txid)) || !Number.isInteger(u.vout) || u.vout < 0);
|
|
if (uRau) return { partial: `scantxoutset served an unspent output whose txid or vout is not a transaction id (64 lowercase hex digits) and an index: ${servit(String(uRau.txid) + ':' + String(uRau.vout))}`, requests: cereri };
|
|
const cache = o.cacheTxids instanceof Map ? o.cacheTxids : new Map();
|
|
const txs = [...cache.values()].map((c) => ({ ...c.tx, height: c.height, block: c.block, unconfirmed: c.unconfirmed }));
|
|
const obs = observatieDinTranzactii(t, txs, { requests: cereri });
|
|
// the given list speaks about THIS address only when one of its CONFIRMED transactions touches it: an output to it, an input spending
|
|
// it or revealing its key or script (counted before the scan's own outputs are added below). A transaction the node holds only in its
|
|
// mempool is not on chain, and says nothing about the address's history on chain: a list that touches the address only there leaves
|
|
// its spends exactly as unread as no list at all (the mempool touch is named in the reason)
|
|
const nTxids = (o.txids && o.txids.length) || 0;
|
|
const atinsa = obs.outputs.length > 0 || obs.inputs.length > 0;
|
|
obs.mempoolTouches = (obs.unconfirmedOutputs || 0) + (obs.unconfirmedInputs || 0);
|
|
// given transactions that name a block off the node's active chain (confirmations 0): not on chain, and named as such in the reasons
|
|
obs.offChainBlocks = [...cache.values()].filter((c) => c.offChain).length;
|
|
// the NEWEST chain time this adapter read, judged against the report date in clasifica.mjs: the scan reads the node's unspent set as of
|
|
// this run, so the time of the run; and the block time of each given transaction on the active chain, when it is later. A given
|
|
// transaction on the active chain served without a block time cannot be placed against the date, and is named
|
|
obs.timpMaxim = { time: Math.floor(Date.now() / 1000), ce: "scantxoutset reads the node's unspent set at the time of this run," };
|
|
for (const [id, c] of cache) if (!c.unconfirmed && c.blocktime !== null && c.blocktime > obs.timpMaxim.time) obs.timpMaxim = { time: c.blocktime, ce: `the block of transaction ${id} given with --txids carries the time` };
|
|
const faraTimp = [...cache.entries()].find(([, c]) => c.faraTimp); if (faraTimp) obs.blocktimeLipsa = faraTimp[0];
|
|
// what the scan says about the outputs read from the given transactions: an output absent from the node's unspent set was SPENT;
|
|
// when no given transaction spends it, the spending transaction (where a key may be revealed) was not given
|
|
const unspentSet = new Set(unspent.map((u) => u.txid + ':' + u.vout));
|
|
const spentRead = new Set(obs.inputs.filter((i) => i.spendsTarget).map((i) => i.spends));
|
|
obs.spentAbsent = obs.outputs.filter((x) => !unspentSet.has(x.txid + ':' + x.vout)).length;
|
|
obs.spentNotRead = obs.outputs.filter((x) => !unspentSet.has(x.txid + ':' + x.vout) && !spentRead.has(x.txid + ':' + x.vout)).length;
|
|
// unspent outputs the scan found that are not in the given transactions still count as outputs on chain
|
|
for (const u of unspent) if (!obs.outputs.some((x) => x.txid === u.txid && x.vout === u.vout)) obs.outputs.push({ txid: u.txid, vout: u.vout, height: Number.isInteger(u.height) ? u.height : null, block: null });
|
|
obs.unspentFound = unspent.length;
|
|
// the scan sees only UNSPENT outputs: without --txids, or when none of the given transactions touches this address, spends (what
|
|
// reveals a key) cannot be enumerated for it, and the classification says so, per ADDRESS (a list given for other addresses says
|
|
// nothing about this one)
|
|
obs.txidsGiven = nTxids;
|
|
obs.spendsNotEnumerable = nTxids === 0 || !atinsa;
|
|
// Bitcoin Core keeps no address index: whatever the list, the history of this address read here is the list the requester gave, which
|
|
// no answer of the node shows to be complete (an output funded and spent by transactions not given is invisible to it). The
|
|
// classification therefore never gives "no on-chain evidence of exposure" through this adapter (clasifica.mjs)
|
|
obs.historyFromRequesterList = true;
|
|
obs.note = obs.spendsNotEnumerable ? null : `Bitcoin Core keeps no address index: only the ${nTxids} transaction id(s) given with --txids were read for spends`;
|
|
// the SIBLING scripts: only a SPEND reveals a hash-type key, and Bitcoin Core shows spends only in the given transactions, so each
|
|
// sibling is judged on those, with no request. The key-in-output siblings of a P2PK input (its other encodings under P2PK, its BIP-86
|
|
// P2TR output) are judged on the given transactions too: an unspent output of theirs that no given transaction carries is not scanned,
|
|
// which can leave the input unmeasured where a scan would show it exposed, never "no on-chain evidence" (through Bitcoin Core an
|
|
// address never gets that label)
|
|
obs.siblings = B.scripturiSurori(t).map((s) => { const st = B.tintaSora(s, t.network); return { type: s.type, relatie: s.relatie, codificare: s.codificare, input: s.input, t: st, obs: { ...observatieDinTranzactii(st, txs), historyFromRequesterList: true, txidsGiven: nTxids } }; });
|
|
obs.chain = o.lantCore;
|
|
return obs;
|
|
} catch (e) { return { error: 'Bitcoin Core target ' + tinta.host + ': ' + taie(e.message), requests: cereri }; }
|
|
}
|
|
|
|
// ---------------------------------------------------------------- Ethereum JSON-RPC
|
|
export function fereastraBlocuri(spec, varf) {
|
|
const s = String(spec || '64');
|
|
const m = /^(\d+)\.\.(\d+)$/.exec(s);
|
|
if (m) { const a = Number(m[1]), b = Number(m[2]); if (a > b) throw new Refuz('eth-blocks', '--eth-blocks a..b needs a <= b'); if (a > varf) throw new Refuz('eth-blocks', `--eth-blocks ${a}..${b} starts after the head block ${varf} reported by the endpoint: no block would be read`); return [a, Math.min(b, varf)]; }
|
|
if (/^\d+$/.test(s)) { const n = Number(s); if (n < 1 || n > 100000) throw new Refuz('eth-blocks', '--eth-blocks N needs 1 <= N <= 100000'); return [Math.max(0, varf - n + 1), varf]; }
|
|
throw new Refuz('eth-blocks', '--eth-blocks is N (the last N blocks) or a..b');
|
|
}
|
|
/**
|
|
* THE ACCOUNT AT THE HEAD (EIP-1186): its nonce and its code hash come from eth_getProof AT THE HEAD NUMBER, every node of the proof
|
|
* looked up by its keccak hash from the state root of the head block (ethereum.mjs contDinDovada), and its code from eth_getCode at the
|
|
* same number, which must hash to the code hash proven (an account whose proven code hash is that of empty code has no code, and no code
|
|
* is asked for). Behind a load balancer, a machine that does not hold that block answers with an error, and a proof of any other state
|
|
* does not lead to that root: either way the account is not measured, instead of a nonce or a code read at 'latest' from another machine
|
|
* being written as the state of this head. An endpoint that does not serve eth_getProof leaves the account unmeasured, named.
|
|
* -> { code, nonce (a quantity), absent } or { partial } / { error }
|
|
*/
|
|
async function stareLaCap(rpc, adresa, laCap, radacina, varf) {
|
|
let dovada;
|
|
try { dovada = await rpc('eth_getProof', [adresa, [], laCap]); }
|
|
catch (e) { return { partial: `the endpoint did not serve the account proof (eth_getProof) at its head block ${varf}: ${taie(e.message)}; this tool reads the nonce and the code of an account only from a proof under the state root of the head block it judges` }; }
|
|
const cont = E.contDinDovada(radacina, adresa, dovada);
|
|
if (cont.eroare) return { partial: `the account proof served at the head block ${varf} does not lead to the state root of that block: ${cont.eroare}` };
|
|
let code = '0x';
|
|
if (cont.codeHash !== E.HASH_COD_GOL) {
|
|
code = await rpc('eth_getCode', [adresa, laCap]);
|
|
if (typeof code !== 'string' || !/^0x(?:[0-9a-fA-F]{2})*$/.test(code)) return { error: `eth_getCode did not return bytes: ${servit(JSON.stringify(typeof code === 'string' ? code.slice(0, 40) : code))}` };
|
|
if (E.keccak(Buffer.from(code.slice(2), 'hex')).toString('hex') !== cont.codeHash) return { partial: `the code served at the head block ${varf} does not hash to the code hash proven under the state root of that block` };
|
|
}
|
|
return { code, nonce: '0x' + cont.nonce.toString(16), absent: cont.absent };
|
|
}
|
|
export async function ethRpc(a, o) {
|
|
const tinta = tintaHttp(o.target); let cereri = 0;
|
|
const rpc = async (m, p) => { cereri++; return jsonRpc(tinta.base, m, p, { lookup: o.lookup }); };
|
|
try {
|
|
const chainId = await rpc('eth_chainId', []);
|
|
const varfHex = await rpc('eth_blockNumber', []);
|
|
// quantities are read only in their own form (0x + hex digits): a conversion error would carry the served text into the report
|
|
if (cantitate(chainId) === null || cantitate(varfHex) === null || cantitate(varfHex) > NUMAR_EXACT) return { error: `eth_chainId / eth_blockNumber did not return quantities: ${servit(JSON.stringify([chainId, varfHex]))}` };
|
|
const varf = Number(cantitate(varfHex));
|
|
// the HEAD the endpoint serves, judged in clasifica.mjs against the report date: eth_syncing (false, or a sync status, in which the
|
|
// node itself says that it is behind) and the block at the head number, read without its transactions, for its time
|
|
const sinc = await rpc('eth_syncing', []);
|
|
if (sinc !== false && (sinc === null || typeof sinc !== 'object' || Array.isArray(sinc))) return { error: `eth_syncing did not return false or a sync status: ${servit(JSON.stringify(sinc))}`, requests: cereri };
|
|
const numar = (x) => (cantitate(x) === null || cantitate(x) > NUMAR_EXACT ? null : Number(cantitate(x)));
|
|
const syncing = sinc === false ? null : { currentBlock: numar(sinc.currentBlock), highestBlock: numar(sinc.highestBlock) };
|
|
const laCap = '0x' + varf.toString(16);
|
|
const cap = await rpc('eth_getBlockByNumber', [laCap, false]);
|
|
const timpCap = cap && typeof cap === 'object' ? cantitate(cap.timestamp) : null;
|
|
// the STATE ROOT of the head block: the account below is read from a proof under it, so the state judged is the state of this head
|
|
const radacina = cap && typeof cap === 'object' && typeof cap.stateRoot === 'string' && HASH_ETH.test(cap.stateRoot) ? cap.stateRoot.toLowerCase() : null;
|
|
if (!cap || typeof cap !== 'object' || cantitate(cap.number) !== BigInt(varf) || timpCap === null || timpCap > 0xffffffffn || radacina === null) return { partial: `the endpoint did not serve its head block ${varf} with that number, a time and a state root: ${servit(JSON.stringify(cap && typeof cap === 'object' ? { number: cap.number ?? null, timestamp: cap.timestamp ?? null, stateRoot: cap.stateRoot ?? null } : cap))}`, requests: cereri };
|
|
// THE ACCOUNT AT THAT HEAD (stareLaCap, above): its nonce and its code, proven under the state root just read
|
|
const st = await stareLaCap(rpc, a.address, laCap, radacina, varf);
|
|
if (st.partial || st.error) return { ...st, requests: cereri };
|
|
const { code, nonce } = st;
|
|
if (typeof code !== 'string' || !/^0x(?:[0-9a-fA-F]{2})*$/.test(code) || cantitate(nonce) === null) return { error: `the code and the nonce of the account are not bytes and a quantity: ${servit(JSON.stringify([typeof code === 'string' ? code.slice(0, 40) : code, nonce]))}`, requests: cereri };
|
|
const obs = { chainId: Number(cantitate(chainId)), blockNumber: varf, head: { number: varf, time: Number(timpCap) }, stateRoot: radacina, accountAbsent: st.absent === true, syncing, code, nonce, requests: cereri, window: null, scanned: 0, sentTx: null, authorization: null };
|
|
// a contract has no key of its own: nothing to search; an EIP-7702 delegation designator is NOT a contract (the account signed it)
|
|
if (code !== '0x' && !E.esteDelegare7702(code)) { obs.requests = cereri; return obs; }
|
|
const [w0, w1] = fereastraBlocuri(o.ethBlocks, varf); obs.window = [w0, w1];
|
|
const delegat = E.esteDelegare7702(code);
|
|
for (let n = w1; n >= w0; n--) {
|
|
const bloc = await rpc('eth_getBlockByNumber', ['0x' + n.toString(16), true]); obs.scanned++;
|
|
if (!bloc || !Array.isArray(bloc.transactions)) return { ...obs, partial: `block ${n} was not served with its transactions` };
|
|
// the hash the endpoint gives is the report's text only in its own form (0x + 64 hex digits); anything else is quoted as served
|
|
if (!obs.sentTx) { const tx = bloc.transactions.find((x) => x && typeof x.from === 'string' && x.from.toLowerCase() === a.address); if (tx) obs.sentTx = { hash: idSauCitat(tx.hash, HASH_ETH), block: n, type: tx.type || '0x0', recovered: E.recupereazaExpeditor(tx) }; }
|
|
// EIP-7702: an authorization in a type-4 transaction is signed by the AUTHORITY, which may or may not be the transaction's sender;
|
|
// the same block can hold both the account's own transaction and its authorization, so both are looked for before stopping
|
|
if (!obs.authorization) {
|
|
for (const t4 of bloc.transactions) {
|
|
if (!t4 || !Array.isArray(t4.authorizationList)) continue;
|
|
const recs = t4.authorizationList.map((au) => E.recupereazaAutoritate(au));
|
|
const i = recs.findIndex((rec) => rec.ok && rec.address === a.address);
|
|
// the hash of the transaction that carries the authorization is re-derived from its served fields, as for a sent transaction; the
|
|
// delegate is the one in the bytes the authority signed (recupereazaAutoritate), not the served string
|
|
if (i >= 0) { let h = null; try { h = E.hashuriTx(t4).txHash; } catch { h = null; } obs.authorization = { hash: idSauCitat(t4.hash, HASH_ETH), block: n, index: i, delegate: recs[i].delegate, txHashMatches: h !== null && String(t4.hash || '').toLowerCase() === h }; break; }
|
|
}
|
|
}
|
|
// stop when what the classification needs is in hand: a delegated account wants its authorization too; any other account stops at
|
|
// the first signature of its key (a sent transaction, or an authorization)
|
|
if (delegat ? (obs.sentTx && obs.authorization) : (obs.sentTx || obs.authorization)) break;
|
|
}
|
|
obs.requests = cereri;
|
|
return obs;
|
|
} catch (e) { if (e instanceof Refuz) throw e; return { error: 'Ethereum JSON-RPC target ' + tinta.host + ': ' + taie(e.message), requests: cereri }; }
|
|
}
|
|
|
|
export const ADAPTOARE = Object.freeze({ electrum, esplora, bitcoind, 'eth-rpc': ethRpc });
|
|
export const tintaAdaptorului = (fel, target) => (fel === 'electrum' ? (({ host, port }) => ({ host, port }))(tintaElectrum(target)) : (({ host, port }) => ({ host, port }))(tintaHttp(target)));
|