From 4a91afc3ba5d274eaac96327261c3b37751b0e2a Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 25 Jul 2026 00:39:13 +0200 Subject: [PATCH 1/2] Bump pin: proof-of-work range checks + witness reserved-value guard Vendors bitcoin-desktop/schema PR #72 into the generated snapshot. - codec/codec.js checkProofOfWork now applies Core's target range checks (powLimit bound, overflow, negative, zero) before comparing the hash; adds expandCompactChecked and Codec.setChainParams - codec/headers.js HeaderEngine.fromSchemas binds the network ceiling onto the codec, so SpvEngine gets it without a signature change - codec/blocks.js guards a coinbase witness reserved value that is not exactly 32 bytes, which previously threw RangeError tools/sync-kernel.mjs carries the createKernel() literal, so the network parameter and setChainParams call are updated there too; packages/kernel/index.js is the regenerated output and matches. Not produced by re-running sync-kernel.mjs: its wasm-loader rewrite is not idempotent (the `loadSecpWasm(url = [^)]*)` pattern re-matches its own output and mangles it), so wasm/ was left untouched and the three codec files were copied verbatim, which is what a clean sync would emit. Worth fixing that regex separately. No schema/*.jsonld changed in #72, so no schema modules regenerate. DO NOT MERGE until #72 lands; the PIN comment needs the merge SHA. Refs #5, #6 --- packages/kernel/codec/blocks.js | 1 + packages/kernel/codec/codec.js | 43 ++++++++++++++++++++++++++++++-- packages/kernel/codec/headers.js | 3 +++ packages/kernel/index.js | 9 ++++--- tools/sync-kernel.mjs | 12 +++++---- 5 files changed, 57 insertions(+), 11 deletions(-) diff --git a/packages/kernel/codec/blocks.js b/packages/kernel/codec/blocks.js index d38a471..93974b0 100644 --- a/packages/kernel/codec/blocks.js +++ b/packages/kernel/codec/blocks.js @@ -227,6 +227,7 @@ export class BlockEngine { i === 0 ? NULL_TXID : this.codec.wtxid(tx)); const root = hexToBytes(this.codec.merkleRoot(wtxids)).reverse(); const reserved = hexToBytes(block.transactions[0].witness?.[0]?.[0] ?? '00'.repeat(32)); + if (reserved.length !== 32) return null; // malformed reserved value can never match the commitment const cat = new Uint8Array(64); cat.set(root); cat.set(reserved, 32); return bytesToHex(dsha256(cat)); diff --git a/packages/kernel/codec/codec.js b/packages/kernel/codec/codec.js index ff0b666..28fd457 100644 --- a/packages/kernel/codec/codec.js +++ b/packages/kernel/codec/codec.js @@ -80,6 +80,10 @@ class Writer { export class Codec { constructor(...schemas) { this.types = new Map(); + // Network proof-of-work ceiling, as a BigInt. Null until chain params are + // supplied; checkProofOfWork skips only the network-specific bound while + // it is unset, never the universal range checks. + this.powLimit = null; for (const schema of schemas) { for (const node of schema['@graph'] ?? []) { if (node['@type'] === 'ConsensusStruct') this.types.set(shortId(node['@id']), node); @@ -87,6 +91,14 @@ export class Codec { } } + // Supply the network parameters the consensus checks need. Called by + // createKernel and by HeaderEngine.fromSchemas; safe to call directly when + // constructing a Codec by hand. + setChainParams(params) { + if (params?.powLimit != null) this.powLimit = BigInt('0x' + params.powLimit); + return this; + } + def(typeName) { const def = this.types.get(typeName); if (!def) throw new Error(`unknown type: ${typeName}`); @@ -275,7 +287,34 @@ export class Codec { return exponent <= 3 ? mantissa >> (8n * BigInt(3 - exponent)) : mantissa << (8n * BigInt(exponent - 3)); } - checkProofOfWork(header) { - return BigInt('0x' + this.blockHash(header)) <= this.expandCompact(header.bits); + // The target plus the two flags Bitcoin Core's arith_uint256::SetCompact + // reports. `negative` is the sign bit the mantissa mask discards; `overflow` + // marks an nBits whose target cannot fit in 256 bits. Both make a header + // invalid regardless of network. + expandCompactChecked(bits) { + const size = bits >>> 24; + const word = bits & 0x007fffff; + return { + target: this.expandCompact(bits), + negative: word !== 0 && (bits & 0x00800000) !== 0, + overflow: word !== 0 && (size > 34 + || (word > 0xff && size > 33) + || (word > 0xffff && size > 32)), + }; + } + + // Core's CheckProofOfWork: the target must be in range *before* the hash is + // compared against it. Without the range checks any nBits claiming an easier + // target than the network permits is accepted, which makes a proof of work + // free to forge. + // + // `powLimit` defaults to the value set on this codec (see `setChainParams`); + // when neither is available the network-specific bound cannot be applied, but + // the negative, zero and overflow checks always are. + checkProofOfWork(header, powLimit = this.powLimit) { + const { target, negative, overflow } = this.expandCompactChecked(header.bits); + if (negative || overflow || target === 0n) return false; + if (powLimit != null && target > powLimit) return false; + return BigInt('0x' + this.blockHash(header)) <= target; } } diff --git a/packages/kernel/codec/headers.js b/packages/kernel/codec/headers.js index 5184454..02f3c96 100644 --- a/packages/kernel/codec/headers.js +++ b/packages/kernel/codec/headers.js @@ -57,6 +57,9 @@ export class HeaderEngine { static fromSchemas(codec, chainSchema, validateSchema, network = 'btc:mainnet') { const params = chainSchema['@graph'].find((n) => n['@id'] === network); const ruleSet = validateSchema['@graph'].find((n) => n['@type'] === 'RuleSet' && n.phase === 'header'); + // The SPV ruleset has no difficulty rule, so btc:rule-spv-pow is the only + // work check there; the codec needs the ceiling to enforce it. + codec.setChainParams(params); return new HeaderEngine(codec, params, ruleSet); } diff --git a/packages/kernel/index.js b/packages/kernel/index.js index 3504904..41054a3 100644 --- a/packages/kernel/index.js +++ b/packages/kernel/index.js @@ -17,13 +17,14 @@ export * from './codec/hash.js'; export * from './codec/secp256k1.js'; export const schemas = { core, proof, script, chain, validate }; -export function createKernel() { +export function createKernel(network = 'btc:mainnet') { const codec = new Codec(core, proof); - const scriptEngine = ScriptEngine.fromSchemas(script, chain); + codec.setChainParams(chain['@graph'].find((n) => n['@id'] === network)); + const scriptEngine = ScriptEngine.fromSchemas(script, chain, network); const limits = script['@graph'].find((n) => n['@id'] === 'btc:scriptLimits'); const interpreter = new ScriptInterpreter(codec, scriptEngine, limits); - const headers = HeaderEngine.fromSchemas(codec, chain, validate); - const blocks = BlockEngine.fromSchemas(codec, chain, validate, script); + const headers = HeaderEngine.fromSchemas(codec, chain, validate, network); + const blocks = BlockEngine.fromSchemas(codec, chain, validate, script, network); const spv = SpvEngine.fromSchemas(codec, validate); return { codec, script: scriptEngine, interpreter, headers, blocks, spv, schemas }; } diff --git a/tools/sync-kernel.mjs b/tools/sync-kernel.mjs index f37cd2e..25a040f 100644 --- a/tools/sync-kernel.mjs +++ b/tools/sync-kernel.mjs @@ -3,7 +3,8 @@ // step: re-run this, review the diff, commit. The engine's canonical dev home // stays bitcoin-desktop/schema; this is a vendored copy. // -// PIN: bitcoin-desktop/schema @ b213128 (Fix #70 OP_CODESEPARATOR, PR #71) +// PIN: bitcoin-desktop/schema @ PENDING (PoW target range checks + witness +// reserved-value guard, PR #72) — update to the merge SHA before landing. // // Sources (override via env for non-default checkouts): // SCHEMA_DIR — the schema repo (codec/ + schema/*.jsonld) @@ -56,13 +57,14 @@ export * from './codec/hash.js'; export * from './codec/secp256k1.js'; export const schemas = { core, proof, script, chain, validate }; -export function createKernel() { +export function createKernel(network = 'btc:mainnet') { const codec = new Codec(core, proof); - const scriptEngine = ScriptEngine.fromSchemas(script, chain); + codec.setChainParams(chain['@graph'].find((n) => n['@id'] === network)); + const scriptEngine = ScriptEngine.fromSchemas(script, chain, network); const limits = script['@graph'].find((n) => n['@id'] === 'btc:scriptLimits'); const interpreter = new ScriptInterpreter(codec, scriptEngine, limits); - const headers = HeaderEngine.fromSchemas(codec, chain, validate); - const blocks = BlockEngine.fromSchemas(codec, chain, validate, script); + const headers = HeaderEngine.fromSchemas(codec, chain, validate, network); + const blocks = BlockEngine.fromSchemas(codec, chain, validate, script, network); const spv = SpvEngine.fromSchemas(codec, validate); return { codec, script: scriptEngine, interpreter, headers, blocks, spv, schemas }; } From 9936bf0d159f87e43819d90c4bbff07dca379ca8 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sat, 25 Jul 2026 00:49:55 +0200 Subject: [PATCH 2/2] Make the sync tool's wasm-loader rewrite idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The replacement text contains a ')' inside new URL(...), and the pattern [^)]* stops at the first one — so the regex matched its own output. Running the tool against an already-transformed loader produced loadSecpWasm(url = new URL('./secp256k1.wasm', import.meta.url).href).href) a syntax error that takes the whole package with it, written with no warning and the usual success line. This matters because WASM_DIR defaults to a sibling checkout absent from a plain clone, so the obvious workaround when regenerating is to point it at the existing packages/kernel/wasm — exactly the case that corrupts. Skips the rewrite when the loader is already in the target form, and now throws rather than silently emitting an unrewritten loader that would resolve the wasm against the page URL instead of the module. Closes #8 --- tools/sync-kernel.mjs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/sync-kernel.mjs b/tools/sync-kernel.mjs index 25a040f..c89d35f 100644 --- a/tools/sync-kernel.mjs +++ b/tools/sync-kernel.mjs @@ -33,8 +33,17 @@ for (const k of SCHEMAS) { // wasm: copy, and make the loader fetch the wasm co-located with the MODULE // (import.meta.url) so it works served from any URL / the CDN. await copyFile(new URL('secp256k1.wasm', WASM), new URL('wasm/secp256k1.wasm', OUT)); -const loader = (await readFile(new URL('secp-wasm.js', WASM), 'utf8')) - .replace(/loadSecpWasm\(url = [^)]*\)/, "loadSecpWasm(url = new URL('./secp256k1.wasm', import.meta.url).href)"); +const WASM_DEFAULT_URL = "loadSecpWasm(url = new URL('./secp256k1.wasm', import.meta.url).href)"; +let loader = await readFile(new URL('secp-wasm.js', WASM), 'utf8'); +// Idempotent: the replacement text contains a ')', so [^)]* matches this +// rewrite's own output and would corrupt it on a second pass. That happens +// whenever WASM_DIR points at a previously generated copy. +if (!loader.includes(WASM_DEFAULT_URL)) { + if (!/loadSecpWasm\(url = [^)]*\)/.test(loader)) { + throw new Error('secp-wasm.js: no loadSecpWasm(url = ...) default to rewrite'); + } + loader = loader.replace(/loadSecpWasm\(url = [^)]*\)/, WASM_DEFAULT_URL); +} await writeFile(new URL('wasm/secp-wasm.js', OUT), loader); // barrel + package.json