106 lines
11 KiB
JavaScript
106 lines
11 KiB
JavaScript
// machete.mjs (quantum exposure report, C12): MOCK targets for the test, one process per kind, serving RECORDED responses from a
|
|
// fixture file (fixturi/*.json: where each answer came from and when is written in the file's `_sursa`). Each mock is a real local
|
|
// listener: it counts the connections it ACCEPTS and the requests it answers, and writes the counts to --contor after every event,
|
|
// so the test can compare them with the connection journal of the report process (paza-retea.mjs).
|
|
//
|
|
// node machete.mjs --fel electrum|esplora|bitcoind|eth --fixtura <json> --gata <file> --contor <file> [--port N]
|
|
// [--gol] every request is answered with an error / empty body (negative control: not "no evidence")
|
|
// [--mut] every connection is closed without an answer (negative control: a timeout is not "no evidence")
|
|
// [--suprascrie f] a JSON whose `raspunsuri` are merged over the fixture's (negative control: a planted spend); for
|
|
// Esplora, a value { "_status": N, "_corp": "text" } is served with that HTTP status and body; for
|
|
// Electrum (features, tip, history, a header at a height) and Ethereum, a value
|
|
// { "_eroare": { code, message } } is served as that JSON-RPC error
|
|
// [--plafon-s N] the mock exits by itself after N seconds (default 600)
|
|
// A script the fixture does not hold is answered as an index answers a script never used (Electrum: an empty history; Esplora: zero
|
|
// counts and an empty page for /address/:a and /scripthash/:h) and is counted in `unknown`: the sibling scripts the report reads beside
|
|
// each address were not recorded with the fixtures (README, Test). The tip (Electrum blockchain.headers.subscribe; Esplora
|
|
// /blocks/tip/height, /block-height/:h, /block/:hash/header), the genesis block (Electrum server.features; Esplora /block-height/0), the
|
|
// block at a checkpoint height (Electrum blockchain.block.header; Esplora /block-height/:h), the chain of a Bitcoin Core node
|
|
// (getblockhash, getblockcount) and the state of an Ethereum account at the head (eth_getProof, eth_getCode at the head number) are
|
|
// served from the fixture, or from --suprascrie; a height or a method the fixture does not hold is answered with an error.
|
|
// Listens on 127.0.0.1 only. Writes { port, pid } to --gata when listening. Nothing here reaches any network.
|
|
// A mock never outlives the process that started it: it checks every second that its parent is still alive (process.ppid changes when
|
|
// the parent dies on POSIX, where the orphan is adopted; on Windows the parent's pid is probed) and exits when it is not, and it exits in
|
|
// any case at the time cap; a test killed in the middle leaves no listener behind.
|
|
import fs from 'node:fs';
|
|
import net from 'node:net';
|
|
import http from 'node:http';
|
|
|
|
const arg = (k, d = null) => { const i = process.argv.indexOf(k); return i >= 0 ? process.argv[i + 1] : d; };
|
|
const are = (k) => process.argv.includes(k);
|
|
const FEL = arg('--fel'); const FIX = arg('--fixtura'); const GATA = arg('--gata'); const CONTOR = arg('--contor'); const PORT = Number(arg('--port', '0'));
|
|
if (!FEL || !FIX || !GATA || !CONTOR) { console.error('usage: --fel electrum|esplora|bitcoind|eth --fixtura f --gata f --contor f [--port N] [--gol] [--mut] [--suprascrie f]'); process.exit(2); }
|
|
const fix = JSON.parse(fs.readFileSync(FIX, 'utf8'));
|
|
const R = { ...fix.raspunsuri };
|
|
if (arg('--suprascrie')) Object.assign(R, JSON.parse(fs.readFileSync(arg('--suprascrie'), 'utf8')).raspunsuri || {});
|
|
const GOL = are('--gol'), MUT = are('--mut');
|
|
const PARINTE = process.ppid;
|
|
const parinteViu = () => { if (process.ppid !== PARINTE) return false; try { process.kill(PARINTE, 0); return true; } catch (e) { return e.code !== 'ESRCH'; } };
|
|
setInterval(() => { if (!parinteViu()) process.exit(0); }, 1000).unref();
|
|
setTimeout(() => process.exit(0), Math.max(5, Number(arg('--plafon-s', '600'))) * 1000).unref();
|
|
const contor = { fel: FEL, connections: 0, requests: 0, unknown: [] };
|
|
const scrieContor = () => fs.writeFileSync(CONTOR, JSON.stringify(contor) + '\n');
|
|
scrieContor();
|
|
|
|
function raspunsElectrum(j) {
|
|
if (GOL) return { jsonrpc: '2.0', id: j.id, error: { code: -32000, message: 'mock: no answer (--gol)' } };
|
|
if (j.method === 'server.version') return { jsonrpc: '2.0', id: j.id, result: ['mock-electrum/' + (fix._sursa ? fix._sursa.cand : '?'), '1.4'] };
|
|
if (j.method === 'server.features') { const k = 'server.features'; if (R[k] && R[k]._eroare) return { jsonrpc: '2.0', id: j.id, error: R[k]._eroare }; if (R[k]) return { jsonrpc: '2.0', id: j.id, result: R[k] }; contor.unknown.push(k); return { jsonrpc: '2.0', id: j.id, error: { code: -32601, message: 'mock: no features in the fixture' } }; }
|
|
if (j.method === 'blockchain.scripthash.get_history') { const k = 'blockchain.scripthash.get_history:' + j.params[0]; if (R[k] && R[k]._eroare) return { jsonrpc: '2.0', id: j.id, error: R[k]._eroare }; if (R[k]) return { jsonrpc: '2.0', id: j.id, result: R[k] }; contor.unknown.push(k); return { jsonrpc: '2.0', id: j.id, result: [] }; }
|
|
if (j.method === 'blockchain.headers.subscribe') { const k = 'blockchain.headers.subscribe'; if (R[k] && R[k]._eroare) return { jsonrpc: '2.0', id: j.id, error: R[k]._eroare }; if (R[k]) return { jsonrpc: '2.0', id: j.id, result: R[k] }; contor.unknown.push(k); return { jsonrpc: '2.0', id: j.id, error: { code: -32601, message: 'mock: no tip in the fixture' } }; }
|
|
if (j.method === 'blockchain.transaction.get') { const k = 'blockchain.transaction.get:' + j.params[0]; if (R[k]) return { jsonrpc: '2.0', id: j.id, result: R[k] }; contor.unknown.push(k); return { jsonrpc: '2.0', id: j.id, error: { code: -32603, message: 'mock: unknown transaction' } }; }
|
|
if (j.method === 'blockchain.block.header') { const k = 'blockchain.block.header:' + (j.params && j.params[0]); if (R[k] && R[k]._eroare) return { jsonrpc: '2.0', id: j.id, error: R[k]._eroare }; if (Object.hasOwn(R, k)) return { jsonrpc: '2.0', id: j.id, result: R[k] }; contor.unknown.push(k); return { jsonrpc: '2.0', id: j.id, error: { code: 1, message: 'mock: no header at this height in the fixture' } }; }
|
|
return { jsonrpc: '2.0', id: j.id, error: { code: -32601, message: 'mock: unknown method' } };
|
|
}
|
|
function raspunsBitcoind(j) {
|
|
if (GOL) return { result: null, error: { code: -32603, message: 'mock: no answer (--gol)' }, id: j.id };
|
|
if (j.method === 'getblockhash') { const k = 'getblockhash:' + (j.params && j.params[0]); if (Object.hasOwn(R, k)) return { result: R[k], error: null, id: j.id }; contor.unknown.push(k); return { result: null, error: { code: -8, message: 'Block height out of range' }, id: j.id }; }
|
|
if (j.method === 'getblockcount') { const k = 'getblockcount'; if (Object.hasOwn(R, k)) return { result: R[k], error: null, id: j.id }; contor.unknown.push(k); return { result: null, error: { code: -32601, message: 'mock: no block count in the fixture' }, id: j.id }; }
|
|
if (j.method === 'scantxoutset') { const d = j.params && j.params[1] && j.params[1][0] ? String(j.params[1][0].desc || j.params[1][0]) : ''; const m = /^addr\(([^)]+)\)/.exec(d) || /^(raw\([0-9a-f]+\))/.exec(d); const k = 'scantxoutset:' + (m ? m[1] : d); if (R[k]) return { result: R[k], error: null, id: j.id }; contor.unknown.push(k); return { result: { success: true, unspents: [] }, error: null, id: j.id }; }
|
|
if (j.method === 'getrawtransaction') { const k = 'getrawtransaction:' + j.params[0]; if (R[k]) return { result: R[k], error: null, id: j.id }; contor.unknown.push(k); return { result: null, error: { code: -5, message: 'No such mempool or blockchain transaction' }, id: j.id }; }
|
|
return { result: null, error: { code: -32601, message: 'Method not found' }, id: j.id };
|
|
}
|
|
function raspunsEth(j) {
|
|
if (GOL) return { jsonrpc: '2.0', id: j.id, result: null };
|
|
const k = j.method + ':' + JSON.stringify(j.params || []);
|
|
if (Object.hasOwn(R, k) && R[k] && typeof R[k] === 'object' && R[k]._eroare) return { jsonrpc: '2.0', id: j.id, error: R[k]._eroare };
|
|
if (Object.hasOwn(R, k)) return { jsonrpc: '2.0', id: j.id, result: R[k] };
|
|
contor.unknown.push(k);
|
|
return { jsonrpc: '2.0', id: j.id, error: { code: -32000, message: 'mock: not in the fixture: ' + k.slice(0, 80) } };
|
|
}
|
|
|
|
if (FEL === 'electrum') {
|
|
const srv = net.createServer((sock) => {
|
|
contor.connections++; scrieContor();
|
|
if (MUT) { sock.destroy(); return; }
|
|
let buf = '';
|
|
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; contor.requests++; scrieContor(); let j; try { j = JSON.parse(line); } catch { sock.write(JSON.stringify({ jsonrpc: '2.0', id: null, error: { code: -32700, message: 'parse error' } }) + '\n'); continue; } sock.write(JSON.stringify(raspunsElectrum(j)) + '\n'); } });
|
|
sock.on('error', () => {});
|
|
});
|
|
srv.listen(PORT, '127.0.0.1', () => fs.writeFileSync(GATA, JSON.stringify({ port: srv.address().port, pid: process.pid }) + '\n'));
|
|
} else {
|
|
const srv = http.createServer((req, res) => {
|
|
contor.requests++; scrieContor();
|
|
const parts = []; req.on('data', (d) => parts.push(d));
|
|
req.on('end', () => {
|
|
const body = Buffer.concat(parts).toString('utf8');
|
|
if (MUT) { req.socket.destroy(); return; }
|
|
if (FEL === 'esplora') {
|
|
if (GOL) { res.writeHead(500, { 'content-type': 'text/plain' }); res.end(''); return; }
|
|
const p = req.url.split('?')[0];
|
|
if (Object.hasOwn(R, p)) { const v = R[p]; if (v && typeof v === 'object' && Number.isInteger(v._status)) { res.writeHead(v._status, { 'content-type': 'text/plain' }); res.end(String(v._corp ?? '')); return; } res.writeHead(200, { 'content-type': typeof v === 'string' ? 'text/plain' : 'application/json' }); res.end(typeof v === 'string' ? v : JSON.stringify(v)); return; }
|
|
if (/^\/(address|scripthash)\/[^/]+\/txs\/chain\/[0-9a-f]{64}$/.test(p)) { res.writeHead(200, { 'content-type': 'application/json' }); res.end('[]'); return; }
|
|
// a script not in the fixture: as an index answers a script never used (zero counts, an empty page), counted in `unknown`
|
|
const nefolosit = /^\/(address|scripthash)\/([^/]+)(\/txs)?$/.exec(p);
|
|
if (nefolosit) { contor.unknown.push(p); scrieContor(); const zero = { funded_txo_count: 0, funded_txo_sum: 0, spent_txo_count: 0, spent_txo_sum: 0, tx_count: 0 }; res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(nefolosit[3] ? [] : { [nefolosit[1]]: nefolosit[2], chain_stats: zero, mempool_stats: zero })); return; }
|
|
contor.unknown.push(p); res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found (mock)'); return;
|
|
}
|
|
let j; try { j = JSON.parse(body); } catch { res.writeHead(400); res.end(''); return; }
|
|
const r = FEL === 'bitcoind' ? raspunsBitcoind(j) : raspunsEth(j);
|
|
res.writeHead(200, { 'content-type': 'application/json' }); res.end(JSON.stringify(r));
|
|
});
|
|
});
|
|
srv.on('connection', () => { contor.connections++; scrieContor(); });
|
|
srv.listen(PORT, '127.0.0.1', () => fs.writeFileSync(GATA, JSON.stringify({ port: srv.address().port, pid: process.pid }) + '\n'));
|
|
}
|