Skip to content

Commit 2951661

Browse files
random.html: real per-tx script+signature verification with the engine
Clicking a transaction now also fetches its raw hex (one more fetch, still only-on-click), decodes it with the engine, and runs every input through be.interpreter.verifyInput using the prevout scriptPubKey+value that esplora already inlines. Shows a green 'N / N inputs verified' banner with the in-browser timing, plus a per-input ✓/✗/○ tick. Coinbase and unconfirmed handled. This is real phase-2 consensus verification, one tx at a time. Also refresh engine/codec to the fixed schema trunk (#60/#61/#62/#63/#67) so verification is correct on inscriptions and large taproot spends, not just standard scripts.
1 parent c068e90 commit 2951661

6 files changed

Lines changed: 121 additions & 24 deletions

File tree

‎engine/codec/blocks.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,14 @@ export class BlockEngine {
192192
// coinbase scriptSig. Returns null if unparseable.
193193
bip34Height(coinbaseTx) {
194194
const script = hexToBytes(coinbaseTx.inputs[0].scriptSig);
195-
const len = script[0];
195+
const op = script[0];
196+
if (op === undefined) return null;
197+
// Heights 1-16 use the minimal push OP_1..OP_16 (0x51..0x60), a single
198+
// opcode byte rather than a length-prefixed push. Core's `CScript() << height`
199+
// encodes them this way, so BIP34 requires exactly this on those blocks.
200+
if (op >= 0x51 && op <= 0x60) return op - 0x50;
201+
// Otherwise a length-prefixed little-endian scriptnum push (1..5 data bytes).
202+
const len = op;
196203
if (len < 1 || len > 5 || script.length < 1 + len) return null;
197204
let n = 0;
198205
for (let i = len; i >= 1; i--) n = n * 256 + script[i];

‎engine/codec/hash.js‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,16 @@ const K = new Uint32Array([
1414

1515
const rotr = (x, n) => (x >>> n) | (x << (32 - n));
1616

17+
// Optional SHA-256 backend (e.g. native node:crypto / WebCrypto / WASM) injected
18+
// by performance-sensitive callers; null = the pure-JS path below. Keeps this
19+
// module zero-dependency: the engine never imports an accelerated hasher, the
20+
// caller supplies one. Must return a 32-byte Uint8Array byte-identical to the
21+
// pure-JS implementation (callers gate on a consensus-equivalence proof).
22+
let __sha256Backend = null;
23+
export function setSha256Backend(fn) { __sha256Backend = fn; }
24+
1725
export function sha256(data) {
26+
if (__sha256Backend) return __sha256Backend(data);
1827
const len = data.length;
1928
const bitLen = len * 8;
2029
const padded = new Uint8Array((((len + 8) >> 6) + 1) << 6);

‎engine/codec/interpreter.js‎

Lines changed: 45 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,16 @@ const truthy = (bytes) => {
5353
const boolBytes = (b) => (b ? Uint8Array.of(1) : new Uint8Array(0));
5454
const eq = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
5555

56+
// Bitcoin CompactSize (1/3/5/9 bytes), matching the codec's varint writer.
57+
// Used for the script-length prefix in the BIP341 TapLeaf hash; tapscripts can
58+
// exceed 65,535 bytes (large inscriptions), so the 0xfe 4-byte case is required.
59+
export function compactSize(n) {
60+
if (n < 0xfd) return Uint8Array.of(n);
61+
if (n <= 0xffff) return Uint8Array.of(0xfd, n & 0xff, (n >>> 8) & 0xff);
62+
if (n <= 0xffffffff) return Uint8Array.of(0xfe, n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff);
63+
const b = new Uint8Array(9); b[0] = 0xff; new DataView(b.buffer).setBigUint64(1, BigInt(n), true); return b;
64+
}
65+
5666
const DEFAULT_LIMITS = {
5767
maxScriptSize: 10000, maxScriptElementSize: 520,
5868
maxOpsPerScript: 201, maxStackSize: 1000, maxMultisigKeys: 20,
@@ -114,6 +124,18 @@ export class ScriptInterpreter {
114124
this.scriptEngine = scriptEngine;
115125
this.limits = limits;
116126
this.handlers = this.#buildHandlers();
127+
// Per-transaction sighash midstate cache (BIP143/BIP341 hashPrevouts,
128+
// hashSequence, hashOutputs, etc. depend only on the whole tx, not the input
129+
// being signed). Without this, a tx with n segwit/taproot inputs recomputes
130+
// these O(n)-sized hashes n times = O(n^2); a 1,400-input consolidation then
131+
// takes ~minutes. Keyed by the tx object (WeakMap) so it clears itself.
132+
this._sigCache = new WeakMap();
133+
}
134+
135+
#txCache(tx) {
136+
let c = this._sigCache.get(tx);
137+
if (!c) { c = {}; this._sigCache.set(tx, c); }
138+
return c;
117139
}
118140

119141
// ---- sighash ----
@@ -162,11 +184,13 @@ export class ScriptInterpreter {
162184
let p = 0; for (const a of arrs) { out.set(a, p); p += a.length; }
163185
return out;
164186
};
165-
const hashPrevouts = anyone ? zero : dsha256(cat(tx.inputs.map(outpoint)));
166-
const hashSequence = (anyone || base === 2 || base === 3) ? zero
167-
: dsha256(cat(tx.inputs.map((i) => u32(i.sequence))));
168187
const serOut = (o) => this.codec.encode('TransactionOutput', o);
169-
const hashOutputs = (base !== 2 && base !== 3) ? dsha256(cat(tx.outputs.map(serOut)))
188+
// These three depend only on the whole tx — memoize per tx (see #txCache).
189+
const C = this.#txCache(tx);
190+
const hashPrevouts = anyone ? zero : (C.wPrevouts ??= dsha256(cat(tx.inputs.map(outpoint))));
191+
const hashSequence = (anyone || base === 2 || base === 3) ? zero
192+
: (C.wSequence ??= dsha256(cat(tx.inputs.map((i) => u32(i.sequence)))));
193+
const hashOutputs = (base !== 2 && base !== 3) ? (C.wOutputs ??= dsha256(cat(tx.outputs.map(serOut))))
170194
: (base === 3 && inIndex < tx.outputs.length) ? dsha256(serOut(tx.outputs[inIndex]))
171195
: zero;
172196
const script = hexToBytes(scriptCodeHex);
@@ -204,15 +228,18 @@ export class ScriptInterpreter {
204228
};
205229
const outpoint = (inp) => cat([hexToBytes(inp.prevout.txid).reverse(), u32(inp.prevout.vout)]);
206230

231+
// sha_prevouts/amounts/scriptpubkeys/sequences/outputs depend only on the
232+
// whole tx (+ its prevout set, identical across inputs) — memoize per tx.
233+
const C = this.#txCache(tx);
207234
const parts = [u8(0x00), u8(hashType), u32(tx.version), u32(tx.lockTime)];
208235
if (!anyone) {
209-
parts.push(sha256(cat(tx.inputs.map(outpoint))));
210-
parts.push(sha256(cat(prevouts.map((p) => i64(p.value)))));
211-
parts.push(sha256(cat(prevouts.map((p) => varbytes(hexToBytes(p.scriptPubKey))))));
212-
parts.push(sha256(cat(tx.inputs.map((i) => u32(i.sequence)))));
236+
parts.push(C.tPrevouts ??= sha256(cat(tx.inputs.map(outpoint))));
237+
parts.push(C.tAmounts ??= sha256(cat(prevouts.map((p) => i64(p.value)))));
238+
parts.push(C.tScriptpubkeys ??= sha256(cat(prevouts.map((p) => varbytes(hexToBytes(p.scriptPubKey))))));
239+
parts.push(C.tSequences ??= sha256(cat(tx.inputs.map((i) => u32(i.sequence)))));
213240
}
214241
if (base !== 2 && base !== 3) {
215-
parts.push(sha256(cat(tx.outputs.map((o) => this.codec.encode('TransactionOutput', o)))));
242+
parts.push(C.tOutputs ??= sha256(cat(tx.outputs.map((o) => this.codec.encode('TransactionOutput', o)))));
216243
}
217244
parts.push(u8((leafHash ? 2 : 0) + (annex ? 1 : 0))); // spend_type
218245
if (anyone) {
@@ -466,7 +493,10 @@ export class ScriptInterpreter {
466493
// amount, sigVersion, alt}. Returns {ok, error?}; stack is mutated.
467494
execute(scriptHex, stack, ctx = {}) {
468495
try {
469-
if (scriptHex.length / 2 > this.limits.maxScriptSize) fail('script too large');
496+
// BIP342: tapscript has no per-script size limit. The legacy 10kB
497+
// MAX_SCRIPT_SIZE does not apply; a tapscript's size is bounded only by the
498+
// transaction/block weight limit (it has to fit in a block).
499+
if (ctx.sigVersion !== 'tapscript' && scriptHex.length / 2 > this.limits.maxScriptSize) fail('script too large');
470500
ctx.alt = ctx.alt ?? [];
471501
ctx.scriptCode = ctx.scriptCode ?? scriptHex;
472502
this.requireMinimalNum = !!ctx.flags?.has('MINIMALDATA'); // for the shared num() helper
@@ -492,7 +522,10 @@ export class ScriptInterpreter {
492522
}
493523
continue;
494524
}
495-
if (op.code > 0x60 && ++opCount > this.limits.maxOpsPerScript) fail('op count');
525+
// BIP342: tapscript removes the 201-non-push-opcode-per-script limit
526+
// entirely (there is no opcode-count cap). Signature-checking cost is
527+
// instead bounded separately by the per-input sigops budget.
528+
if (ctx.sigVersion !== 'tapscript' && op.code > 0x60 && ++opCount > this.limits.maxOpsPerScript) fail('op count');
496529
if (this.#isDisabled(op.name)) fail(`disabled opcode ${op.name}`);
497530
const isBranch = ['OP_IF', 'OP_NOTIF', 'OP_ELSE', 'OP_ENDIF'].includes(op.name);
498531
if (!executing && !isBranch) continue;
@@ -610,10 +643,7 @@ export class ScriptInterpreter {
610643
const leafVersion = control[0] & 0xfe;
611644
const parity = control[0] & 0x01;
612645
const internalKey = control.subarray(1, 33);
613-
const sizePrefix = script.length < 0xfd
614-
? Uint8Array.of(script.length)
615-
: Uint8Array.of(0xfd, script.length & 0xff, script.length >> 8);
616-
const leafHash = taggedHash('TapLeaf', Uint8Array.of(leafVersion), sizePrefix, script);
646+
const leafHash = taggedHash('TapLeaf', Uint8Array.of(leafVersion), compactSize(script.length), script);
617647
let k = leafHash;
618648
for (let i = 33; i < control.length; i += 32) {
619649
const e = control.subarray(i, i + 32);

‎engine/codec/script.js‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,18 @@ export class ScriptEngine {
7575
if (code >= 0x01 && code <= 0x4b) len = code;
7676
else if (code === 0x4c) { len = bytes[i]; i += 1; }
7777
else if (code === 0x4d) { len = bytes[i] | (bytes[i + 1] << 8); i += 2; }
78-
else if (code === 0x4e) { len = bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24); i += 4; }
78+
// >>> 0: the 4-byte length is unsigned. Without it, JS's `<< 24` makes a
79+
// high-bit length negative, which slips past the bounds check below and
80+
// sends `i` negative, looping into undefined bytes (crashes on malformed
81+
// coinbase scriptSig data). See bitcoin-desktop/schema#62.
82+
else if (code === 0x4e) { len = (bytes[i] | (bytes[i + 1] << 8) | (bytes[i + 2] << 16) | (bytes[i + 3] << 24)) >>> 0; i += 4; }
7983
if (len === null) {
8084
ops.push({ code, name: this.byCode.get(code) ?? `OP_UNKNOWN_0x${code.toString(16).padStart(2, '0')}` });
8185
continue;
8286
}
83-
if (Number.isNaN(len) || i + len > bytes.length) {
87+
// Truncated if the length prefix itself overran the end (i > length, len
88+
// undefined/0), the declared push runs past the end, or len isn't finite.
89+
if (len == null || Number.isNaN(len) || i > bytes.length || i + len > bytes.length) {
8490
ops.push({ code, name: 'OP_PUSH', error: 'truncated push' });
8591
break;
8692
}

‎engine/codec/secp256k1.js‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,9 +109,16 @@ export function parseDerSignature(bytes) {
109109
}
110110
}
111111

112+
// Optional verify backend (e.g. WASM libsecp256k1) injected by performance-
113+
// sensitive callers; null = the pure-JS path. Keeps this module zero-dependency:
114+
// the engine never imports a WASM lib, the caller supplies one.
115+
let __verifyBackend = null;
116+
export function setVerifyBackend(b) { __verifyBackend = b; }
117+
112118
// ECDSA verify: signature (parsed r,s) over a 32-byte message hash with an
113119
// affine public key point.
114120
export function verifyEcdsa(msgHash, sig, pubkey) {
121+
if (__verifyBackend) return __verifyBackend.ecdsa(msgHash, sig, pubkey);
115122
const { r, s } = sig;
116123
if (r <= 0n || r >= N || s <= 0n || s >= N) return false;
117124
const e = mod(bytesToBig(msgHash), N);
@@ -146,6 +153,7 @@ export function liftX(xBytes) {
146153
}
147154

148155
export function verifySchnorr(msg32, sig64, pubkey32) {
156+
if (__verifyBackend) return __verifyBackend.schnorr(msg32, sig64, pubkey32);
149157
if (sig64.length !== 64 || pubkey32.length !== 32) return false;
150158
const Ppoint = liftX(pubkey32);
151159
if (!Ppoint) return false;

‎random.html‎

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ <h1>Verify a Bitcoin block.</h1>
108108
</div>
109109
<p id="err"></p>
110110
<div id="card"></div>
111-
<p class="note"><b>Phase 1:</b> proof of work, merkle root, witness commitment and block structure all live inside the block itself, so they verify with no extra data. Script execution and signature checks need the spending history (the prevouts), which is phase 2. A pass here means structurally sound, not that every signature was checked.</p>
111+
<p class="note"><b>Two layers.</b> A block's proof of work, merkle root, witness commitment and structure all live inside the block itself, so they verify with no extra data. Scripts and signatures need the spending history (the prevouts), which isn't in the block &mdash; so <b>click any transaction</b> to fetch it and run its scripts and signatures through the engine for real, right here in your browser. That is the full check, one transaction at a time.</p>
112112
</div>
113113

114114
<footer>An independent JavaScript implementation. Block data from <span id="prov">mempool.space</span>. <a href="./index.html">bitcoin-kernel</a></footer>
@@ -297,7 +297,7 @@ <h1>Verify a Bitcoin block.</h1>
297297
} catch (e) { $('live').classList.remove('on'); $('err').textContent = 'Error: ' + e.message; $('rand').disabled = $('go').disabled = false; }
298298
}
299299

300-
// --- transaction detail: one fetch, only on click ---
300+
// --- transaction detail + real engine verification (fetches only on click) ---
301301
async function showTx(txid, push = true) {
302302
$('rand').disabled = $('go').disabled = true;
303303
$('card').classList.remove('show'); $('card').style.display = 'none';
@@ -306,8 +306,24 @@ <h1>Verify a Bitcoin block.</h1>
306306
step('fetching transaction');
307307
try {
308308
const tx = await (await api(`/api/tx/${txid}`)).json();
309+
let ver = null;
310+
try {
311+
step('verifying scripts & signatures with the engine');
312+
const hex = (await (await api(`/api/tx/${txid}/hex`)).text()).trim();
313+
const etx = codec.decode('Transaction', hex);
314+
// esplora inlines each prevout's scriptpubkey + value -> exactly what the interpreter needs
315+
const prevouts = tx.vin.map((v) => v.is_coinbase ? null : { value: v.prevout.value, scriptPubKey: v.prevout.scriptpubkey });
316+
const all = prevouts.every(Boolean) ? prevouts : null; // taproot sighash needs every prevout
317+
const t0 = performance.now();
318+
const res = tx.vin.map((v, i) => v.is_coinbase ? { coinbase: true } : be.interpreter.verifyInput(etx, i, prevouts[i], all));
319+
const ms = performance.now() - t0;
320+
const total = res.filter((r) => !r.coinbase).length;
321+
const ok = res.filter((r) => r.ok === true).length;
322+
const skipped = res.filter((r) => r.ok === null).length;
323+
ver = { res, ms, total, ok, skipped, allOk: total > 0 && ok === total };
324+
} catch (e) { ver = { error: e.message }; }
309325
$('live').classList.remove('on');
310-
renderTx(tx);
326+
renderTx(tx, ver);
311327
if (push) history.pushState({ tx: txid }, '', `?tx=${txid}`);
312328
} catch (e) {
313329
$('live').classList.remove('on');
@@ -317,29 +333,50 @@ <h1>Verify a Bitcoin block.</h1>
317333
}
318334
}
319335

320-
function renderTx(tx) {
336+
function renderTx(tx, ver) {
321337
const st = tx.status || {};
322338
const inT = tx.vin.reduce((s, v) => s + (v.prevout ? v.prevout.value : 0), 0);
323339
const outT = tx.vout.reduce((s, v) => s + v.value, 0);
324-
const vins = tx.vin.map((v) => {
340+
const mark = (i) => {
341+
const r = ver && ver.res ? ver.res[i] : null;
342+
if (!r || r.coinbase) return '';
343+
if (r.ok === true) return '<span class="ic g">✓</span> ';
344+
if (r.ok === false) return '<span class="ic b">✗</span> ';
345+
return '<span class="ic s">○</span> ';
346+
};
347+
const vins = tx.vin.map((v, i) => {
325348
if (v.is_coinbase) return `<div class="ior"><div class="top"><span class="amt">newly issued coins</span><span class="typ">coinbase</span></div></div>`;
326349
const p = v.prevout || {};
327-
return `<div class="ior"><div class="top"><span class="amt">${btc(p.value || 0)} BTC</span><span class="typ">${esc(p.scriptpubkey_type || '')}</span></div>`
350+
return `<div class="ior"><div class="top"><span class="amt">${mark(i)}${btc(p.value || 0)} BTC</span><span class="typ">${esc(p.scriptpubkey_type || '')}</span></div>`
328351
+ `<div class="sub">from <a href="?tx=${v.txid}" data-tx="${v.txid}">${esc(v.txid.slice(0, 22))}…:${v.vout}</a>${p.scriptpubkey_address ? ' &middot; ' + esc(p.scriptpubkey_address) : ''}</div></div>`;
329352
}).join('');
330353
const vouts = tx.vout.map((o) => `<div class="ior"><div class="top"><span class="amt">${btc(o.value)} BTC</span><span class="typ">${esc(o.scriptpubkey_type || '')}</span></div>${o.scriptpubkey_address ? `<div class="sub">${esc(o.scriptpubkey_address)}</div>` : ''}</div>`).join('');
354+
let vhero = '';
355+
if (ver && ver.total > 0) {
356+
vhero = `<div class="vhero ${ver.allOk ? '' : 'bad'}" style="padding:1.4rem 1.4rem 1.2rem">
357+
<div class="vc" style="font-size:2.2rem"><span class="cu" data-c="${ver.ok}">0</span> <span style="font-size:1.2rem;letter-spacing:-1px">/ ${ver.total}</span></div>
358+
<div class="sl">${ver.allOk ? 'inputs verified' : ver.ok + ' of ' + ver.total + ' inputs verified'}${ver.skipped ? ` &middot; ${ver.skipped} skipped` : ''}</div>
359+
<div class="pct">${ver.allOk ? 'scripts &amp; signatures checked' : 'verification failed'}<span class="t"> &middot; in <b style="color:var(--good);font-weight:800">${ver.ms.toFixed(1)} ms</b> by the engine, in your browser</span></div>
360+
</div>`;
361+
} else if (ver && ver.error) {
362+
vhero = `<div class="note" style="margin:0;border-radius:0;border-left:0;border-right:0;border-top:0">Could not run verification (${esc(ver.error)}); detail shown from the explorer.</div>`;
363+
} else if (ver && ver.total === 0) {
364+
vhero = `<div class="note" style="margin:0;border-radius:0;border-left:0;border-right:0;border-top:0">Coinbase transaction &mdash; newly issued coins, no inputs to verify.</div>`;
365+
}
331366
$('card').innerHTML = `
332367
<div class="backlink">${st.block_height != null ? `&larr; <a href="?height=${st.block_height}" data-h="${st.block_height}">Block ${fmt(st.block_height)}</a>` : 'unconfirmed (in the mempool)'}</div>
333368
<div class="chead">
334369
<div class="h" style="font-size:1rem">Transaction</div>
335370
<div class="hash">${esc(tx.txid)}</div>
336371
<div class="meta">${tx.vin.length} in &middot; ${tx.vout.length} out &middot; ${fmt(tx.size)} bytes &middot; weight ${fmt(tx.weight)}${tx.fee != null ? ` &middot; fee ${fmt(tx.fee)} sat` : ''}</div>
337372
</div>
373+
${vhero}
338374
<div class="seclab">Inputs (${tx.vin.length})${inT ? ` &middot; ${btc(inT)} BTC` : ''}</div>
339375
<div class="io">${vins}</div>
340376
<div class="seclab">Outputs (${tx.vout.length}) &middot; ${btc(outT)} BTC</div>
341377
<div class="io">${vouts}</div>`;
342378
$('card').style.display = 'block'; $('card').classList.add('show');
379+
$('card').querySelectorAll('.cu[data-c]').forEach((el) => countUp(el, +el.dataset.c));
343380
}
344381

345382
// every click inside the card stays client-side: tx links fetch one tx, block links re-verify

0 commit comments

Comments
 (0)