// 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, ''); 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)));